From 0e64cec8f15af7cf80221621ca951a710dff2bb5 Mon Sep 17 00:00:00 2001 From: Diana Huang <2952947+dianakhuang@users.noreply.github.com> Date: Thu, 9 Oct 2025 09:23:36 -0400 Subject: [PATCH 01/54] fix: Update workflows to work on the release-ulmo branch. (#1) --- .../add-depr-ticket-to-depr-board.yml | 19 ------------------ .../workflows/add-remove-label-on-comment.yml | 20 ------------------- .github/workflows/js-tests.yml | 2 +- .github/workflows/lint-imports.yml | 2 +- .github/workflows/lockfileversion-check.yml | 2 +- .github/workflows/migrations-check.yml | 2 +- .github/workflows/quality-checks.yml | 3 +-- .github/workflows/semgrep.yml | 2 +- .github/workflows/shellcheck.yml | 2 +- .github/workflows/static-assets-check.yml | 2 +- .github/workflows/unit-tests.yml | 2 +- .../units-test-scripts-structures-pruning.yml | 2 +- .../units-test-scripts-user-retirement.yml | 2 +- .../upgrade-one-python-dependency.yml | 2 +- .../workflows/upgrade-python-requirements.yml | 2 +- .github/workflows/verify-dunder-init.yml | 2 +- 16 files changed, 14 insertions(+), 54 deletions(-) delete mode 100644 .github/workflows/add-depr-ticket-to-depr-board.yml delete mode 100644 .github/workflows/add-remove-label-on-comment.yml diff --git a/.github/workflows/add-depr-ticket-to-depr-board.yml b/.github/workflows/add-depr-ticket-to-depr-board.yml deleted file mode 100644 index 250e394abc11..000000000000 --- a/.github/workflows/add-depr-ticket-to-depr-board.yml +++ /dev/null @@ -1,19 +0,0 @@ -# Run the workflow that adds new tickets that are either: -# - labelled "DEPR" -# - title starts with "[DEPR]" -# - body starts with "Proposal Date" (this is the first template field) -# to the org-wide DEPR project board - -name: Add newly created DEPR issues to the DEPR project board - -on: - issues: - types: [opened] - -jobs: - routeissue: - uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master - secrets: - GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }} - GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }} - SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }} diff --git a/.github/workflows/add-remove-label-on-comment.yml b/.github/workflows/add-remove-label-on-comment.yml deleted file mode 100644 index 0f369db7d293..000000000000 --- a/.github/workflows/add-remove-label-on-comment.yml +++ /dev/null @@ -1,20 +0,0 @@ -# This workflow runs when a comment is made on the ticket -# If the comment starts with "label: " it tries to apply -# the label indicated in rest of comment. -# If the comment starts with "remove label: ", it tries -# to remove the indicated label. -# Note: Labels are allowed to have spaces and this script does -# not parse spaces (as often a space is legitimate), so the command -# "label: really long lots of words label" will apply the -# label "really long lots of words label" - -name: Allows for the adding and removing of labels via comment - -on: - issue_comment: - types: [created] - -jobs: - add_remove_labels: - uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master - diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml index 94a1368e96a5..972435a820e3 100644 --- a/.github/workflows/js-tests.yml +++ b/.github/workflows/js-tests.yml @@ -4,7 +4,7 @@ on: pull_request: push: branches: - - master + - release-ulmo jobs: run_tests: diff --git a/.github/workflows/lint-imports.yml b/.github/workflows/lint-imports.yml index baf914298be2..17cc4ea0a935 100644 --- a/.github/workflows/lint-imports.yml +++ b/.github/workflows/lint-imports.yml @@ -4,7 +4,7 @@ on: pull_request: push: branches: - - master + - release-ulmo jobs: lint-imports: diff --git a/.github/workflows/lockfileversion-check.yml b/.github/workflows/lockfileversion-check.yml index 736f1f98de13..ed0b7f97de6d 100644 --- a/.github/workflows/lockfileversion-check.yml +++ b/.github/workflows/lockfileversion-check.yml @@ -5,7 +5,7 @@ name: Lockfile Version check on: push: branches: - - master + - release-ulmo pull_request: jobs: diff --git a/.github/workflows/migrations-check.yml b/.github/workflows/migrations-check.yml index cd4d09589c12..686d8d9086b0 100644 --- a/.github/workflows/migrations-check.yml +++ b/.github/workflows/migrations-check.yml @@ -5,7 +5,7 @@ on: pull_request: push: branches: - - master + - release-ulmo jobs: check_migrations: diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 3f4cbeeb4df9..ee67e3903569 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -4,8 +4,7 @@ on: pull_request: push: branches: - - master - - open-release/lilac.master + - release-ulmo jobs: run_tests: diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index 520cd23a678b..d9d32ab9d36d 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -9,7 +9,7 @@ on: pull_request: push: branches: - - master + - release-ulmo jobs: run_semgrep: diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index 2e5b04bcc2ff..a8df63a20d57 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -9,7 +9,7 @@ on: pull_request: push: branches: - - master + - release-ulmo permissions: contents: read diff --git a/.github/workflows/static-assets-check.yml b/.github/workflows/static-assets-check.yml index 43cb597c16d7..3a0afa76deb7 100644 --- a/.github/workflows/static-assets-check.yml +++ b/.github/workflows/static-assets-check.yml @@ -4,7 +4,7 @@ on: pull_request: push: branches: - - master + - release-ulmo jobs: static_assets_check: diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index d8b8c26cd049..b89a1b6eb9f3 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -4,7 +4,7 @@ on: pull_request: push: branches: - - master + - release-ulmo concurrency: # We only need to be running tests for the latest commit on each PR diff --git a/.github/workflows/units-test-scripts-structures-pruning.yml b/.github/workflows/units-test-scripts-structures-pruning.yml index 14a01b592308..ef408cfe66ec 100644 --- a/.github/workflows/units-test-scripts-structures-pruning.yml +++ b/.github/workflows/units-test-scripts-structures-pruning.yml @@ -4,7 +4,7 @@ on: pull_request: push: branches: - - master + - release-ulmo jobs: test: diff --git a/.github/workflows/units-test-scripts-user-retirement.yml b/.github/workflows/units-test-scripts-user-retirement.yml index 889c43a64a48..b43bbf46b0d4 100644 --- a/.github/workflows/units-test-scripts-user-retirement.yml +++ b/.github/workflows/units-test-scripts-user-retirement.yml @@ -4,7 +4,7 @@ on: pull_request: push: branches: - - master + - release-ulmo jobs: test: diff --git a/.github/workflows/upgrade-one-python-dependency.yml b/.github/workflows/upgrade-one-python-dependency.yml index 3f9678593c25..1d8170961865 100644 --- a/.github/workflows/upgrade-one-python-dependency.yml +++ b/.github/workflows/upgrade-one-python-dependency.yml @@ -6,7 +6,7 @@ on: branch: description: "Target branch to create requirements PR against" required: true - default: "master" + default: "release-ulmo" type: string package: description: "Name of package to upgrade" diff --git a/.github/workflows/upgrade-python-requirements.yml b/.github/workflows/upgrade-python-requirements.yml index cbb70b06b79d..90c6be00744c 100644 --- a/.github/workflows/upgrade-python-requirements.yml +++ b/.github/workflows/upgrade-python-requirements.yml @@ -8,7 +8,7 @@ on: branch: description: "Target branch to create requirements PR against" required: true - default: "master" + default: "release-ulmo" jobs: call-upgrade-python-requirements-workflow: # Don't run the weekly upgrade job on forks -- it will send a weekly failure email. diff --git a/.github/workflows/verify-dunder-init.yml b/.github/workflows/verify-dunder-init.yml index c3248def2f33..462f0e06af0b 100644 --- a/.github/workflows/verify-dunder-init.yml +++ b/.github/workflows/verify-dunder-init.yml @@ -3,7 +3,7 @@ name: Verify Dunder __init__.py Files on: pull_request: branches: - - master + - release-ulmo jobs: verify_dunder_init: From 5523072086200d70dd8d6bc10ef4598b1f808970 Mon Sep 17 00:00:00 2001 From: iloveagent57 <2307986+iloveagent57@users.noreply.github.com> Date: Fri, 10 Oct 2025 13:22:26 +0000 Subject: [PATCH 02/54] feat: Upgrade Python dependency edx-enterprise feat: add an endpoint to create a customer admin user Commit generated by workflow `openedx/edx-platform/.github/workflows/upgrade-one-python-dependency.yml@refs/heads/master` --- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index f78de7473150..b4fa1f404a4f 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -42,7 +42,7 @@ django-stubs<6 # 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==6.4.4 +edx-enterprise==6.5.0 # Date: 2023-07-26 # Our legacy Sass code is incompatible with anything except this ancient libsass version. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 730460139839..bf633e192747 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -473,7 +473,7 @@ edx-drf-extensions==10.6.0 # edxval # enterprise-integrated-channels # openedx-learning -edx-enterprise==6.4.4 +edx-enterprise==6.5.0 # via # -c requirements/constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 9df3845c2e62..8732a8c8649a 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -747,7 +747,7 @@ edx-drf-extensions==10.6.0 # edxval # enterprise-integrated-channels # openedx-learning -edx-enterprise==6.4.4 +edx-enterprise==6.5.0 # via # -c requirements/constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index faba8969f10b..dd626ceaeba5 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -557,7 +557,7 @@ edx-drf-extensions==10.6.0 # edxval # enterprise-integrated-channels # openedx-learning -edx-enterprise==6.4.4 +edx-enterprise==6.5.0 # via # -c requirements/constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 7de977c71684..9e4960d6edf2 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -578,7 +578,7 @@ edx-drf-extensions==10.6.0 # edxval # enterprise-integrated-channels # openedx-learning -edx-enterprise==6.4.4 +edx-enterprise==6.5.0 # via # -c requirements/constraints.txt # -r requirements/edx/base.txt From 5f94efcc89f5b90e1a2ffdce5fec57989a313a7f Mon Sep 17 00:00:00 2001 From: Nathan Sprenkle Date: Thu, 16 Oct 2025 10:31:40 -0400 Subject: [PATCH 03/54] chore: bump edxval to 3.1.0 (#37490) --- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index bf633e192747..194c693c9c78 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -553,7 +553,7 @@ edx-when==3.0.0 # via # -r requirements/edx/kernel.in # edx-proctoring -edxval==3.0.0 +edxval==3.1.0 # via -r requirements/edx/kernel.in elasticsearch==7.9.1 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 8732a8c8649a..c48117dbf35f 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -855,7 +855,7 @@ edx-when==3.0.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-proctoring -edxval==3.0.0 +edxval==3.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index dd626ceaeba5..06965ed48d0a 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -639,7 +639,7 @@ edx-when==3.0.0 # via # -r requirements/edx/base.txt # edx-proctoring -edxval==3.0.0 +edxval==3.1.0 # via -r requirements/edx/base.txt elasticsearch==7.9.1 # via diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 9e4960d6edf2..b7bbce06c6e1 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -662,7 +662,7 @@ edx-when==3.0.0 # via # -r requirements/edx/base.txt # edx-proctoring -edxval==3.0.0 +edxval==3.1.0 # via -r requirements/edx/base.txt elasticsearch==7.9.1 # via From 3736ac4698c62b5bfa672bad9bc60c59fac2b593 Mon Sep 17 00:00:00 2001 From: Raymond Zhou <56318341+rayzhou-bit@users.noreply.github.com> Date: Tue, 14 Oct 2025 18:32:48 -0400 Subject: [PATCH 04/54] feat: future proof artifact uploads (#37464) --- .github/workflows/unit-tests.yml | 10 +++++----- openedx/core/process_warnings.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index b89a1b6eb9f3..40f5dc9e71d7 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -124,26 +124,26 @@ jobs: shell: bash run: | cd test_root/log - mv pytest_warnings.json pytest_warnings_${{ matrix.shard_name }}.json + mv pytest_warnings.json pytest_warnings_${{ matrix.shard_name }}_${{ matrix.python-version }}_${{ matrix.django-version }}_${{ matrix.mongo-version }}_${{ matrix.os-version }}.json - name: save pytest warnings json file if: success() uses: actions/upload-artifact@v4 with: - name: pytest-warnings-json-${{ matrix.shard_name }} + name: pytest-warnings-json-${{ matrix.shard_name }}-${{ matrix.python-version }}-${{ matrix.django-version }}-${{ matrix.mongo-version }}-${{ matrix.os-version }} path: | test_root/log/pytest_warnings*.json overwrite: true - name: Renaming coverage data file run: | - mv reports/.coverage reports/${{ matrix.shard_name }}.coverage + mv reports/.coverage reports/${{ matrix.shard_name }}_${{ matrix.python-version }}_${{ matrix.django-version }}_${{ matrix.mongo-version }}_${{ matrix.os-version }}.coverage - name: Upload coverage uses: actions/upload-artifact@v4 with: - name: coverage-${{ matrix.shard_name }} - path: reports/${{ matrix.shard_name }}.coverage + name: coverage-${{ matrix.shard_name }}-${{ matrix.python-version }}-${{ matrix.django-version }}-${{ matrix.mongo-version }}-${{ matrix.os-version }} + path: reports/${{ matrix.shard_name }}_${{ matrix.python-version }}_${{ matrix.django-version }}_${{ matrix.mongo-version }}_${{ matrix.os-version }}.coverage overwrite: true collect-and-verify: diff --git a/openedx/core/process_warnings.py b/openedx/core/process_warnings.py index 6f695fa27796..712f0f1ea8f5 100644 --- a/openedx/core/process_warnings.py +++ b/openedx/core/process_warnings.py @@ -91,7 +91,7 @@ def read_warning_data(dir_path): # TODO(jinder): currently this is hard-coded in, maybe create a constants file with info # THINK(jinder): but creating file for one constant seems overkill warnings_file_name_regex = ( - r"pytest_warnings_?[\w-]*\.json" # noqa pylint: disable=W1401 + r"pytest_warnings_?[\w.-]*\.json" # noqa pylint: disable=W1401 ) # iterate through files_in_dir and see if they match our know file name pattern From 3b9f120f29b5771d6168a440f8c2a6a8a4651e9e Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Thu, 23 Oct 2025 14:24:44 +0500 Subject: [PATCH 05/54] fix: pasting a component with image isn't working - when copying a component that has image in it, and we try to paste it. Image URL appends `static_None`. Result in crash or image not found error. - In this commit we have fixed this scenario, copy paste is working for components containing images. --- cms/djangoapps/contentstore/helpers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py index 2bdbabc7df8c..2cc7ba94e748 100644 --- a/cms/djangoapps/contentstore/helpers.py +++ b/cms/djangoapps/contentstore/helpers.py @@ -747,8 +747,8 @@ def _import_file_into_course( contentstore().save(content) return True, {clipboard_file_path: f"static/{import_path}"} elif current_file.content_digest == file_data_obj.md5_hash: - # The file already exists and matches exactly, so no action is needed except substitutions - return None, {clipboard_file_path: f"static/{import_path}"} + # The file already exists and matches exactly, so no action is needed + return None, {} else: # There is a conflict with some other file that has the same name. return False, {} From df7fccfbf0eae6ce59ac1c20d57c6959095460ce Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Thu, 23 Oct 2025 23:08:51 +0500 Subject: [PATCH 06/54] fix: copy paste component from one course to another --- cms/djangoapps/contentstore/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py index 2cc7ba94e748..91236a4dade9 100644 --- a/cms/djangoapps/contentstore/helpers.py +++ b/cms/djangoapps/contentstore/helpers.py @@ -745,7 +745,7 @@ def _import_file_into_course( if thumbnail_content is not None: content.thumbnail_location = thumbnail_location contentstore().save(content) - return True, {clipboard_file_path: f"static/{import_path}"} + return True, {clipboard_file_path: filename if not import_path else f"static/{import_path}"} elif current_file.content_digest == file_data_obj.md5_hash: # The file already exists and matches exactly, so no action is needed return None, {} From 712379309cbf8066137b4d664e63b732247e1db3 Mon Sep 17 00:00:00 2001 From: Deborah Kaplan Date: Fri, 24 Oct 2025 13:07:40 -0400 Subject: [PATCH 07/54] feat: allows a reversion of the retirement partner report reset toggle (#37539) * feat: allows a reversion of the retirement partner report reset toggle This allows you to set retirement partner report statuses to True as well as to False. One sample use case: if an overly large number of retirement partner reports have their status reset to false, the partner report queue can struggle to deal with the large queue. FIXES: APER-4177 --- openedx/core/djangoapps/user_api/admin.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/openedx/core/djangoapps/user_api/admin.py b/openedx/core/djangoapps/user_api/admin.py index c1de6490edb9..f68d9f3e7dad 100644 --- a/openedx/core/djangoapps/user_api/admin.py +++ b/openedx/core/djangoapps/user_api/admin.py @@ -185,7 +185,7 @@ def user_id(self, obj): """ return obj.user.id - def reset_state(self, request, queryset): + def reset_state_false(self, request, queryset): """ Action callback for bulk resetting is_being_processed to False (0). """ @@ -194,9 +194,22 @@ def reset_state(self, request, queryset): message_bit = "one user was" else: message_bit = "%s users were" % rows_updated - self.message_user(request, "%s successfully reset." % message_bit) + self.message_user(request, "%s successfully reset to False." % message_bit) - reset_state.short_description = 'Reset is_being_processed to False' + reset_state_false.short_description = "Reset is_being_processed to False" + + def reset_state_true(self, request, queryset): + """ + Action callback for bulk resetting is_being_processed to True (1). + """ + rows_updated = queryset.update(is_being_processed=1) + if rows_updated == 1: + message_bit = "one user was" + else: + message_bit = "%s users were" % rows_updated + self.message_user(request, "%s successfully reset to True." % message_bit) + + reset_state_true.short_description = "Reset is_being_processed to True" @admin.register(BulkUserRetirementConfig) From 65979bceaf488ed7799daa254bb3ced961b31aab Mon Sep 17 00:00:00 2001 From: Deborah Kaplan Date: Mon, 27 Oct 2025 13:25:38 -0400 Subject: [PATCH 08/54] feat: display the reset toggles for a report (#37556) One retirement partner status report admin toggle has being renamed, and another has been added. This PR displays them on the appropriate django admin page. --- openedx/core/djangoapps/user_api/admin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/user_api/admin.py b/openedx/core/djangoapps/user_api/admin.py index f68d9f3e7dad..228a9a5bc451 100644 --- a/openedx/core/djangoapps/user_api/admin.py +++ b/openedx/core/djangoapps/user_api/admin.py @@ -170,7 +170,8 @@ class UserRetirementPartnerReportingStatusAdmin(admin.ModelAdmin): raw_id_fields = ('user',) search_fields = ('user__id', 'original_username', 'original_email', 'original_name') actions = [ - 'reset_state', # See reset_state() below. + 'reset_state_false', + 'reset_state_true', ] class Meta: From d61802121e80d24a18efaecdc51327f2570daf39 Mon Sep 17 00:00:00 2001 From: Krish Tyagi Date: Wed, 29 Oct 2025 12:39:33 +0530 Subject: [PATCH 09/54] fix: Improve SAML configuration checks and update warning messages (#37377) (#18) - Removes custom attributes for report. Uses report output only. - Adds a count for disabled SAML configs. - Displays disabled status of provider. - Slug mismatch now informational only (rather than warning) * Cleans up unit tests. --- .../management/commands/saml.py | 156 +++++++----- .../management/commands/tests/test_saml.py | 240 ++++++++++++------ 2 files changed, 257 insertions(+), 139 deletions(-) diff --git a/common/djangoapps/third_party_auth/management/commands/saml.py b/common/djangoapps/third_party_auth/management/commands/saml.py index afe369c2ade0..6865ebf69987 100644 --- a/common/djangoapps/third_party_auth/management/commands/saml.py +++ b/common/djangoapps/third_party_auth/management/commands/saml.py @@ -6,7 +6,6 @@ import logging from django.core.management.base import BaseCommand, CommandError -from edx_django_utils.monitoring import set_custom_attribute from common.djangoapps.third_party_auth.tasks import fetch_saml_metadata from common.djangoapps.third_party_auth.models import SAMLProviderConfig, SAMLConfiguration @@ -71,31 +70,28 @@ def _handle_run_checks(self): """ Handle the --run-checks option for checking SAMLProviderConfig configuration issues. - This is a report-only command. It identifies potential configuration problems such as: - - Outdated SAMLConfiguration references (provider pointing to old config version) - - Site ID mismatches between SAMLProviderConfig and its SAMLConfiguration - - Slug mismatches (except 'default' slugs) # noqa: E501 - - SAMLProviderConfig objects with null SAMLConfiguration references (informational) - - Includes observability attributes for monitoring. + This is a report-only command that identifies potential configuration problems. """ - # Set custom attributes for monitoring the check operation - # .. custom_attribute_name: saml_management_command.operation - # .. custom_attribute_description: Records current SAML operation ('run_checks'). - set_custom_attribute('saml_management_command.operation', 'run_checks') - metrics = self._check_provider_configurations() self._report_check_summary(metrics) def _check_provider_configurations(self): """ - Check each provider configuration for potential issues. + Check each provider configuration for potential issues: + - Outdated configuration references + - Site ID mismatches + - Missing configurations (no direct config and no default) + - Disabled providers and configurations + Also reports informational data such as slug mismatches. + + See code comments near each log output for possible resolution details. Returns a dictionary of metrics about the found issues. """ outdated_count = 0 site_mismatch_count = 0 slug_mismatch_count = 0 null_config_count = 0 + disabled_config_count = 0 error_count = 0 total_providers = 0 @@ -107,53 +103,74 @@ def _check_provider_configurations(self): for provider_config in provider_configs: total_providers += 1 + + # Check if provider is disabled + provider_disabled = not provider_config.enabled + disabled_status = ", enabled=False" if provider_disabled else "" + provider_info = ( - f"Provider (id={provider_config.id}, name={provider_config.name}, " - f"slug={provider_config.slug}, site_id={provider_config.site_id})" + f"Provider (id={provider_config.id}, " + f"name={provider_config.name}, slug={provider_config.slug}, " + f"site_id={provider_config.site_id}{disabled_status})" ) - if not provider_config.saml_configuration: - self.stdout.write( - f"[INFO] {provider_info} has no SAML configuration because " - "a matching default was not found." - ) - null_config_count += 1 - continue + # Provider disabled status is already included in provider_info format try: + if not provider_config.saml_configuration: + null_config_count, disabled_config_count = self._check_no_config( + provider_config, provider_info, null_config_count, disabled_config_count + ) + continue + + # Check if SAML configuration is disabled + if not provider_config.saml_configuration.enabled: + # Resolution: Enable the SAML configuration in Django admin + # or assign a different configuration + self.stdout.write( + f"[WARNING] {provider_info} " + f"has SAML config (id={provider_config.saml_configuration_id}, enabled=False)." + ) + disabled_config_count += 1 + + # Check configuration currency current_config = SAMLConfiguration.current( provider_config.saml_configuration.site_id, provider_config.saml_configuration.slug ) - # Check for outdated configuration references - if current_config: - if current_config.id != provider_config.saml_configuration_id: - self.stdout.write( - f"[WARNING] {provider_info} " - f"has outdated SAML config (id={provider_config.saml_configuration_id} which " - f"should be updated to the current SAML config (id={current_config.id})." - ) - outdated_count += 1 + if current_config and (current_config.id != provider_config.saml_configuration_id): + # Resolution: Update the provider's saml_configuration_id to the current config ID + self.stdout.write( + f"[WARNING] {provider_info} " + f"has outdated SAML config (id={provider_config.saml_configuration_id}) which " + f"should be updated to the current SAML config (id={current_config.id})." + ) + outdated_count += 1 + # Check site ID match if provider_config.saml_configuration.site_id != provider_config.site_id: config_site_id = provider_config.saml_configuration.site_id - provider_site_id = provider_config.site_id + # Resolution: Create a new SAML configuration for the correct site + # or move the provider to the matching site self.stdout.write( f"[WARNING] {provider_info} " - f"SAML config (id={provider_config.saml_configuration_id}, site_id={config_site_id}) " - "does not match the provider's site_id." + f"SAML config (id={provider_config.saml_configuration_id}, " + f"site_id={config_site_id}) does not match the provider's site_id." ) site_mismatch_count += 1 - saml_configuration_slug = provider_config.saml_configuration.slug - provider_config_slug = provider_config.slug - - if saml_configuration_slug not in (provider_config_slug, 'default'): + # Check slug match + if provider_config.saml_configuration.slug not in (provider_config.slug, 'default'): + config_id = provider_config.saml_configuration_id + saml_configuration_slug = provider_config.saml_configuration.slug + config_disabled_status = ", enabled=False" if not provider_config.saml_configuration.enabled else "" + # Resolution: This is informational only - provider can use + # a different slug configuration self.stdout.write( - f"[WARNING] {provider_info} " - f"SAML config (id={provider_config.saml_configuration_id}, slug='{saml_configuration_slug}') " - "does not match the provider's slug." + f"[INFO] {provider_info} has " + f"SAML config (id={config_id}, slug='{saml_configuration_slug}'{config_disabled_status}) " + "that does not match the provider's slug." ) slug_mismatch_count += 1 @@ -165,41 +182,64 @@ def _check_provider_configurations(self): 'total_providers': {'count': total_providers, 'requires_attention': False}, 'outdated_count': {'count': outdated_count, 'requires_attention': True}, 'site_mismatch_count': {'count': site_mismatch_count, 'requires_attention': True}, - 'slug_mismatch_count': {'count': slug_mismatch_count, 'requires_attention': True}, + 'slug_mismatch_count': {'count': slug_mismatch_count, 'requires_attention': False}, 'null_config_count': {'count': null_config_count, 'requires_attention': False}, + 'disabled_config_count': {'count': disabled_config_count, 'requires_attention': True}, 'error_count': {'count': error_count, 'requires_attention': True}, } - for key, metric_data in metrics.items(): - # .. custom_attribute_name: saml_management_command.{key} - # .. custom_attribute_description: Records metrics from SAML configuration checks. - set_custom_attribute(f'saml_management_command.{key}', metric_data['count']) - return metrics + def _check_no_config(self, provider_config, provider_info, null_config_count, disabled_config_count): + """Helper to check providers with no direct SAML configuration.""" + default_config = SAMLConfiguration.current(provider_config.site_id, 'default') + if not default_config or default_config.id is None: + # Resolution: Create/Link a SAML configuration for this provider + # or create/link a default configuration for the site + self.stdout.write( + f"[WARNING] {provider_info} has no direct SAML configuration and " + "no matching default configuration was found." + ) + null_config_count += 1 + + elif not default_config.enabled: + # Resolution: Enable the provider's linked SAML configuration + # or create/link a specific configuration for this provider + self.stdout.write( + f"[WARNING] {provider_info} has no direct SAML configuration and " + f"the default configuration (id={default_config.id}, enabled=False)." + ) + disabled_config_count += 1 + + return null_config_count, disabled_config_count + def _report_check_summary(self, metrics): """ - Print a summary of the check results and set the total_requiring_attention custom attribute. + Print a summary of the check results. """ total_requiring_attention = sum( metric_data['count'] for metric_data in metrics.values() if metric_data['requires_attention'] ) - # .. custom_attribute_name: saml_management_command.total_requiring_attention - # .. custom_attribute_description: The total number of configuration issues requiring attention. - set_custom_attribute('saml_management_command.total_requiring_attention', total_requiring_attention) - self.stdout.write(self.style.SUCCESS("CHECK SUMMARY:")) self.stdout.write(f" Providers checked: {metrics['total_providers']['count']}") - self.stdout.write(f" Null configs: {metrics['null_config_count']['count']}") + self.stdout.write("") + + # Informational only section + self.stdout.write("Informational only:") + self.stdout.write(f" Slug mismatches: {metrics['slug_mismatch_count']['count']}") + self.stdout.write(f" Missing configs: {metrics['null_config_count']['count']}") + self.stdout.write("") + # Issues requiring attention section if total_requiring_attention > 0: - self.stdout.write("\nIssues requiring attention:") + self.stdout.write("Issues requiring attention:") self.stdout.write(f" Outdated: {metrics['outdated_count']['count']}") self.stdout.write(f" Site mismatches: {metrics['site_mismatch_count']['count']}") - self.stdout.write(f" Slug mismatches: {metrics['slug_mismatch_count']['count']}") + self.stdout.write(f" Disabled configs: {metrics['disabled_config_count']['count']}") self.stdout.write(f" Errors: {metrics['error_count']['count']}") - self.stdout.write(f"\nTotal issues requiring attention: {total_requiring_attention}") + self.stdout.write("") + self.stdout.write(f"Total issues requiring attention: {total_requiring_attention}") else: - self.stdout.write(self.style.SUCCESS("\nNo configuration issues found!")) + self.stdout.write(self.style.SUCCESS("No configuration issues found!")) diff --git a/common/djangoapps/third_party_auth/management/commands/tests/test_saml.py b/common/djangoapps/third_party_auth/management/commands/tests/test_saml.py index 6963d5dcd0d5..d80c9146664b 100644 --- a/common/djangoapps/third_party_auth/management/commands/tests/test_saml.py +++ b/common/djangoapps/third_party_auth/management/commands/tests/test_saml.py @@ -79,6 +79,7 @@ def setUp(self): name='TestShib College', entity_id='https://idp.testshib.org/idp/shibboleth', metadata_source='https://www.testshib.org/metadata/testshib-providers.xml', + saml_configuration=self.saml_config, ) def _setup_test_configs_for_run_checks(self): @@ -337,8 +338,30 @@ def _run_checks_command(self): call_command('saml', '--run-checks', stdout=out) return out.getvalue() - @mock.patch('common.djangoapps.third_party_auth.management.commands.saml.set_custom_attribute') - def test_run_checks_outdated_configs(self, mock_set_custom_attribute): + def test_run_checks_setup_test_data(self): + """ + Test the --run-checks command against initial setup test data. + + This test validates that the base setup data (from setUp) is correctly + identified as having configuration issues. The setup includes a provider + (self.provider_config) with a disabled SAML configuration (self.saml_config), + which is reported as a disabled config issue (not a missing config). + """ + output = self._run_checks_command() + + # The setup data includes a provider with a disabled SAML config + expected_warning = ( + f'[WARNING] Provider (id={self.provider_config.id}, ' + f'name={self.provider_config.name}, ' + f'slug={self.provider_config.slug}, ' + f'site_id={self.provider_config.site_id}) ' + f'has SAML config (id={self.saml_config.id}, enabled=False).' + ) + self.assertIn(expected_warning, output) + self.assertIn('Missing configs: 0', output) # No missing configs from setUp + self.assertIn('Disabled configs: 1', output) # From setUp: provider_config with disabled saml_config + + def test_run_checks_outdated_configs(self): """ Test the --run-checks command identifies outdated configurations. """ @@ -346,31 +369,18 @@ def test_run_checks_outdated_configs(self, mock_set_custom_attribute): output = self._run_checks_command() - self.assertIn('[WARNING]', output) - self.assertIn('test-provider', output) - self.assertIn( - f'id={old_config.id} which should be updated to the current SAML config (id={new_config.id})', - output + expected_warning = ( + f'[WARNING] Provider (id={test_provider_config.id}, name={test_provider_config.name}, ' + f'slug={test_provider_config.slug}, site_id={test_provider_config.site_id}) ' + f'has outdated SAML config (id={old_config.id}) which should be updated to ' + f'the current SAML config (id={new_config.id}).' ) - self.assertIn('CHECK SUMMARY:', output) - self.assertIn('Providers checked: 2', output) + self.assertIn(expected_warning, output) self.assertIn('Outdated: 1', output) + # Total includes: 1 outdated + 2 disabled configs (setUp + test's old_config which is also disabled) + self.assertIn('Total issues requiring attention: 3', output) - # Check key observability calls - expected_calls = [ - mock.call('saml_management_command.operation', 'run_checks'), - mock.call('saml_management_command.total_providers', 2), - mock.call('saml_management_command.outdated_count', 1), - mock.call('saml_management_command.site_mismatch_count', 0), - mock.call('saml_management_command.slug_mismatch_count', 1), - mock.call('saml_management_command.null_config_count', 1), - mock.call('saml_management_command.error_count', 0), - mock.call('saml_management_command.total_requiring_attention', 2), - ] - mock_set_custom_attribute.assert_has_calls(expected_calls, any_order=False) - - @mock.patch('common.djangoapps.third_party_auth.management.commands.saml.set_custom_attribute') - def test_run_checks_site_mismatches(self, mock_set_custom_attribute): + def test_run_checks_site_mismatches(self): """ Test the --run-checks command identifies site ID mismatches. """ @@ -380,7 +390,7 @@ def test_run_checks_site_mismatches(self, mock_set_custom_attribute): entity_id='https://example.com' ) - SAMLProviderConfigFactory.create( + provider = SAMLProviderConfigFactory.create( site=self.site, slug='test-provider', saml_configuration=config @@ -388,25 +398,17 @@ def test_run_checks_site_mismatches(self, mock_set_custom_attribute): output = self._run_checks_command() - self.assertIn('[WARNING]', output) - self.assertIn('test-provider', output) - self.assertIn('does not match the provider\'s site_id', output) - - # Check observability calls - expected_calls = [ - mock.call('saml_management_command.operation', 'run_checks'), - mock.call('saml_management_command.total_providers', 2), - mock.call('saml_management_command.outdated_count', 0), - mock.call('saml_management_command.site_mismatch_count', 1), - mock.call('saml_management_command.slug_mismatch_count', 1), - mock.call('saml_management_command.null_config_count', 1), - mock.call('saml_management_command.error_count', 0), - mock.call('saml_management_command.total_requiring_attention', 2), - ] - mock_set_custom_attribute.assert_has_calls(expected_calls, any_order=False) - - @mock.patch('common.djangoapps.third_party_auth.management.commands.saml.set_custom_attribute') - def test_run_checks_slug_mismatches(self, mock_set_custom_attribute): + expected_warning = ( + f'[WARNING] Provider (id={provider.id}, name={provider.name}, ' + f'slug={provider.slug}, site_id={provider.site_id}) ' + f'SAML config (id={config.id}, site_id={config.site_id}) does not match the provider\'s site_id.' + ) + self.assertIn(expected_warning, output) + self.assertIn('Site mismatches: 1', output) + # Total includes: 1 site mismatch + 1 disabled config (from setUp) + self.assertIn('Total issues requiring attention: 2', output) + + def test_run_checks_slug_mismatches(self): """ Test the --run-checks command identifies slug mismatches. """ @@ -416,7 +418,7 @@ def test_run_checks_slug_mismatches(self, mock_set_custom_attribute): entity_id='https://example.com' ) - SAMLProviderConfigFactory.create( + provider = SAMLProviderConfigFactory.create( site=self.site, slug='provider-slug', saml_configuration=config @@ -424,29 +426,23 @@ def test_run_checks_slug_mismatches(self, mock_set_custom_attribute): output = self._run_checks_command() - self.assertIn('[WARNING]', output) - self.assertIn('provider-slug', output) - self.assertIn('does not match the provider\'s slug', output) - - # Check observability calls - expected_calls = [ - mock.call('saml_management_command.operation', 'run_checks'), - mock.call('saml_management_command.total_providers', 2), - mock.call('saml_management_command.outdated_count', 0), - mock.call('saml_management_command.site_mismatch_count', 0), - mock.call('saml_management_command.slug_mismatch_count', 1), - mock.call('saml_management_command.null_config_count', 1), - mock.call('saml_management_command.error_count', 0), - mock.call('saml_management_command.total_requiring_attention', 1), - ] - mock_set_custom_attribute.assert_has_calls(expected_calls, any_order=False) - - @mock.patch('common.djangoapps.third_party_auth.management.commands.saml.set_custom_attribute') - def test_run_checks_null_configurations(self, mock_set_custom_attribute): + expected_info = ( + f'[INFO] Provider (id={provider.id}, name={provider.name}, ' + f'slug={provider.slug}, site_id={provider.site_id}) ' + f'has SAML config (id={config.id}, slug=\'{config.slug}\') ' + f'that does not match the provider\'s slug.' + ) + self.assertIn(expected_info, output) + self.assertIn('Slug mismatches: 1', output) + + def test_run_checks_null_configurations(self): """ Test the --run-checks command identifies providers with null configurations. + This test verifies that providers with no direct SAML configuration and no + default configuration available are properly reported. """ - SAMLProviderConfigFactory.create( + # Create a provider with no SAML configuration on a site that has no default config + provider = SAMLProviderConfigFactory.create( site=self.site, slug='null-provider', saml_configuration=None @@ -454,19 +450,101 @@ def test_run_checks_null_configurations(self, mock_set_custom_attribute): output = self._run_checks_command() - self.assertIn('[INFO]', output) - self.assertIn('null-provider', output) - self.assertIn('has no SAML configuration because a matching default was not found', output) - - # Check observability calls - expected_calls = [ - mock.call('saml_management_command.operation', 'run_checks'), - mock.call('saml_management_command.total_providers', 2), - mock.call('saml_management_command.outdated_count', 0), - mock.call('saml_management_command.site_mismatch_count', 0), - mock.call('saml_management_command.slug_mismatch_count', 0), - mock.call('saml_management_command.null_config_count', 2), - mock.call('saml_management_command.error_count', 0), - mock.call('saml_management_command.total_requiring_attention', 0), - ] - mock_set_custom_attribute.assert_has_calls(expected_calls, any_order=False) + expected_warning = ( + f'[WARNING] Provider (id={provider.id}, name={provider.name}, ' + f'slug={provider.slug}, site_id={provider.site_id}) ' + f'has no direct SAML configuration and no matching default configuration was found.' + ) + self.assertIn(expected_warning, output) + self.assertIn('Missing configs: 1', output) # 1 from this test (provider with no config and no default) + self.assertIn('Disabled configs: 1', output) # 1 from setUp data + + def test_run_checks_null_config_id(self): + """ + Test the --run-checks command identifies providers with disabled default configurations. + When a provider has no direct SAML configuration and the default config is disabled, + it should be reported as a missing config issue. + """ + # Create a disabled default configuration for this site + disabled_default_config = SAMLConfigurationFactory.create( + site=self.site, + slug='default', + entity_id='https://default.example.com', + enabled=False + ) + + # Create a provider with no direct SAML configuration + # It will fall back to the disabled default config + provider = SAMLProviderConfigFactory.create( + site=self.site, + slug='null-id-provider', + saml_configuration=None + ) + + output = self._run_checks_command() + + expected_warning = ( + f'[WARNING] Provider (id={provider.id}, name={provider.name}, ' + f'slug={provider.slug}, site_id={provider.site_id}) ' + f'has no direct SAML configuration and the default configuration ' + f'(id={disabled_default_config.id}, enabled=False).' + ) + self.assertIn(expected_warning, output) + self.assertIn('Missing configs: 0', output) # No missing configs since default config exists + self.assertIn('Disabled configs: 2', output) # 1 from this test + 1 from setUp data + + def test_run_checks_with_default_config(self): + """ + Test the --run-checks command correctly handles providers with default configurations. + """ + provider = SAMLProviderConfigFactory.create( + site=self.site, + slug='default-config-provider', + saml_configuration=None + ) + + default_config = SAMLConfigurationFactory.create( + site=self.site, + slug='default', + entity_id='https://default.example.com' + ) + + output = self._run_checks_command() + + self.assertIn('Missing configs: 0', output) # This tests provider has valid default config + self.assertIn('Disabled configs: 1', output) # From setUp + + def test_run_checks_disabled_functionality(self): + """ + Test the --run-checks command handles disabled providers and configurations. + """ + disabled_provider = SAMLProviderConfigFactory.create( + site=self.site, + slug='disabled-provider', + enabled=False + ) + + disabled_config = SAMLConfigurationFactory.create( + site=self.site, + slug='disabled-config', + enabled=False + ) + + provider_with_disabled_config = SAMLProviderConfigFactory.create( + site=self.site, + slug='provider-with-disabled-config', + saml_configuration=disabled_config + ) + + output = self._run_checks_command() + + expected_warning = ( + f'[WARNING] Provider (id={provider_with_disabled_config.id}, ' + f'name={provider_with_disabled_config.name}, ' + f'slug={provider_with_disabled_config.slug}, ' + f'site_id={provider_with_disabled_config.site_id}) ' + f'has SAML config (id={disabled_config.id}, enabled=False).' + ) + self.assertIn(expected_warning, output) + self.assertIn('Missing configs: 1', output) # disabled_provider has no config + self.assertIn('Disabled configs: 2', output) # setUp's provider + provider_with_disabled_config From 43f31d8f0e96aafe45c5fd00c5b5137821b16224 Mon Sep 17 00:00:00 2001 From: Krish Tyagi Date: Wed, 29 Oct 2025 12:40:03 +0530 Subject: [PATCH 10/54] Merge pull request #37510 from openedx/feanil/fix_branding_redirect_loop (#17) --- lms/djangoapps/branding/tests/test_views.py | 14 ++++++++++++++ lms/djangoapps/branding/views.py | 8 +++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/branding/tests/test_views.py b/lms/djangoapps/branding/tests/test_views.py index 36ebcd73509e..10c27192d11a 100644 --- a/lms/djangoapps/branding/tests/test_views.py +++ b/lms/djangoapps/branding/tests/test_views.py @@ -269,6 +269,20 @@ def test_index_does_not_redirect_without_site_override(self): response = self.client.get(reverse("root")) assert response.status_code == 200 + @override_settings(ENABLE_MKTG_SITE=True) + @override_settings(MKTG_URLS={'ROOT': 'https://foo.bar/'}) + @override_settings(LMS_ROOT_URL='https://foo.bar/') + def test_index_wont_redirect_to_marketing_root_if_it_matches_lms_root(self): + response = self.client.get(reverse("root")) + assert response.status_code == 200 + + @override_settings(ENABLE_MKTG_SITE=True) + @override_settings(MKTG_URLS={'ROOT': 'https://home.foo.bar/'}) + @override_settings(LMS_ROOT_URL='https://foo.bar/') + def test_index_will_redirect_to_new_root_if_mktg_site_is_enabled(self): + response = self.client.get(reverse("root")) + assert response.status_code == 302 + def test_index_redirects_to_marketing_site_with_site_override(self): """ Test index view redirects if MKTG_URLS['ROOT'] is set in SiteConfiguration """ self.use_site(self.site_other) diff --git a/lms/djangoapps/branding/views.py b/lms/djangoapps/branding/views.py index 711adb85afec..33c5813f16ff 100644 --- a/lms/djangoapps/branding/views.py +++ b/lms/djangoapps/branding/views.py @@ -42,7 +42,7 @@ def index(request): # page to make it easier to browse for courses (and register) if configuration_helpers.get_value( 'ALWAYS_REDIRECT_HOMEPAGE_TO_DASHBOARD_FOR_AUTHENTICATED_USER', - settings.FEATURES.get('ALWAYS_REDIRECT_HOMEPAGE_TO_DASHBOARD_FOR_AUTHENTICATED_USER', True)): + getattr(settings, 'ALWAYS_REDIRECT_HOMEPAGE_TO_DASHBOARD_FOR_AUTHENTICATED_USER', True)): return redirect('dashboard') if use_catalog_mfe(): @@ -50,7 +50,7 @@ def index(request): enable_mktg_site = configuration_helpers.get_value( 'ENABLE_MKTG_SITE', - settings.FEATURES.get('ENABLE_MKTG_SITE', False) + getattr(settings, 'ENABLE_MKTG_SITE', False) ) if enable_mktg_site: @@ -58,7 +58,9 @@ def index(request): 'MKTG_URLS', settings.MKTG_URLS ) - return redirect(marketing_urls.get('ROOT')) + root_url = marketing_urls.get("ROOT") + if root_url != getattr(settings, "LMS_ROOT_URL", None): + return redirect(root_url) domain = request.headers.get('Host') From 45b72a020576297a4feff708179b956b04298b38 Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Wed, 29 Oct 2025 15:55:54 -0400 Subject: [PATCH 11/54] feat: add a default audio codec for the HLS video player (#37525) This seems to reduce instances of audio garbling when switching levels during HLS video streaming. --- xmodule/js/src/video/02_html5_hls_video.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/xmodule/js/src/video/02_html5_hls_video.js b/xmodule/js/src/video/02_html5_hls_video.js index cb6a1a2fda27..ce1db6ae068c 100644 --- a/xmodule/js/src/video/02_html5_hls_video.js +++ b/xmodule/js/src/video/02_html5_hls_video.js @@ -26,6 +26,12 @@ // do common initialization independent of player type this.init(el, config); + // set a default audio codec if not provided, this helps reduce issues + // switching audio codecs during playback + if (!this.config.defaultAudioCodec) { + this.config.defaultAudioCodec = "mp4a.40.5"; + } + _.bindAll(this, 'playVideo', 'pauseVideo', 'onReady'); // If we have only HLS sources and browser doesn't support HLS then show error message. From 6a43bcc9b7031ae3af897e7074257ea693308c82 Mon Sep 17 00:00:00 2001 From: Michael Roytman Date: Thu, 30 Oct 2025 09:03:05 -0400 Subject: [PATCH 12/54] fix: incorrect LTI exam due dates for self-paced courses These changes fix a bug in how LTI-based exam due dates are computed and written to the exams service. Prior to this change, an LTI exam due date was computed irrespective of the course pacing type. In certain cases, this caused incorrect due dates to be written to the exams service for LTI-based exams. For example, if a course team initially develops a course as an instructor-paced course and sets a due date on an exam subsection, that subsection due date is written to the modulestore. If the course team subsequently changes that course pacing type to self-paced, then that due date remains in the modulestore to allow course teams to switch pacing types without erasing due dates. The impact of this is that, when the course is published, the exam subsection due date is written to the exams service as the due date, even though there are no static due dates in a self-paced course. Frequently, these due dates are in the past (e.g. for course reruns), so learners automatically cannot access exams. Even if the due date is manually corrected in the exams service, every course publish reverts the due date to the incorrect due date. This change computes the due date of LTI-based exams as... * the exam subsection due date if the course is instructor-paced, if the subsection has a due date; else None * the course end date if the course is self-paced, if the course has an end date; else None In order to correct any incorrect due dates, course teams should republish their courses. --- cms/djangoapps/contentstore/exams.py | 10 ++-- .../contentstore/tests/test_exams.py | 48 +++++++++---------- 2 files changed, 27 insertions(+), 31 deletions(-) diff --git a/cms/djangoapps/contentstore/exams.py b/cms/djangoapps/contentstore/exams.py index 6b25147c6abc..8a4ddc09425e 100644 --- a/cms/djangoapps/contentstore/exams.py +++ b/cms/djangoapps/contentstore/exams.py @@ -74,13 +74,13 @@ def register_exams(course_key): # Exams in courses not using an LTI based proctoring provider should use the original definition of due_date # from contentstore/proctoring.py. These exams are powered by the edx-proctoring plugin and not the edx-exams # microservice. + is_instructor_paced = not course.self_paced if course.proctoring_provider == 'lti_external': - due_date = ( - timed_exam.due.isoformat() if timed_exam.due - else (course.end.isoformat() if course.end else None) - ) + due_date_source = timed_exam.due if is_instructor_paced else course.end else: - due_date = timed_exam.due if not course.self_paced else None + due_date_source = timed_exam.due if is_instructor_paced else None + + due_date = due_date_source.isoformat() if due_date_source else None exams_list.append({ 'course_id': str(course_key), diff --git a/cms/djangoapps/contentstore/tests/test_exams.py b/cms/djangoapps/contentstore/tests/test_exams.py index 798b5e51fd80..823038957714 100644 --- a/cms/djangoapps/contentstore/tests/test_exams.py +++ b/cms/djangoapps/contentstore/tests/test_exams.py @@ -65,16 +65,21 @@ def _get_exam_due_date(self, course, sequential): Return the expected exam due date for the exam, based on the selected course proctoring provider and the exam due date or the course end date. + This is a copy of the due date computation logic in register_exams function. + Arguments: * course: the course that the exam subsection is in; may have a course.end attribute * sequential: the exam subsection; may have a sequential.due attribute """ + is_instructor_paced = not course.self_paced if course.proctoring_provider == 'lti_external': - return sequential.due.isoformat() if sequential.due else (course.end.isoformat() if course.end else None) - elif course.self_paced: - return None + due_date_source = sequential.due if is_instructor_paced else course.end else: - return sequential.due + due_date_source = sequential.due if is_instructor_paced else None + + due_date = due_date_source.isoformat() if due_date_source else None + + return due_date @ddt.data(*(tuple(base) + (extra,) for base, extra in itertools.product( [ @@ -185,14 +190,13 @@ def test_feature_flag_off(self, mock_patch_course_exams): def test_no_due_dates(self, is_self_paced, course_end_date, proctoring_provider, mock_patch_course_exams): """ Test that the the correct due date is registered for the exam when the subsection does not have a due date, - depending on the proctoring provider. + depending on the proctoring provider and course pacing type. * lti_external - * The course end date is registered as the due date when the subsection does not have a due date for both - self-paced and instructor-paced exams. + * If the course is instructor-paced, the exam due date is the subsection due date if it exists, else None. + * If the course is self-paced, the exam due date is the course end date if it exists, else None. * not lti_external - * None is registered as the due date when the subsection does not have a due date for both - self-paced and instructor-paced exams. + * The exam due date is always the subsection due date if it exists, else None. """ self.course.self_paced = is_self_paced self.course.end = course_end_date @@ -222,25 +226,17 @@ def test_no_due_dates(self, is_self_paced, course_end_date, proctoring_provider, @ddt.data(*itertools.product((True, False), ('lti_external', 'null'))) @ddt.unpack @freeze_time('2024-01-01') - def test_subsection_due_date_prioritized(self, is_self_paced, proctoring_provider, mock_patch_course_exams): + def test_subsection_due_date_prioritized_instructor_paced( + self, + is_self_paced, + proctoring_provider, + mock_patch_course_exams + ): """ - Test that the subsection due date is registered as the due date when both the subsection has a due date and the - course has an end date for both self-paced and instructor-paced exams. - - Test that the the correct due date is registered for the exam when the subsection has a due date, depending on - the proctoring provider. - - * lti_external - * The subsection due date is registered as the due date when both the subsection has a due date and the - course has an end date for both self-paced and instructor-paced exams - * not lti_external - * None is registered as the due date when both the subsection has a due date and the course has an end date - for self-paced exams. - * The subsection due date is registered as the due date when both the subsection has a due date and the - course has an end date for instructor-paced exams. + Test that exam due date is computed correctly. """ self.course.self_paced = is_self_paced - self.course.end = datetime(2035, 1, 1, 0, 0) + self.course.end = datetime(2035, 1, 1, 0, 0, tzinfo=timezone.utc) self.course.proctoring_provider = proctoring_provider self.course = self.update_course(self.course, 1) @@ -260,7 +256,7 @@ def test_subsection_due_date_prioritized(self, is_self_paced, proctoring_provide ) listen_for_course_publish(self, self.course.id) - called_exams, called_course = mock_patch_course_exams.call_args[0] + called_exams, _ = mock_patch_course_exams.call_args[0] expected_due_date = self._get_exam_due_date(self.course, sequence) From bf4c7f676484fa876a963fd177b58ca923bb6b89 Mon Sep 17 00:00:00 2001 From: Nathan Sprenkle Date: Thu, 30 Oct 2025 14:31:33 -0400 Subject: [PATCH 13/54] fix: move default audio codec setting earlier to init correctly (#21) --- xmodule/js/src/video/02_html5_hls_video.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/xmodule/js/src/video/02_html5_hls_video.js b/xmodule/js/src/video/02_html5_hls_video.js index ce1db6ae068c..673ea5f96381 100644 --- a/xmodule/js/src/video/02_html5_hls_video.js +++ b/xmodule/js/src/video/02_html5_hls_video.js @@ -23,15 +23,15 @@ this.config = config; - // do common initialization independent of player type - this.init(el, config); - // set a default audio codec if not provided, this helps reduce issues // switching audio codecs during playback if (!this.config.defaultAudioCodec) { this.config.defaultAudioCodec = "mp4a.40.5"; } + // do common initialization independent of player type + this.init(el, config); + _.bindAll(this, 'playVideo', 'pauseVideo', 'onReady'); // If we have only HLS sources and browser doesn't support HLS then show error message. From fb3862279cb5b44a9be1ef752a7e49154d2beba9 Mon Sep 17 00:00:00 2001 From: Nathan Sprenkle Date: Thu, 30 Oct 2025 17:25:07 -0400 Subject: [PATCH 14/54] fix: video default audio codec fix (attempt 3) (#22) * fix: move defaultAudioCodec config earlier in init This causes it to get picked up in the places that it is actually needed to handle issues in audio quality switching. --- xmodule/js/src/video/02_html5_hls_video.js | 6 ------ xmodule/js/src/video/03_video_player.js | 5 ++++- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/xmodule/js/src/video/02_html5_hls_video.js b/xmodule/js/src/video/02_html5_hls_video.js index 673ea5f96381..cb6a1a2fda27 100644 --- a/xmodule/js/src/video/02_html5_hls_video.js +++ b/xmodule/js/src/video/02_html5_hls_video.js @@ -23,12 +23,6 @@ this.config = config; - // set a default audio codec if not provided, this helps reduce issues - // switching audio codecs during playback - if (!this.config.defaultAudioCodec) { - this.config.defaultAudioCodec = "mp4a.40.5"; - } - // do common initialization independent of player type this.init(el, config); diff --git a/xmodule/js/src/video/03_video_player.js b/xmodule/js/src/video/03_video_player.js index a2464cf40b84..6215d54689ad 100644 --- a/xmodule/js/src/video/03_video_player.js +++ b/xmodule/js/src/video/03_video_player.js @@ -182,7 +182,10 @@ onReadyHLS: function() { dfd.resolve(); }, videoSources: state.HLSVideoSources, canPlayHLS: state.canPlayHLS, - HLSOnlySources: state.HLSOnlySources + HLSOnlySources: state.HLSOnlySources, + // set a default audio codec if not provided, this helps reduce issues + // switching audio codecs during playback + defaultAudioCodec: "mp4a.40.5" }) ); // `loadedmetadata` event triggered too early on Safari due From 9a3e2881bbecae83fec20627ce18233a3788e619 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Fri, 31 Oct 2025 16:17:25 +0500 Subject: [PATCH 15/54] fix: accessibility issue on video transcripts --- xmodule/static/css-builtin-blocks/VideoBlockDisplay.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/xmodule/static/css-builtin-blocks/VideoBlockDisplay.css b/xmodule/static/css-builtin-blocks/VideoBlockDisplay.css index 7f95111469f0..c4f6b0c73612 100644 --- a/xmodule/static/css-builtin-blocks/VideoBlockDisplay.css +++ b/xmodule/static/css-builtin-blocks/VideoBlockDisplay.css @@ -708,6 +708,10 @@ line-height: lh(); } +.xmodule_display.xmodule_VideoBlock .video .subtitles .subtitles-menu li:has(> span:empty) { + display: none; +} + .xmodule_display.xmodule_VideoBlock .video .subtitles .subtitles-menu li span { display: block; } From 031eabb2916060c55babc012970eca3871d19738 Mon Sep 17 00:00:00 2001 From: naincy128 Date: Sat, 1 Nov 2025 15:47:12 +0000 Subject: [PATCH 16/54] fix: add text alternative for external link icon in LTI components --- lms/templates/lti.html | 1 + xmodule/js/fixtures/lti.html | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lms/templates/lti.html b/lms/templates/lti.html index 05346ec3dc40..d03b7e386740 100644 --- a/lms/templates/lti.html +++ b/lms/templates/lti.html @@ -37,6 +37,7 @@

% else: diff --git a/xmodule/js/fixtures/lti.html b/xmodule/js/fixtures/lti.html index 8c433d047b9d..7f5ed743b9ff 100644 --- a/xmodule/js/fixtures/lti.html +++ b/xmodule/js/fixtures/lti.html @@ -30,7 +30,12 @@ - + From c69d8938f7f33ad8f4de060de0f26040e856fa24 Mon Sep 17 00:00:00 2001 From: Sameen Fatima <55431213+sameenfatima78@users.noreply.github.com> Date: Thu, 23 Oct 2025 14:47:14 +0500 Subject: [PATCH 17/54] fix: do not autogenerate username if coming through SSO (#37522) Co-authored-by: Sameen Fatima --- common/djangoapps/third_party_auth/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index 496cfce93c1f..4b8804ca3802 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -1009,7 +1009,7 @@ def get_username(strategy, details, backend, user=None, *args, **kwargs): # lin else: slug_func = lambda val: val - if is_auto_generated_username_enabled(): + if is_auto_generated_username_enabled() and details.get('username') is None: username = get_auto_generated_username(details) else: if email_as_username and details.get('email'): From da54a1449673beecb8594d48c8772e0f79db73d4 Mon Sep 17 00:00:00 2001 From: sameeramin <35958006+sameeramin@users.noreply.github.com> Date: Wed, 5 Nov 2025 04:37:32 +0000 Subject: [PATCH 18/54] feat: Upgrade Python dependency enterprise-integrated-channels Commit generated by workflow `edx/edx-platform/.github/workflows/upgrade-one-python-dependency.yml@refs/heads/release-ulmo` --- requirements/common_constraints.txt | 9 ++++++++- requirements/edx/base.txt | 3 ++- requirements/edx/development.txt | 3 ++- requirements/edx/doc.txt | 3 ++- requirements/edx/testing.txt | 3 ++- requirements/pip.txt | 4 +++- scripts/user_retirement/requirements/base.txt | 1 + 7 files changed, 20 insertions(+), 6 deletions(-) diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt index 368f8fa81166..77cba92568da 100644 --- a/requirements/common_constraints.txt +++ b/requirements/common_constraints.txt @@ -16,9 +16,16 @@ # this file from Github directly. It does not require packaging in edx-lint. # using LTS django version - +Django<6.0 # elasticsearch>=7.14.0 includes breaking changes in it which caused issues in discovery upgrade process. # elastic search changelog: https://www.elastic.co/guide/en/enterprise-search/master/release-notes-7.14.0.html # See https://github.com/openedx/edx-platform/issues/35126 for more info elasticsearch<7.14.0 + +# pip 25.3 is incompatible with pip-tools hence causing failures during the build process +# Make upgrade command and all requirements upgrade jobs are broken due to this. +# See issue https://github.com/openedx/public-engineering/issues/440 for details regarding the ongoing fix. +# The constraint can be removed once a release (pip-tools > 7.5.1) is available with support for pip 25.3 +# Issue to track this dependency and unpin later on: https://github.com/openedx/edx-lint/issues/503 +pip<25.3 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 194c693c9c78..54ea6eda74df 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -169,6 +169,7 @@ defusedxml==0.7.1 # social-auth-core django==4.2.25 # via + # -c requirements/common_constraints.txt # -c requirements/constraints.txt # -r requirements/edx/kernel.in # django-appconf @@ -565,7 +566,7 @@ enmerkar==0.7.1 # via enmerkar-underscore enmerkar-underscore==2.4.0 # via -r requirements/edx/kernel.in -enterprise-integrated-channels==0.1.18 +enterprise-integrated-channels==0.1.22 # via -r requirements/edx/bundled.in event-tracking==3.3.0 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index c48117dbf35f..6c197d009ea7 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -333,6 +333,7 @@ distlib==0.4.0 # virtualenv django==4.2.25 # via + # -c requirements/common_constraints.txt # -c requirements/constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -876,7 +877,7 @@ enmerkar-underscore==2.4.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -enterprise-integrated-channels==0.1.18 +enterprise-integrated-channels==0.1.22 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 06965ed48d0a..ce385fec3950 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -227,6 +227,7 @@ defusedxml==0.7.1 # social-auth-core django==4.2.25 # via + # -c requirements/common_constraints.txt # -c requirements/constraints.txt # -r requirements/edx/base.txt # django-appconf @@ -654,7 +655,7 @@ enmerkar==0.7.1 # enmerkar-underscore enmerkar-underscore==2.4.0 # via -r requirements/edx/base.txt -enterprise-integrated-channels==0.1.18 +enterprise-integrated-channels==0.1.22 # via -r requirements/edx/base.txt event-tracking==3.3.0 # via diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index b7bbce06c6e1..3bfc7bbf0499 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -253,6 +253,7 @@ distlib==0.4.0 # via virtualenv django==4.2.25 # via + # -c requirements/common_constraints.txt # -c requirements/constraints.txt # -r requirements/edx/base.txt # django-appconf @@ -677,7 +678,7 @@ enmerkar==0.7.1 # enmerkar-underscore enmerkar-underscore==2.4.0 # via -r requirements/edx/base.txt -enterprise-integrated-channels==0.1.18 +enterprise-integrated-channels==0.1.22 # via -r requirements/edx/base.txt event-tracking==3.3.0 # via diff --git a/requirements/pip.txt b/requirements/pip.txt index dec15874f740..c6158d38e981 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -9,6 +9,8 @@ wheel==0.45.1 # The following packages are considered to be unsafe in a requirements file: pip==25.2 - # via -r requirements/pip.in + # via + # -c requirements/common_constraints.txt + # -r requirements/pip.in setuptools==80.9.0 # via -r requirements/pip.in diff --git a/scripts/user_retirement/requirements/base.txt b/scripts/user_retirement/requirements/base.txt index fd67805f02c0..0b208f8d6a7d 100644 --- a/scripts/user_retirement/requirements/base.txt +++ b/scripts/user_retirement/requirements/base.txt @@ -36,6 +36,7 @@ cryptography==45.0.7 # pyjwt django==4.2.25 # via + # -c requirements/common_constraints.txt # -c requirements/constraints.txt # django-crum # django-waffle From c2d2341460520c21c1db1422c593a24cf94cd30e Mon Sep 17 00:00:00 2001 From: Naincy Chourasia Date: Wed, 5 Nov 2025 11:49:24 +0530 Subject: [PATCH 19/54] feat: update new learner display and logic for improved visibility (#13) https://2u-internal.atlassian.net/jira/software/c/projects/COSMO2/boards/2821?selectedIssue=COSMO2-735 --- .../discussion/rest_api/serializers.py | 22 +++++ .../discussion/rest_api/tests/test_api_v2.py | 6 ++ .../rest_api/tests/test_serializers.py | 1 + .../discussion/rest_api/tests/test_views.py | 1 + .../rest_api/tests/test_views_v2.py | 1 + .../discussion/rest_api/tests/utils.py | 2 + lms/djangoapps/discussion/rest_api/utils.py | 84 +++++++++++++++++++ 7 files changed, 117 insertions(+) diff --git a/lms/djangoapps/discussion/rest_api/serializers.py b/lms/djangoapps/discussion/rest_api/serializers.py index 9c2668d0b226..8a7ab16e0903 100644 --- a/lms/djangoapps/discussion/rest_api/serializers.py +++ b/lms/djangoapps/discussion/rest_api/serializers.py @@ -37,6 +37,7 @@ get_course_staff_users_list, get_moderator_users_list, get_course_ta_users_list, + get_user_learner_status, ) from openedx.core.djangoapps.discussions.models import DiscussionTopicLink from openedx.core.djangoapps.discussions.utils import get_group_names_by_id @@ -182,6 +183,7 @@ class _ContentSerializer(serializers.Serializer): id = serializers.CharField(read_only=True) # pylint: disable=invalid-name author = serializers.SerializerMethodField() author_label = serializers.SerializerMethodField() + learner_status = serializers.SerializerMethodField() created_at = serializers.CharField(read_only=True) updated_at = serializers.CharField(read_only=True) raw_body = serializers.CharField(source="body", validators=[validate_not_blank]) @@ -275,6 +277,26 @@ def get_author_label(self, obj): user_id = int(obj["user_id"]) return self._get_user_label(user_id) + def get_learner_status(self, obj): + """ + Get the learner status for the discussion post author. + Returns one of: "anonymous", "staff", "new", "regular" + """ + # Skip for anonymous content + if self._is_anonymous(obj) or obj.get("user_id") is None: + return "anonymous" + + try: + user = User.objects.get(id=int(obj["user_id"])) + except (User.DoesNotExist, ValueError): + return "anonymous" + + course = self.context.get("course") + if not course: + return "anonymous" + + return get_user_learner_status(user, course.id) + def get_rendered_body(self, obj): """ Returns the rendered body content. diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py index f5b15b905639..53c12454aec9 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py @@ -363,6 +363,7 @@ def test_basic_in_blackout_period_with_user_access(self, mock_emit): "course_id": str(self.course.id), "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_id", "read": True, + "learner_status": "staff", "editable_fields": [ "abuse_flagged", "anonymous", @@ -689,6 +690,7 @@ def test_success(self, parent_id, mock_emit): "parent_id": parent_id, "author": self.user.username, "author_label": None, + "learner_status": "new", "created_at": "2015-05-27T00:00:00Z", "updated_at": "2015-05-27T00:00:00Z", "raw_body": "Test body", @@ -796,6 +798,7 @@ def test_success_in_black_out_with_user_access(self, parent_id, mock_emit): "parent_id": parent_id, "author": self.user.username, "author_label": "Moderator", + "learner_status": "staff", "created_at": "2015-05-27T00:00:00Z", "updated_at": "2015-05-27T00:00:00Z", "raw_body": "Test body", @@ -1799,6 +1802,7 @@ def test_basic(self, parent_id): "parent_id": parent_id, "author": self.user.username, "author_label": None, + "learner_status": "new", "created_at": "2015-06-03T00:00:00Z", "updated_at": "2015-06-03T00:00:00Z", "raw_body": "Edited body", @@ -3737,6 +3741,7 @@ def get_source_and_expected_comments(self): "parent_id": None, "author": self.author.username, "author_label": None, + "learner_status": "new", "created_at": "2015-05-11T00:00:00Z", "updated_at": "2015-05-11T11:11:11Z", "raw_body": "Test body", @@ -3771,6 +3776,7 @@ def get_source_and_expected_comments(self): "parent_id": None, "author": None, "author_label": None, + "learner_status": "anonymous", "created_at": "2015-05-11T22:22:22Z", "updated_at": "2015-05-11T33:33:33Z", "raw_body": "More content", diff --git a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py index 0cbcc0bebdd1..a1443252a1ce 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py @@ -464,6 +464,7 @@ def test_basic(self): "can_delete": False, "last_edit": None, "edit_by_label": None, + "learner_status": "new", "profile_image": { "has_image": False, "image_url_full": "http://testserver/static/default_500.png", diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index be8a793abc92..e4d46168c46d 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -1113,6 +1113,7 @@ def setUp(self): {"key": "group_name", "value": None}, {"key": "has_endorsed", "value": False}, {"key": "last_edit", "value": None}, + {"key": "learner_status", "value": "new"}, {"key": "non_endorsed_comment_list_url", "value": None}, {"key": "preview_body", "value": "Test body"}, {"key": "raw_body", "value": "Test body"}, diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py index 4247cbcab06c..431304a9a2b5 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py @@ -386,6 +386,7 @@ def expected_response_data(self, overrides=None): "image_url_medium": "http://testserver/static/default_50.png", "image_url_small": "http://testserver/static/default_30.png", }, + "learner_status": "new", } response_data.update(overrides or {}) return response_data diff --git a/lms/djangoapps/discussion/rest_api/tests/utils.py b/lms/djangoapps/discussion/rest_api/tests/utils.py index 342afb0ada5e..8c1615690ad5 100644 --- a/lms/djangoapps/discussion/rest_api/tests/utils.py +++ b/lms/djangoapps/discussion/rest_api/tests/utils.py @@ -588,6 +588,7 @@ def expected_thread_data(self, overrides=None): "closed_by_label": None, "close_reason": None, "close_reason_code": None, + "learner_status": "new", } response_data.update(overrides or {}) return response_data @@ -816,6 +817,7 @@ def expected_thread_data(self, overrides=None): "closed_by_label": None, "close_reason": None, "close_reason_code": None, + "learner_status": "new", } response_data.update(overrides or {}) return response_data diff --git a/lms/djangoapps/discussion/rest_api/utils.py b/lms/djangoapps/discussion/rest_api/utils.py index 0f02a0dcdcf2..8914527f1b6a 100644 --- a/lms/djangoapps/discussion/rest_api/utils.py +++ b/lms/djangoapps/discussion/rest_api/utils.py @@ -15,6 +15,7 @@ from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole from common.djangoapps.student.models import CourseAccessRole +from completion.models import BlockCompletion from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread from lms.djangoapps.discussion.config.settings import ENABLE_CAPTCHA_IN_DISCUSSION @@ -496,3 +497,86 @@ def get_captcha_site_key_by_platform(platform: str) -> str | None: Get reCAPTCHA site key based on the platform. """ return settings.RECAPTCHA_SITE_KEYS.get(platform, None) + + +def _is_privileged_user(user, course_id): + """ + Check if a user has privileged roles (staff, moderator, TA, etc.) in the course. + + This helper function checks both forum roles and course access roles to determine + if a user should be considered privileged. + + Args: + user: User object to check + course_id: Course key to check roles in + + Returns: + bool: True if user has any privileged role, False otherwise + """ + # Check forum-specific privileged roles + user_roles = get_user_role_names(user, course_id) + privileged_roles = { + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_GROUP_MODERATOR + } + + if any(role in privileged_roles for role in user_roles): + return True + + # Check for staff roles using CourseAccessRole + # Include limited_staff for consistency with is_only_student check + return CourseAccessRole.objects.filter( + user=user, + course_id=course_id, + role__in=['instructor', 'staff', 'limited_staff'] + ).exists() + + +def _check_user_engagement(user, course_id): + """ + Returns True if the user shows meaningful engagement: + - Completed ≥ 2 blocks, or + - Completed at least 1 video or 1 problem. + """ + try: + completed = BlockCompletion.objects.filter( + user=user, context_key=course_id, completion=1.0 + ) + return ( + completed.count() >= 2 + or completed.filter(block_type__in=["video", "problem"]).exists() + ) + except (AttributeError, TypeError, ValueError): + return False + + +def get_user_learner_status(user, course_id): + """ + Determine a user's learner status in the given course. + + Possible return values: + - "anonymous" → User not logged in + - "staff" → Staff/moderator/TA + - "new" → Enrolled but no engagement + - "regular" → Enrolled and has engaged with course content + + Args: + user (User): Django user object + course_id (CourseKey): Course key to check engagement in + + Returns: + str: One of ["anonymous", "staff", "new", "regular"] + """ + # Anonymous user + if not user or not user.is_authenticated: + return "anonymous" + + # Privileged user (staff/moderator/TA) + if _is_privileged_user(user, course_id): + return "staff" + + # Engagement-based learner type + has_engagement = _check_user_engagement(user, course_id) + return "regular" if has_engagement else "new" From 90c665016f409f685b9610e28a16b2c49aee08e3 Mon Sep 17 00:00:00 2001 From: Chintan Joshi Date: Wed, 5 Nov 2025 20:26:24 +0530 Subject: [PATCH 20/54] fix: AI moderation fields in accessible_fields (#26) This PR adds three new fields related to AI-powered content moderation to the accessible_fields list for both Thread and Comment models in the comment client layer. Adds is_spam, ai_moderation_reason, and abuse_flagged fields to accessible_fields lists Enables Thread and Comment objects to retrieve and store these moderation-related fields from the backend --- .../djangoapps/django_comment_common/comment_client/comment.py | 1 + .../djangoapps/django_comment_common/comment_client/thread.py | 1 + 2 files changed, 2 insertions(+) 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 a368d09830af..8905679a45db 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py @@ -23,6 +23,7 @@ class Comment(models.Model): 'closed', 'created_at', 'updated_at', 'depth', 'at_position_list', 'type', 'commentable_id', 'abuse_flaggers', 'endorsement', 'child_count', 'edit_history', + 'is_spam', 'ai_moderation_reason', 'abuse_flagged', ] updatable_fields = [ diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py index b884352ce340..34ccd7bf2ce6 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py @@ -33,6 +33,7 @@ class Thread(models.Model): 'abuse_flaggers', 'resp_skip', 'resp_limit', 'resp_total', 'thread_type', 'endorsed_responses', 'non_endorsed_responses', 'non_endorsed_resp_total', 'context', 'last_activity_at', 'closed_by', 'close_reason_code', 'edit_history', + 'is_spam', 'ai_moderation_reason', 'abuse_flagged', ] # updateable_fields are sent in PUT requests From 527d1dbdb8583ebf0d43c004546fc171550e14fe Mon Sep 17 00:00:00 2001 From: Tim McCormack <59623490+timmc-edx@users.noreply.github.com> Date: Wed, 5 Nov 2025 13:33:01 -0500 Subject: [PATCH 21/54] chore: Upgrade Django to 4.2.26 (security release) (#31) Ran `make upgrade-package package=Django` in 3.11 venv. --- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- scripts/user_retirement/requirements/base.txt | 2 +- scripts/user_retirement/requirements/testing.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 54ea6eda74df..ab63c93c8e6a 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -167,7 +167,7 @@ defusedxml==0.7.1 # ora2 # python3-openid # social-auth-core -django==4.2.25 +django==4.2.26 # via # -c requirements/common_constraints.txt # -c requirements/constraints.txt diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 6c197d009ea7..4fabae5bd9b8 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -331,7 +331,7 @@ distlib==0.4.0 # via # -r requirements/edx/testing.txt # virtualenv -django==4.2.25 +django==4.2.26 # via # -c requirements/common_constraints.txt # -c requirements/constraints.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index ce385fec3950..5c8ea529302b 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -225,7 +225,7 @@ defusedxml==0.7.1 # ora2 # python3-openid # social-auth-core -django==4.2.25 +django==4.2.26 # via # -c requirements/common_constraints.txt # -c requirements/constraints.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 3bfc7bbf0499..4669817d7e11 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -251,7 +251,7 @@ dill==0.4.0 # via pylint distlib==0.4.0 # via virtualenv -django==4.2.25 +django==4.2.26 # via # -c requirements/common_constraints.txt # -c requirements/constraints.txt diff --git a/scripts/user_retirement/requirements/base.txt b/scripts/user_retirement/requirements/base.txt index 0b208f8d6a7d..2a04fc9ea380 100644 --- a/scripts/user_retirement/requirements/base.txt +++ b/scripts/user_retirement/requirements/base.txt @@ -34,7 +34,7 @@ cryptography==45.0.7 # via # -c requirements/constraints.txt # pyjwt -django==4.2.25 +django==4.2.26 # via # -c requirements/common_constraints.txt # -c requirements/constraints.txt diff --git a/scripts/user_retirement/requirements/testing.txt b/scripts/user_retirement/requirements/testing.txt index 31b20fc6d8fd..153e0c0dc4ce 100644 --- a/scripts/user_retirement/requirements/testing.txt +++ b/scripts/user_retirement/requirements/testing.txt @@ -52,7 +52,7 @@ cryptography==45.0.7 # pyjwt ddt==1.7.2 # via -r scripts/user_retirement/requirements/testing.in -django==4.2.25 +django==4.2.26 # via # -r scripts/user_retirement/requirements/base.txt # django-crum From 0d79ecb62339d14d1fa7c644cb27d905ab942966 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Wed, 12 Nov 2025 18:57:06 +0500 Subject: [PATCH 22/54] feat: use new MFE editor for game xblock --- cms/static/js/views/pages/container.js | 1 + 1 file changed, 1 insertion(+) diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index d50f6b4bbe4a..9f8c5ddc6d51 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -512,6 +512,7 @@ function($, _, Backbone, gettext, BasePage, if((useNewTextEditor === 'True' && blockType === 'html') || (useNewVideoEditor === 'True' && blockType === 'video') || (useNewProblemEditor === 'True' && blockType === 'problem') + || (blockType === 'games') ) { var destinationUrl = primaryHeader.attr('authoring_MFE_base_url') + '/' + blockType From 3289e55cc485c287b609cc81e1a136d2618d728f Mon Sep 17 00:00:00 2001 From: Sameen Fatima <55431213+sameenfatima78@users.noreply.github.com> Date: Thu, 13 Nov 2025 09:20:26 +0500 Subject: [PATCH 23/54] feat: look up remote_id by remote_id_field_name (#37228) --- .../third_party_auth/api/serializers.py | 3 ++ .../third_party_auth/api/tests/test_views.py | 28 +++++++++++++++++-- .../djangoapps/third_party_auth/api/views.py | 7 +++++ common/djangoapps/third_party_auth/models.py | 11 ++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/third_party_auth/api/serializers.py b/common/djangoapps/third_party_auth/api/serializers.py index 3e8513de7312..a510cbe07a07 100644 --- a/common/djangoapps/third_party_auth/api/serializers.py +++ b/common/djangoapps/third_party_auth/api/serializers.py @@ -20,4 +20,7 @@ def get_username(self, social_user): def get_remote_id(self, social_user): """ Gets remote id from social user based on provider """ + remote_id_field_name = self.context.get('remote_id_field_name', None) + if remote_id_field_name: + return self.provider.get_remote_id_from_field_name(social_user, remote_id_field_name) return self.provider.get_remote_id_from_social_auth(social_user) diff --git a/common/djangoapps/third_party_auth/api/tests/test_views.py b/common/djangoapps/third_party_auth/api/tests/test_views.py index f7834001d66b..61740268db90 100644 --- a/common/djangoapps/third_party_auth/api/tests/test_views.py +++ b/common/djangoapps/third_party_auth/api/tests/test_views.py @@ -38,8 +38,10 @@ PASSWORD = "edx" -def get_mapping_data_by_usernames(usernames): +def get_mapping_data_by_usernames(usernames, remote_id_field_name=False): """ Generate mapping data used in response """ + if remote_id_field_name: + return [{'username': username, 'remote_id': 'external_' + username} for username in usernames] return [{'username': username, 'remote_id': 'remote_' + username} for username in usernames] @@ -76,11 +78,13 @@ def setUp(self): # pylint: disable=arguments-differ provider=google.backend_name, uid=f'{username}@gmail.com', ) - UserSocialAuth.objects.create( + usa = UserSocialAuth.objects.create( user=user, provider=testshib.backend_name, uid=f'{testshib.slug}:remote_{username}', ) + usa.set_extra_data({'external_user_id': f'external_{username}'}) + usa.refresh_from_db() # Create another user not linked to any providers: UserFactory.create(username=CARL_USERNAME, email=f'{CARL_USERNAME}@example.com', password=PASSWORD) @@ -304,12 +308,20 @@ def test_list_all_user_mappings_oauth2(self, valid_call, expect_code, expect_dat @ddt.data( ({'username': [ALICE_USERNAME, STAFF_USERNAME]}, 200, get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME])), + ({'username': [ALICE_USERNAME, STAFF_USERNAME], 'remote_id_field_name': 'external_user_id'}, 200, + get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME], remote_id_field_name=True)), ({'remote_id': ['remote_' + ALICE_USERNAME, 'remote_' + STAFF_USERNAME, 'remote_' + CARL_USERNAME]}, 200, get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME])), + ({'remote_id': ['remote_' + ALICE_USERNAME, 'remote_' + STAFF_USERNAME, 'remote_' + CARL_USERNAME], + 'remote_id_field_name': 'external_user_id'}, 200, + get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME], remote_id_field_name=True)), ({'username': [ALICE_USERNAME, CARL_USERNAME, STAFF_USERNAME]}, 200, get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME])), ({'username': [ALICE_USERNAME], 'remote_id': ['remote_' + STAFF_USERNAME]}, 200, get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME])), + ({'username': [ALICE_USERNAME], 'remote_id': ['remote_' + STAFF_USERNAME], + 'remote_id_field_name': 'external_user_id'}, 200, + get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME], remote_id_field_name=True)), ) @ddt.unpack def test_user_mappings_with_query_params_comma_separated(self, query_params, expect_code, expect_data): @@ -321,6 +333,8 @@ def test_user_mappings_with_query_params_comma_separated(self, query_params, exp for attr in ['username', 'remote_id']: if attr in query_params: params.append('{}={}'.format(attr, ','.join(query_params[attr]))) + if 'remote_id_field_name' in query_params: + params.append('remote_id_field_name={}'.format(query_params['remote_id_field_name'])) url = "{}?{}".format(base_url, '&'.join(params)) response = self.client.get(url, HTTP_X_EDX_API_KEY=VALID_API_KEY) self._verify_response(response, expect_code, expect_data) @@ -328,12 +342,20 @@ def test_user_mappings_with_query_params_comma_separated(self, query_params, exp @ddt.data( ({'username': [ALICE_USERNAME, STAFF_USERNAME]}, 200, get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME])), + ({'username': [ALICE_USERNAME, STAFF_USERNAME], 'remote_id_field_name': 'external_user_id'}, 200, + get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME], remote_id_field_name=True)), ({'remote_id': ['remote_' + ALICE_USERNAME, 'remote_' + STAFF_USERNAME, 'remote_' + CARL_USERNAME]}, 200, get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME])), + ({'remote_id': ['remote_' + ALICE_USERNAME, 'remote_' + STAFF_USERNAME, 'remote_' + CARL_USERNAME], + 'remote_id_field_name': 'external_user_id'}, 200, + get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME], remote_id_field_name=True)), ({'username': [ALICE_USERNAME, CARL_USERNAME, STAFF_USERNAME]}, 200, get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME])), ({'username': [ALICE_USERNAME], 'remote_id': ['remote_' + STAFF_USERNAME]}, 200, get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME])), + ({'username': [ALICE_USERNAME], 'remote_id': ['remote_' + STAFF_USERNAME], + 'remote_id_field_name': 'external_user_id'}, 200, + get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME], remote_id_field_name=True)), ) @ddt.unpack def test_user_mappings_with_query_params_multi_value_key(self, query_params, expect_code, expect_data): @@ -345,6 +367,8 @@ def test_user_mappings_with_query_params_multi_value_key(self, query_params, exp for attr in ['username', 'remote_id']: if attr in query_params: params.setlist(attr, query_params[attr]) + if 'remote_id_field_name' in query_params: + params['remote_id_field_name'] = query_params['remote_id_field_name'] url = f"{base_url}?{params.urlencode()}" response = self.client.get(url, HTTP_X_EDX_API_KEY=VALID_API_KEY) self._verify_response(response, expect_code, expect_data) diff --git a/common/djangoapps/third_party_auth/api/views.py b/common/djangoapps/third_party_auth/api/views.py index c2b8b0dd6f39..89d55e2eecdc 100644 --- a/common/djangoapps/third_party_auth/api/views.py +++ b/common/djangoapps/third_party_auth/api/views.py @@ -323,6 +323,9 @@ class UserMappingView(ListAPIView): GET /api/third_party_auth/v0/providers/{provider_id}/users?username={username1},{username2} + GET /api/third_party_auth/v0/providers/{provider_id}/users?username={username1}& + remote_id_field_name={external_id_field_name} + GET /api/third_party_auth/v0/providers/{provider_id}/users?username={username1}&usernames={username2} GET /api/third_party_auth/v0/providers/{provider_id}/users?remote_id={remote_id1},{remote_id2} @@ -346,6 +349,9 @@ class UserMappingView(ListAPIView): * usernames: Optional. List of comma separated edX usernames to filter the result set. e.g. ?usernames=bob123,jane456 + * remote_id_field_name: Optional. The field name to use for the remote id lookup. + Useful when learners are coming from external LMS. e.g. ?remote_id_field_name=ext_userid_sf + * page, page_size: Optional. Used for paging the result set, especially when getting an unfiltered list. @@ -415,6 +421,7 @@ def get_serializer_context(self): remove idp_slug from the remote_id if there is any """ context = super().get_serializer_context() + context['remote_id_field_name'] = self.request.query_params.get('remote_id_field_name', None) context['provider'] = self.provider return context diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py index 6d244d96eddd..412875fd6cf0 100644 --- a/common/djangoapps/third_party_auth/models.py +++ b/common/djangoapps/third_party_auth/models.py @@ -810,6 +810,17 @@ def match_social_auth(self, social_auth): prefix = self.slug + ":" return self.backend_name == social_auth.provider and social_auth.uid.startswith(prefix) + def get_remote_id_from_field_name(self, social_auth, field_name): + """ Given a UserSocialAuth object, return the user remote ID against the field name provided. """ + if not self.match_social_auth(social_auth): + raise ValueError( + f"UserSocialAuth record does not match given provider {self.provider_id}" + ) + field_value = social_auth.extra_data.get(field_name, None) + if field_value and isinstance(field_value, list): + return field_value[0] + return field_value + def get_remote_id_from_social_auth(self, social_auth): """ Given a UserSocialAuth object, return the remote ID used by this provider. """ assert self.match_social_auth(social_auth) From 0b4a21f024d6a3cb38b2702d09f59a2e994db01f Mon Sep 17 00:00:00 2001 From: Vivek Ambaliya Date: Mon, 17 Nov 2025 11:08:51 +0000 Subject: [PATCH 24/54] feat: make game xblock in default component --- cms/djangoapps/contentstore/views/component.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 34c1f465c566..b56fb8778aaa 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -62,6 +62,7 @@ 'discussion', 'openassessment', 'drag-and-drop-v2', + 'games', ] BETA_COMPONENT_TYPES = ['library_v2', 'itembank'] @@ -287,6 +288,7 @@ def create_support_legend_dict(): 'library_v2': _("Library Content"), 'itembank': _("Problem Bank"), 'drag-and-drop-v2': _("Drag and Drop"), + 'games': _("Games"), } component_templates = [] From 5c7bd3a417a80c04e945e8777206a329deb4a8ba Mon Sep 17 00:00:00 2001 From: Naincy Chourasia Date: Tue, 18 Nov 2025 13:48:10 +0530 Subject: [PATCH 25/54] fix: team posts now visible by handling standalone context properly (#38) https://2u-internal.atlassian.net/jira/software/c/projects/COSMO2/boards/2821?selectedIssue=COSMO2-776 --- lms/djangoapps/discussion/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lms/djangoapps/discussion/views.py b/lms/djangoapps/discussion/views.py index c01fb24b073a..7dfdaa896413 100644 --- a/lms/djangoapps/discussion/views.py +++ b/lms/djangoapps/discussion/views.py @@ -166,6 +166,7 @@ def get_threads(request, course, user_info, discussion_id=None, per_page=THREADS 'flagged', 'unread', 'unanswered', + 'context', ] ) ) From e72fc59e8af71aae5ac8abd527f79e2ccce9883a Mon Sep 17 00:00:00 2001 From: Sameen Fatima Date: Wed, 19 Nov 2025 17:01:50 +0500 Subject: [PATCH 26/54] fix: point to new models in channel_migrations app fix: fixed tests and quality failures --- .../accounts/tests/test_retirement_views.py | 8 +++++++- .../core/djangoapps/user_api/accounts/views.py | 11 +++++++++-- .../commands/create_user_gdpr_testing.py | 8 +++++++- openedx/features/enterprise_support/signals.py | 16 ++++++++++++---- .../enterprise_support/tests/test_signals.py | 9 +++++++-- 5 files changed, 42 insertions(+), 10 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py index 9d4efb2fa77c..5bef4324d122 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py @@ -11,6 +11,7 @@ from consent.models import DataSharingConsent from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.contrib.sites.models import Site +from django.conf import settings from django.core import mail from django.core.cache import cache from django.test import TestCase @@ -21,7 +22,6 @@ EnterpriseCustomerUser, PendingEnterpriseCustomerUser ) -from integrated_channels.sap_success_factors.models import SapSuccessFactorsLearnerDataTransmissionAudit from opaque_keys.edx.keys import CourseKey from rest_framework import status from social_django.models import UserSocialAuth @@ -87,6 +87,12 @@ setup_retirement_states ) +# This is a temporary import path while we transition from integrated_channels to channel_integrations +if getattr(settings, 'ENABLE_LEGACY_INTEGRATED_CHANNELS', True): + from integrated_channels.sap_success_factors.models import SapSuccessFactorsLearnerDataTransmissionAudit +else: + from channel_integrations.sap_success_factors.models import SapSuccessFactorsLearnerDataTransmissionAudit + def build_jwt_headers(user): """ diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 0464187b5d7e..2a5b38fbe3fa 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -26,8 +26,6 @@ from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser, PendingEnterpriseCustomerUser -from integrated_channels.degreed.models import DegreedLearnerDataTransmissionAudit -from integrated_channels.sap_success_factors.models import SapSuccessFactorsLearnerDataTransmissionAudit from rest_framework import permissions, status from rest_framework.authentication import SessionAuthentication from rest_framework.exceptions import UnsupportedMediaType @@ -97,6 +95,15 @@ from .signals import USER_RETIRE_LMS_CRITICAL, USER_RETIRE_LMS_MISC, USER_RETIRE_MAILINGS from .utils import create_retirement_request_and_deactivate_account, username_suffix_generator +# This is a temporary import path while we transition from integrated_channels to channel_integrations +if getattr(settings, 'ENABLE_LEGACY_INTEGRATED_CHANNELS', True): + from integrated_channels.degreed.models import DegreedLearnerDataTransmissionAudit + from integrated_channels.sap_success_factors.models import SapSuccessFactorsLearnerDataTransmissionAudit +else: + from channel_integrations.degreed2.models import Degreed2LearnerDataTransmissionAudit \ + as DegreedLearnerDataTransmissionAudit + from channel_integrations.sap_success_factors.models import SapSuccessFactorsLearnerDataTransmissionAudit + log = logging.getLogger(__name__) USER_PROFILE_PII = { diff --git a/openedx/core/djangoapps/user_api/management/commands/create_user_gdpr_testing.py b/openedx/core/djangoapps/user_api/management/commands/create_user_gdpr_testing.py index 2008ce8652d5..e744baa6c7a3 100644 --- a/openedx/core/djangoapps/user_api/management/commands/create_user_gdpr_testing.py +++ b/openedx/core/djangoapps/user_api/management/commands/create_user_gdpr_testing.py @@ -11,6 +11,7 @@ from consent.models import DataSharingConsent from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user +from django.conf import settings from django.core.management.base import BaseCommand from enterprise.models import ( EnterpriseCourseEnrollment, @@ -18,7 +19,6 @@ EnterpriseCustomerUser, PendingEnterpriseCustomerUser ) -from integrated_channels.sap_success_factors.models import SapSuccessFactorsLearnerDataTransmissionAudit from opaque_keys.edx.keys import CourseKey from pytz import UTC @@ -31,6 +31,12 @@ from ...models import UserOrgTag +# This is a temporary import path while we transition from integrated_channels to channel_integrations +if getattr(settings, 'ENABLE_LEGACY_INTEGRATED_CHANNELS', True): + from integrated_channels.sap_success_factors.models import SapSuccessFactorsLearnerDataTransmissionAudit +else: + from channel_integrations.sap_success_factors.models import SapSuccessFactorsLearnerDataTransmissionAudit + class Command(BaseCommand): """ diff --git a/openedx/features/enterprise_support/signals.py b/openedx/features/enterprise_support/signals.py index c8122e2102ca..c3ade14081b3 100644 --- a/openedx/features/enterprise_support/signals.py +++ b/openedx/features/enterprise_support/signals.py @@ -10,10 +10,6 @@ from django.db.models.signals import post_save, pre_save from django.dispatch import receiver from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomer -from integrated_channels.integrated_channel.tasks import ( - transmit_single_learner_data, - transmit_single_subsection_learner_data -) from slumber.exceptions import HttpClientError from common.djangoapps.student.signals import UNENROLL_DONE @@ -22,6 +18,18 @@ from openedx.features.enterprise_support.tasks import clear_enterprise_customer_data_consent_share_cache from openedx.features.enterprise_support.utils import clear_data_consent_share_cache, is_enterprise_learner +# This is a temporary import path while we transition from integrated_channels to channel_integrations +if getattr(settings, 'ENABLE_LEGACY_INTEGRATED_CHANNELS', True): + from integrated_channels.integrated_channel.tasks import ( + transmit_single_learner_data, + transmit_single_subsection_learner_data + ) +else: + from channel_integrations.integrated_channel.tasks import ( + transmit_single_learner_data, + transmit_single_subsection_learner_data + ) + log = logging.getLogger(__name__) diff --git a/openedx/features/enterprise_support/tests/test_signals.py b/openedx/features/enterprise_support/tests/test_signals.py index 3207aeb024f3..1a1f742f01f5 100644 --- a/openedx/features/enterprise_support/tests/test_signals.py +++ b/openedx/features/enterprise_support/tests/test_signals.py @@ -6,6 +6,7 @@ from unittest.mock import patch import ddt +from django.conf import settings from django.test.utils import override_settings from django.utils.timezone import now from edx_django_utils.cache import TieredCache @@ -196,7 +197,9 @@ def test_handle_enterprise_learner_passing_grade(self): Test to assert transmit_single_learner_data is called when COURSE_GRADE_NOW_PASSED signal is fired """ with patch( - 'integrated_channels.integrated_channel.tasks.transmit_single_learner_data.apply_async', + 'integrated_channels.integrated_channel.tasks.transmit_single_learner_data.apply_async' + if getattr(settings, 'ENABLE_LEGACY_INTEGRATED_CHANNELS', True) else + 'channel_integrations.integrated_channel.tasks.transmit_single_learner_data.apply_async', return_value=None ) as mock_task_apply: course_key = CourseKey.from_string(self.course_id) @@ -218,7 +221,9 @@ def test_handle_enterprise_learner_subsection(self): Test to assert transmit_subsection_learner_data is called when COURSE_ASSESSMENT_GRADE_CHANGED signal is fired. """ with patch( - 'integrated_channels.integrated_channel.tasks.transmit_single_subsection_learner_data.apply_async', + 'integrated_channels.integrated_channel.tasks.transmit_single_subsection_learner_data.apply_async' + if getattr(settings, 'ENABLE_LEGACY_INTEGRATED_CHANNELS', True) else + 'channel_integrations.integrated_channel.tasks.transmit_single_subsection_learner_data.apply_async', return_value=None ) as mock_task_apply: course_key = CourseKey.from_string(self.course_id) From 6b495946cb28c6907b0bf5629adc14e2249b2252 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Fri, 21 Nov 2025 15:32:41 +0500 Subject: [PATCH 27/54] feat: add games xblock icon --- cms/static/images/large-games-icon.svg | 10 ++++++++++ cms/static/sass/assets/_graphics.scss | 6 ++++++ 2 files changed, 16 insertions(+) create mode 100644 cms/static/images/large-games-icon.svg diff --git a/cms/static/images/large-games-icon.svg b/cms/static/images/large-games-icon.svg new file mode 100644 index 000000000000..9c862ef6c194 --- /dev/null +++ b/cms/static/images/large-games-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/cms/static/sass/assets/_graphics.scss b/cms/static/sass/assets/_graphics.scss index afb830d5dd71..58141c445a75 100644 --- a/cms/static/sass/assets/_graphics.scss +++ b/cms/static/sass/assets/_graphics.scss @@ -80,3 +80,9 @@ height: ($baseline*3); background: url('#{$static-path}/images/large-itembank-icon.png') center no-repeat; } + +.large-games-icon { + display: inline-block; + width: ($baseline*3); + height: ($baseline*3); + background: url('#{$static-path}/images/large-games-icon.svg') center no-repeat; } From 72465835c25231ee9a8923a288d2bfc5bfe275bf Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Fri, 21 Nov 2025 15:33:20 +0500 Subject: [PATCH 28/54] feat: add waffle flag to toggle on/off for games xblock --- cms/djangoapps/contentstore/views/component.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index b56fb8778aaa..5c4215c7f18f 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -42,6 +42,7 @@ from openedx.core.djangoapps.content_tagging.api import get_object_tags from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order +from games.toggles import is_games_xblock_enabled __all__ = [ 'container_handler', @@ -62,8 +63,9 @@ 'discussion', 'openassessment', 'drag-and-drop-v2', - 'games', ] +if is_games_xblock_enabled(): + COMPONENT_TYPES.append('games') BETA_COMPONENT_TYPES = ['library_v2', 'itembank'] @@ -288,8 +290,9 @@ def create_support_legend_dict(): 'library_v2': _("Library Content"), 'itembank': _("Problem Bank"), 'drag-and-drop-v2': _("Drag and Drop"), - 'games': _("Games"), } + if is_games_xblock_enabled(): + component_display_names['games'] = _("Games") component_templates = [] categories = set() From cf34d5cbf68140f296320d24f91606b2bd7dd508 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Fri, 21 Nov 2025 16:30:33 +0500 Subject: [PATCH 29/54] fix: tests by adding try-catch --- cms/djangoapps/contentstore/views/component.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 5c4215c7f18f..470d4274d1b0 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -42,7 +42,12 @@ from openedx.core.djangoapps.content_tagging.api import get_object_tags from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order -from games.toggles import is_games_xblock_enabled + +try: + from games.toggles import is_games_xblock_enabled # pylint: disable=import-error +except ImportError: + def is_games_xblock_enabled(): + return False __all__ = [ 'container_handler', From cb0c6c1299ca9936d91748a9962f17f9973b2fb7 Mon Sep 17 00:00:00 2001 From: sameeramin <35958006+sameeramin@users.noreply.github.com> Date: Tue, 25 Nov 2025 07:02:32 +0000 Subject: [PATCH 30/54] feat: Upgrade Python dependency edx-enterprise Commit generated by workflow `edx/edx-platform/.github/workflows/upgrade-one-python-dependency.yml@refs/heads/release-ulmo` --- requirements/common_constraints.txt | 2 +- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 13 ++++++++----- requirements/edx/development.txt | 10 ++++++++-- requirements/edx/doc.txt | 9 +++++++-- requirements/edx/testing.txt | 9 +++++++-- 6 files changed, 32 insertions(+), 13 deletions(-) diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt index 77cba92568da..1f3e81f50334 100644 --- a/requirements/common_constraints.txt +++ b/requirements/common_constraints.txt @@ -23,7 +23,7 @@ Django<6.0 # See https://github.com/openedx/edx-platform/issues/35126 for more info elasticsearch<7.14.0 -# pip 25.3 is incompatible with pip-tools hence causing failures during the build process +# pip 25.3 is incompatible with pip-tools hence causing failures during the build process # Make upgrade command and all requirements upgrade jobs are broken due to this. # See issue https://github.com/openedx/public-engineering/issues/440 for details regarding the ongoing fix. # The constraint can be removed once a release (pip-tools > 7.5.1) is available with support for pip 25.3 diff --git a/requirements/constraints.txt b/requirements/constraints.txt index b4fa1f404a4f..bac195511a1a 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -42,7 +42,7 @@ django-stubs<6 # 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==6.5.0 +edx-enterprise==6.5.5 # Date: 2023-07-26 # Our legacy Sass code is incompatible with anything except this ancient libsass version. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index ab63c93c8e6a..74dbfbff5fa6 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -19,7 +19,9 @@ amqp==5.3.1 analytics-python==1.4.post1 # via -r requirements/edx/kernel.in aniso8601==10.0.1 - # via edx-tincan-py35 + # via + # edx-tincan-py35 + # tincan annotated-types==0.7.0 # via pydantic anyio==4.11.0 @@ -474,7 +476,7 @@ edx-drf-extensions==10.6.0 # edxval # enterprise-integrated-channels # openedx-learning -edx-enterprise==6.5.0 +edx-enterprise==6.5.5 # via # -c requirements/constraints.txt # -r requirements/edx/kernel.in @@ -534,9 +536,7 @@ edx-submissions==3.12.0 # -r requirements/edx/kernel.in # ora2 edx-tincan-py35==2.0.0 - # via - # edx-enterprise - # enterprise-integrated-channels + # via enterprise-integrated-channels edx-toggles==5.4.1 # via # -r requirements/edx/kernel.in @@ -1009,6 +1009,7 @@ pytz==2025.2 # olxcleaner # ora2 # snowflake-connector-python + # tincan # xblock pyuca==1.2 # via -r requirements/edx/kernel.in @@ -1162,6 +1163,8 @@ testfixtures==9.1.0 # via edx-enterprise text-unidecode==1.3 # via python-slugify +tincan==1.0.0 + # via edx-enterprise tinycss2==1.4.0 # via bleach tomlkit==0.13.3 diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 4fabae5bd9b8..980389cf60f8 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -46,6 +46,7 @@ aniso8601==10.0.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-tincan-py35 + # tincan annotated-types==0.7.0 # via # -r requirements/edx/doc.txt @@ -748,7 +749,7 @@ edx-drf-extensions==10.6.0 # edxval # enterprise-integrated-channels # openedx-learning -edx-enterprise==6.5.0 +edx-enterprise==6.5.5 # via # -c requirements/constraints.txt # -r requirements/edx/doc.txt @@ -835,7 +836,6 @@ edx-tincan-py35==2.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt - # edx-enterprise # enterprise-integrated-channels edx-toggles==5.4.1 # via @@ -1763,6 +1763,7 @@ pytz==2025.2 # olxcleaner # ora2 # snowflake-connector-python + # tincan # xblock pyuca==1.2 # via @@ -2073,6 +2074,11 @@ text-unidecode==1.3 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # python-slugify +tincan==1.0.0 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # edx-enterprise tinycss2==1.4.0 # via # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 5c8ea529302b..801e8626ad16 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -33,6 +33,7 @@ aniso8601==10.0.1 # via # -r requirements/edx/base.txt # edx-tincan-py35 + # tincan annotated-types==0.7.0 # via # -r requirements/edx/base.txt @@ -558,7 +559,7 @@ edx-drf-extensions==10.6.0 # edxval # enterprise-integrated-channels # openedx-learning -edx-enterprise==6.5.0 +edx-enterprise==6.5.5 # via # -c requirements/constraints.txt # -r requirements/edx/base.txt @@ -621,7 +622,6 @@ edx-submissions==3.12.0 edx-tincan-py35==2.0.0 # via # -r requirements/edx/base.txt - # edx-enterprise # enterprise-integrated-channels edx-toggles==5.4.1 # via @@ -1233,6 +1233,7 @@ pytz==2025.2 # olxcleaner # ora2 # snowflake-connector-python + # tincan # xblock pyuca==1.2 # via -r requirements/edx/base.txt @@ -1467,6 +1468,10 @@ text-unidecode==1.3 # via # -r requirements/edx/base.txt # python-slugify +tincan==1.0.0 + # via + # -r requirements/edx/base.txt + # edx-enterprise tinycss2==1.4.0 # via # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 4669817d7e11..c08eae3ae008 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -29,6 +29,7 @@ aniso8601==10.0.1 # via # -r requirements/edx/base.txt # edx-tincan-py35 + # tincan annotated-types==0.7.0 # via # -r requirements/edx/base.txt @@ -579,7 +580,7 @@ edx-drf-extensions==10.6.0 # edxval # enterprise-integrated-channels # openedx-learning -edx-enterprise==6.5.0 +edx-enterprise==6.5.5 # via # -c requirements/constraints.txt # -r requirements/edx/base.txt @@ -644,7 +645,6 @@ edx-submissions==3.12.0 edx-tincan-py35==2.0.0 # via # -r requirements/edx/base.txt - # edx-enterprise # enterprise-integrated-channels edx-toggles==5.4.1 # via @@ -1346,6 +1346,7 @@ pytz==2025.2 # olxcleaner # ora2 # snowflake-connector-python + # tincan # xblock pyuca==1.2 # via -r requirements/edx/base.txt @@ -1538,6 +1539,10 @@ text-unidecode==1.3 # via # -r requirements/edx/base.txt # python-slugify +tincan==1.0.0 + # via + # -r requirements/edx/base.txt + # edx-enterprise tinycss2==1.4.0 # via # -r requirements/edx/base.txt From b78617e044de5fecf92f939045c92b625946bddb Mon Sep 17 00:00:00 2001 From: sameeramin <35958006+sameeramin@users.noreply.github.com> Date: Tue, 25 Nov 2025 07:20:08 +0000 Subject: [PATCH 31/54] feat: Upgrade Python dependency enterprise-integrated-channels Commit generated by workflow `edx/edx-platform/.github/workflows/upgrade-one-python-dependency.yml@refs/heads/release-ulmo` --- requirements/common_constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt index 77cba92568da..1f3e81f50334 100644 --- a/requirements/common_constraints.txt +++ b/requirements/common_constraints.txt @@ -23,7 +23,7 @@ Django<6.0 # See https://github.com/openedx/edx-platform/issues/35126 for more info elasticsearch<7.14.0 -# pip 25.3 is incompatible with pip-tools hence causing failures during the build process +# pip 25.3 is incompatible with pip-tools hence causing failures during the build process # Make upgrade command and all requirements upgrade jobs are broken due to this. # See issue https://github.com/openedx/public-engineering/issues/440 for details regarding the ongoing fix. # The constraint can be removed once a release (pip-tools > 7.5.1) is available with support for pip 25.3 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index ab63c93c8e6a..5705d5609c28 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -566,7 +566,7 @@ enmerkar==0.7.1 # via enmerkar-underscore enmerkar-underscore==2.4.0 # via -r requirements/edx/kernel.in -enterprise-integrated-channels==0.1.22 +enterprise-integrated-channels==0.1.24 # via -r requirements/edx/bundled.in event-tracking==3.3.0 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 4fabae5bd9b8..ef54f652a2b4 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -877,7 +877,7 @@ enmerkar-underscore==2.4.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -enterprise-integrated-channels==0.1.22 +enterprise-integrated-channels==0.1.24 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 5c8ea529302b..0042f65697bb 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -655,7 +655,7 @@ enmerkar==0.7.1 # enmerkar-underscore enmerkar-underscore==2.4.0 # via -r requirements/edx/base.txt -enterprise-integrated-channels==0.1.22 +enterprise-integrated-channels==0.1.24 # via -r requirements/edx/base.txt event-tracking==3.3.0 # via diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 4669817d7e11..39bf2cbf91c0 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -678,7 +678,7 @@ enmerkar==0.7.1 # enmerkar-underscore enmerkar-underscore==2.4.0 # via -r requirements/edx/base.txt -enterprise-integrated-channels==0.1.22 +enterprise-integrated-channels==0.1.24 # via -r requirements/edx/base.txt event-tracking==3.3.0 # via From cdbfe0acefa63c31ab1cf33e508b19156fac40d1 Mon Sep 17 00:00:00 2001 From: sameeramin <35958006+sameeramin@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:33:24 +0000 Subject: [PATCH 32/54] feat: Upgrade Python dependency edx-enterprise Commit generated by workflow `edx/edx-platform/.github/workflows/upgrade-one-python-dependency.yml@refs/heads/release-ulmo` --- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index bac195511a1a..e4ecb8e51cde 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -42,7 +42,7 @@ django-stubs<6 # 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==6.5.5 +edx-enterprise==6.5.7 # Date: 2023-07-26 # Our legacy Sass code is incompatible with anything except this ancient libsass version. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index ec33a5b329f2..de60171d0fb7 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -476,7 +476,7 @@ edx-drf-extensions==10.6.0 # edxval # enterprise-integrated-channels # openedx-learning -edx-enterprise==6.5.5 +edx-enterprise==6.5.7 # via # -c requirements/constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 07955fcfe6df..161a13d0e871 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -749,7 +749,7 @@ edx-drf-extensions==10.6.0 # edxval # enterprise-integrated-channels # openedx-learning -edx-enterprise==6.5.5 +edx-enterprise==6.5.7 # via # -c requirements/constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 53ae87ddbd66..4b68ac4e3e29 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -559,7 +559,7 @@ edx-drf-extensions==10.6.0 # edxval # enterprise-integrated-channels # openedx-learning -edx-enterprise==6.5.5 +edx-enterprise==6.5.7 # via # -c requirements/constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 704fbb0798ef..3e6433a06481 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -580,7 +580,7 @@ edx-drf-extensions==10.6.0 # edxval # enterprise-integrated-channels # openedx-learning -edx-enterprise==6.5.5 +edx-enterprise==6.5.7 # via # -c requirements/constraints.txt # -r requirements/edx/base.txt From f95c01cdb282eb9d4385084e9b58a956eb1dc987 Mon Sep 17 00:00:00 2001 From: sameeramin <35958006+sameeramin@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:34:06 +0000 Subject: [PATCH 33/54] feat: Upgrade Python dependency enterprise-integrated-channels Commit generated by workflow `edx/edx-platform/.github/workflows/upgrade-one-python-dependency.yml@refs/heads/release-ulmo` --- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index ec33a5b329f2..4d321a2c6640 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -566,7 +566,7 @@ enmerkar==0.7.1 # via enmerkar-underscore enmerkar-underscore==2.4.0 # via -r requirements/edx/kernel.in -enterprise-integrated-channels==0.1.24 +enterprise-integrated-channels==0.1.25 # via -r requirements/edx/bundled.in event-tracking==3.3.0 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 07955fcfe6df..78fb3e6be219 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -877,7 +877,7 @@ enmerkar-underscore==2.4.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -enterprise-integrated-channels==0.1.24 +enterprise-integrated-channels==0.1.25 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 53ae87ddbd66..780876b6b3dc 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -655,7 +655,7 @@ enmerkar==0.7.1 # enmerkar-underscore enmerkar-underscore==2.4.0 # via -r requirements/edx/base.txt -enterprise-integrated-channels==0.1.24 +enterprise-integrated-channels==0.1.25 # via -r requirements/edx/base.txt event-tracking==3.3.0 # via diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 704fbb0798ef..f9c65d2b76c2 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -678,7 +678,7 @@ enmerkar==0.7.1 # enmerkar-underscore enmerkar-underscore==2.4.0 # via -r requirements/edx/base.txt -enterprise-integrated-channels==0.1.24 +enterprise-integrated-channels==0.1.25 # via -r requirements/edx/base.txt event-tracking==3.3.0 # via From 201009dee9f5298c9487ee7204452bd1ff9f2bee Mon Sep 17 00:00:00 2001 From: Devasia Joseph Date: Mon, 1 Dec 2025 19:23:52 +0530 Subject: [PATCH 34/54] fix: reorder showanswer checks to restore expected behavior in preview mode (#50) --- xmodule/capa_block.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xmodule/capa_block.py b/xmodule/capa_block.py index 1a096e76b22d..b048b4f02a06 100644 --- a/xmodule/capa_block.py +++ b/xmodule/capa_block.py @@ -1498,14 +1498,14 @@ def answer_available(self): if not self.correctness_available(): # If correctness is being withheld, then don't show answers either. return False - elif self.showanswer == '': - return False elif self.showanswer == SHOWANSWER.NEVER: return False elif user_is_staff: # This is after the 'never' check because admins can see the answer # unless the problem explicitly prevents it return True + elif self.showanswer == '': + return False elif self.showanswer == SHOWANSWER.ATTEMPTED: return self.is_attempted() or self.is_past_due() elif self.showanswer == SHOWANSWER.ANSWERED: From 47ab9604b29706fce5f301af405f1293769f67e4 Mon Sep 17 00:00:00 2001 From: Nathan Sprenkle Date: Mon, 1 Dec 2025 14:16:02 -0500 Subject: [PATCH 35/54] feat: audit preview of verified content in course outline (#42) * feat: add toggle for audit preview of verified content This is, specifically, when all 3 of the critera are met: 1. The feature flag is enabled for the course. 2. The requesting user is enrolled as audit. 3. The course has a verified track. * feat: when audit preview of verified content is enabled, mark non-audit content as previewable Adds new previewable_sequences to UserCourseOutlineData * feat: mark previewable sections for audit learners who can preview verified content --- .../course_home_api/outline/serializers.py | 1 + .../outline/tests/test_view.py | 85 ++++++++++ .../course_home_api/outline/views.py | 22 ++- .../course_home_api/tests/test_toggles.py | 155 ++++++++++++++++++ lms/djangoapps/course_home_api/toggles.py | 55 +++++++ .../learning_sequences/api/outlines.py | 25 ++- .../api/tests/test_outlines.py | 91 ++++++++++ .../content/learning_sequences/data.py | 3 + .../content/learning_sequences/services.py | 9 +- openedx/features/course_experience/utils.py | 4 +- 10 files changed, 440 insertions(+), 10 deletions(-) create mode 100644 lms/djangoapps/course_home_api/tests/test_toggles.py diff --git a/lms/djangoapps/course_home_api/outline/serializers.py b/lms/djangoapps/course_home_api/outline/serializers.py index cfa518138a95..f66012362327 100644 --- a/lms/djangoapps/course_home_api/outline/serializers.py +++ b/lms/djangoapps/course_home_api/outline/serializers.py @@ -62,6 +62,7 @@ def get_blocks(self, block): # pylint: disable=missing-function-docstring 'type': block_type, 'has_scheduled_content': block.get('has_scheduled_content'), 'hide_from_toc': block.get('hide_from_toc'), + 'is_preview': block.get('is_preview', False), }, } if 'special_exam_info' in self.context.get('extra_fields', []) and block.get('special_exam_info'): diff --git a/lms/djangoapps/course_home_api/outline/tests/test_view.py b/lms/djangoapps/course_home_api/outline/tests/test_view.py index 74e22e5fcc4b..6de5db83f94c 100644 --- a/lms/djangoapps/course_home_api/outline/tests/test_view.py +++ b/lms/djangoapps/course_home_api/outline/tests/test_view.py @@ -13,6 +13,7 @@ from django.test import override_settings from django.urls import reverse from edx_toggles.toggles.testutils import override_waffle_flag +from opaque_keys.edx.keys import UsageKey from cms.djangoapps.contentstore.outlines import update_outline_from_modulestore from common.djangoapps.course_modes.models import CourseMode @@ -43,6 +44,7 @@ BlockFactory, CourseFactory ) +from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID @ddt.ddt @@ -484,6 +486,89 @@ def test_course_progress_analytics_disabled(self, mock_task): self.client.get(self.url) mock_task.assert_not_called() + # Tests for verified content preview functionality + # These tests cover the feature that allows audit learners to preview + # the structure of verified-only content without access to the content itself + + @patch('lms.djangoapps.course_home_api.outline.views.learner_can_preview_verified_content') + def test_verified_content_preview_disabled_integration(self, mock_preview_function): + """Test that when verified preview is disabled, no preview markers are added.""" + # Given a course with some Verified only sequences + with self.store.bulk_operations(self.course.id): + chapter = BlockFactory.create(category='chapter', parent_location=self.course.location) + sequential = BlockFactory.create( + category='sequential', + parent_location=chapter.location, + display_name='Verified Sequential', + group_access={ENROLLMENT_TRACK_PARTITION_ID: [2]} # restrict to verified only + ) + update_outline_from_modulestore(self.course.id) + + # ... where the preview feature is disabled + mock_preview_function.return_value = False + + # When I access them as an audit user + CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT) + response = self.client.get(self.url) + + # Then I get a valid response back + assert response.status_code == 200 + + # ... with course_blocks populated + course_blocks = response.data['course_blocks']["blocks"] + + # ... but with verified content omitted + assert str(sequential.location) not in course_blocks + + # ... and no block has preview set to true + for block in course_blocks: + assert course_blocks[block].get('is_preview') is not True + + @patch('lms.djangoapps.course_home_api.outline.views.learner_can_preview_verified_content') + @patch('lms.djangoapps.course_home_api.outline.views.get_user_course_outline') + def test_verified_content_preview_enabled_marks_previewable_content(self, mock_outline, mock_preview_enabled): + """Test that when verified preview is enabled, previewable sequences and chapters are marked.""" + # Given a course with some Verified only sequences and some regular sequences + with self.store.bulk_operations(self.course.id): + chapter = BlockFactory.create(category='chapter', parent_location=self.course.location) + verified_sequential = BlockFactory.create( + category='sequential', + parent_location=chapter.location, + display_name='Verified Sequential', + ) + regular_sequential = BlockFactory.create( + category='sequential', + parent_location=chapter.location, + display_name='Regular Sequential' + ) + update_outline_from_modulestore(self.course.id) + + # ... with an outline that correctly identifies previewable sequences + mock_course_outline = Mock() + mock_course_outline.sections = {Mock(usage_key=chapter.location)} + mock_course_outline.sequences = {verified_sequential.location, regular_sequential.location} + mock_course_outline.previewable_sequences = {verified_sequential.location} + mock_outline.return_value = mock_course_outline + + # When I access them as an audit user with preview enabled + CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT) + mock_preview_enabled.return_value = True + + # Then I get a valid response back + response = self.client.get(self.url) + assert response.status_code == 200 + + # ... with course_blocks populated + course_blocks = response.data['course_blocks']["blocks"] + + for block in course_blocks: + # ... and the verified only content is marked as preview only + if UsageKey.from_string(block) in mock_course_outline.previewable_sequences: + assert course_blocks[block].get('is_preview') is True + # ... and the regular content is not marked as preview + else: + assert course_blocks[block].get('is_preview') is False + @ddt.ddt class SidebarBlocksTestViews(BaseCourseHomeTests): diff --git a/lms/djangoapps/course_home_api/outline/views.py b/lms/djangoapps/course_home_api/outline/views.py index 7c5307cba764..78d5767ffeed 100644 --- a/lms/djangoapps/course_home_api/outline/views.py +++ b/lms/djangoapps/course_home_api/outline/views.py @@ -36,7 +36,10 @@ ) from lms.djangoapps.course_home_api.utils import get_course_or_403 from lms.djangoapps.course_home_api.tasks import collect_progress_for_user_in_course -from lms.djangoapps.course_home_api.toggles import send_course_progress_analytics_for_student_is_enabled +from lms.djangoapps.course_home_api.toggles import ( + learner_can_preview_verified_content, + send_course_progress_analytics_for_student_is_enabled, +) from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs from lms.djangoapps.courseware.courses import get_course_date_blocks, get_course_info_section @@ -209,6 +212,7 @@ def get(self, request, *args, **kwargs): # pylint: disable=too-many-statements allow_anonymous = COURSE_ENABLE_UNENROLLED_ACCESS_FLAG.is_enabled(course_key) allow_public = allow_anonymous and course.course_visibility == COURSE_VISIBILITY_PUBLIC allow_public_outline = allow_anonymous and course.course_visibility == COURSE_VISIBILITY_PUBLIC_OUTLINE + allow_preview_of_verified_content = learner_can_preview_verified_content(course_key, request.user) # User locale settings user_timezone_locale = user_timezone_locale_prefs(request) @@ -309,7 +313,8 @@ def get(self, request, *args, **kwargs): # pylint: disable=too-many-statements # so this is a tiny first step in that migration. if course_blocks: user_course_outline = get_user_course_outline( - course_key, request.user, datetime.now(tz=timezone.utc) + course_key, request.user, datetime.now(tz=timezone.utc), + preview_verified_content=allow_preview_of_verified_content ) available_seq_ids = {str(usage_key) for usage_key in user_course_outline.sequences} @@ -339,6 +344,19 @@ def get(self, request, *args, **kwargs): # pylint: disable=too-many-statements ) ] if 'children' in chapter_data else [] + # For audit preview of verified content, we don't remove verified content. + # Instead, we mark it as preview so the frontend can handle it appropriately. + if allow_preview_of_verified_content: + previewable_sequences = {str(usage_key) for usage_key in user_course_outline.previewable_sequences} + + # Iterate through course_blocks to mark previewable sequences and chapters + for chapter_data in course_blocks['children']: + if chapter_data['id'] in previewable_sequences: + chapter_data['is_preview'] = True + for seq_data in chapter_data.get('children', []): + if seq_data['id'] in previewable_sequences: + seq_data['is_preview'] = True + user_has_passing_grade = False if not request.user.is_anonymous: user_grade = CourseGradeFactory().read(request.user, course) diff --git a/lms/djangoapps/course_home_api/tests/test_toggles.py b/lms/djangoapps/course_home_api/tests/test_toggles.py new file mode 100644 index 000000000000..46ab545d0ade --- /dev/null +++ b/lms/djangoapps/course_home_api/tests/test_toggles.py @@ -0,0 +1,155 @@ +""" +Tests for Course Home API toggles. +""" + +from unittest.mock import Mock, patch + +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.course_modes.tests.factories import CourseModeFactory + +from ..toggles import learner_can_preview_verified_content + + +class TestLearnerCanPreviewVerifiedContent(TestCase): + """Test cases for learner_can_preview_verified_content function.""" + + def setUp(self): + """Set up test fixtures.""" + self.course_key = CourseKey.from_string("course-v1:TestX+CS101+2024") + self.user = Mock() + + # Set up patchers + self.feature_enabled_patcher = patch( + "lms.djangoapps.course_home_api.toggles.audit_learner_verified_preview_is_enabled" + ) + self.verified_mode_for_course_patcher = patch( + "common.djangoapps.course_modes.models.CourseMode.verified_mode_for_course" + ) + self.get_enrollment_patcher = patch( + "common.djangoapps.student.models.CourseEnrollment.get_enrollment" + ) + + # Course set up with verified, professional, and audit modes + self.verified_mode = CourseModeFactory( + course_id=self.course_key, + mode_slug=CourseMode.VERIFIED, + mode_display_name="Verified", + ) + self.professional_mode = CourseModeFactory( + course_id=self.course_key, + mode_slug=CourseMode.PROFESSIONAL, + mode_display_name="Professional", + ) + self.audit_mode = CourseModeFactory( + course_id=self.course_key, + mode_slug=CourseMode.AUDIT, + mode_display_name="Audit", + ) + self.course_modes_dict = { + "audit": self.audit_mode, + "verified": self.verified_mode, + "professional": self.professional_mode, + } + + # Start patchers + self.mock_feature_enabled = self.feature_enabled_patcher.start() + self.mock_verified_mode_for_course = ( + self.verified_mode_for_course_patcher.start() + ) + self.mock_get_enrollment = self.get_enrollment_patcher.start() + + def _enroll_user(self, mode): + """Helper method to set up user enrollment mock.""" + mock_enrollment = Mock() + mock_enrollment.mode = mode + self.mock_get_enrollment.return_value = mock_enrollment + + def tearDown(self): + """Clean up patchers.""" + self.feature_enabled_patcher.stop() + self.verified_mode_for_course_patcher.stop() + self.get_enrollment_patcher.stop() + + def test_all_conditions_met_returns_true(self): + """Test that function returns True when all conditions are met.""" + # Given the feature is enabled, course has verified mode, and user is enrolled as audit + self.mock_feature_enabled.return_value = True + self.mock_verified_mode_for_course.return_value = self.course_modes_dict[ + "professional" + ] + self._enroll_user(CourseMode.AUDIT) + + # When I check if the learner can preview verified content + result = learner_can_preview_verified_content(self.course_key, self.user) + + # Then the result should be True + self.assertTrue(result) + + def test_feature_disabled_returns_false(self): + """Test that function returns False when feature is disabled.""" + # Given the feature is disabled + self.mock_feature_enabled.return_value = False + + # ... even if all other conditions are met + self.mock_verified_mode_for_course.return_value = self.course_modes_dict[ + "professional" + ] + self._enroll_user(CourseMode.AUDIT) + + # When I check if the learner can preview verified content + result = learner_can_preview_verified_content(self.course_key, self.user) + + # Then the result should be False + self.assertFalse(result) + + def test_no_verified_mode_returns_false(self): + """Test that function returns False when course has no verified mode.""" + # Given the course does not have a verified mode + self.mock_verified_mode_for_course.return_value = None + + # ... even if all other conditions are met + self.mock_feature_enabled.return_value = True + self._enroll_user(CourseMode.AUDIT) + + # When I check if the learner can preview verified content + result = learner_can_preview_verified_content(self.course_key, self.user) + + # Then the result should be False + self.assertFalse(result) + + def test_no_enrollment_returns_false(self): + """Test that function returns False when user is not enrolled.""" + # Given the user is unenrolled + self.mock_get_enrollment.return_value = None + + # ... even if all other conditions are met + self.mock_feature_enabled.return_value = True + self.mock_verified_mode_for_course.return_value = self.course_modes_dict[ + "professional" + ] + + # When I check if the learner can preview verified content + result = learner_can_preview_verified_content(self.course_key, self.user) + + # Then the result should be False + self.assertFalse(result) + + def test_verified_enrollment_returns_false(self): + """Test that function returns False when user is enrolled in verified mode.""" + # Given the user is not enrolled as audit + self._enroll_user(CourseMode.VERIFIED) + + # ... even if all other conditions are met + self.mock_feature_enabled.return_value = True + self.mock_verified_mode_for_course.return_value = self.course_modes_dict[ + "professional" + ] + + # When I check if the learner can preview verified content + result = learner_can_preview_verified_content(self.course_key, self.user) + + # Then the result should be False + self.assertFalse(result) diff --git a/lms/djangoapps/course_home_api/toggles.py b/lms/djangoapps/course_home_api/toggles.py index 052862796c75..1f2d32b87e96 100644 --- a/lms/djangoapps/course_home_api/toggles.py +++ b/lms/djangoapps/course_home_api/toggles.py @@ -3,6 +3,9 @@ """ from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag +from openedx.core.lib.cache_utils import request_cached +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.student.models import CourseEnrollment WAFFLE_FLAG_NAMESPACE = 'course_home' @@ -51,6 +54,21 @@ ) +# Waffle flag to enable audit learner preview of course structure visible to verified learners. +# +# .. toggle_name: course_home.audit_learner_verified_preview +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Where enabled, audit learners can see the presence of the sections / units +# otherwise restricted to verified learners. The content itself remains inaccessible. +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2025-11-07 +# .. toggle_target_removal_date: None +COURSE_HOME_AUDIT_LEARNER_VERIFIED_PREVIEW = CourseWaffleFlag( + f'{WAFFLE_FLAG_NAMESPACE}.audit_learner_verified_preview', __name__ +) + + def course_home_mfe_progress_tab_is_active(course_key): # Avoiding a circular dependency from .models import DisableProgressPageStackedConfig @@ -73,3 +91,40 @@ def send_course_progress_analytics_for_student_is_enabled(course_key): Returns True if the course completion analytics feature is enabled for a given course. """ return COURSE_HOME_SEND_COURSE_PROGRESS_ANALYTICS_FOR_STUDENT.is_enabled(course_key) + + +def audit_learner_verified_preview_is_enabled(course_key): + """ + Returns True if the audit learner verified preview feature is enabled for a given course. + """ + return COURSE_HOME_AUDIT_LEARNER_VERIFIED_PREVIEW.is_enabled(course_key) + + +@request_cached() +def learner_can_preview_verified_content(course_key, user): + """ + Determine if an audit learner can preview verified content in a course. + + Args: + course_key: The course identifier. + user: The user object + Returns: + True if the learner can preview verified content, False otherwise. + """ + # To preview verified content, the feature must be enabled for the course... + feature_enabled = audit_learner_verified_preview_is_enabled(course_key) + if not feature_enabled: + return False + + # ... the course must have a verified mode + course_has_verified_mode = CourseMode.verified_mode_for_course(course_key) + if not course_has_verified_mode: + return False + + # ... and the user must be enrolled as audit + enrollment = CourseEnrollment.get_enrollment(user, course_key) + user_enrolled_as_audit = enrollment is not None and enrollment.mode == CourseMode.AUDIT + if not user_enrolled_as_audit: + return False + + return True diff --git a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py index cd2b12d03f1c..c91971f0fc67 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py @@ -258,17 +258,23 @@ def get_content_errors(course_key: CourseKey) -> List[ContentErrorData]: @function_trace('learning_sequences.api.get_user_course_outline') def get_user_course_outline(course_key: CourseKey, user: types.User, - at_time: datetime) -> UserCourseOutlineData: + at_time: datetime, + preview_verified_content: bool = False) -> UserCourseOutlineData: """ Get an outline customized for a particular user at a particular time. `user` is a Django User object (including the AnonymousUser) `at_time` should be a UTC datetime.datetime object. + If `preview_verified_content` is True, an audit user will be able to see the + presence of verified content even if they are not enrolled in verified mode. + See the definition of UserCourseOutlineData for details about the data returned. """ - user_course_outline, _ = _get_user_course_outline_and_processors(course_key, user, at_time) + user_course_outline, _ = _get_user_course_outline_and_processors( + course_key, user, at_time, preview_verified_content=preview_verified_content + ) return user_course_outline @@ -302,7 +308,8 @@ def get_user_course_outline_details(course_key: CourseKey, def _get_user_course_outline_and_processors(course_key: CourseKey, # lint-amnesty, pylint: disable=missing-function-docstring user: types.User, - at_time: datetime): + at_time: datetime, + preview_verified_content: bool = False): """ Helper function that runs the outline processors. @@ -340,6 +347,8 @@ def _get_user_course_outline_and_processors(course_key: CourseKey, # lint-amnes processors = {} usage_keys_to_remove = set() inaccessible_sequences = set() + preview_usage_keys = set() + for name, processor_cls in processor_classes: # Future optimization: This should be parallelizable (don't rely on a # particular ordering). @@ -349,6 +358,14 @@ def _get_user_course_outline_and_processors(course_key: CourseKey, # lint-amnes if not user_can_see_all_content: # function_trace lets us see how expensive each processor is being. with function_trace(f'learning_sequences.api.outline_processors.{name}'): + + # An exception is made for audit preview of verified content. + # Where enabled, we selectively disable the enrollment track partition processor + # so audit learners can preview (see presence of, but not access) of other track content. + if name == 'enrollment_track_partitions' and preview_verified_content: + preview_usage_keys |= processor.usage_keys_to_remove(full_course_outline) + continue + processor_usage_keys_removed = processor.usage_keys_to_remove(full_course_outline) processor_inaccessible_sequences = processor.inaccessible_sequences(full_course_outline) usage_keys_to_remove |= processor_usage_keys_removed @@ -357,12 +374,14 @@ def _get_user_course_outline_and_processors(course_key: CourseKey, # lint-amnes # Open question: Does it make sense to remove a Section if it has no Sequences in it? trimmed_course_outline = full_course_outline.remove(usage_keys_to_remove) accessible_sequences = frozenset(set(trimmed_course_outline.sequences) - inaccessible_sequences) + previewable_sequences = frozenset(preview_usage_keys) user_course_outline = UserCourseOutlineData( base_outline=full_course_outline, user=user, at_time=at_time, accessible_sequences=accessible_sequences, + previewable_sequences=previewable_sequences, **{ name: getattr(trimmed_course_outline, name) for name in [ diff --git a/openedx/core/djangoapps/content/learning_sequences/api/tests/test_outlines.py b/openedx/core/djangoapps/content/learning_sequences/api/tests/test_outlines.py index 20effa6b16cd..ecd1ce282998 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/tests/test_outlines.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/tests/test_outlines.py @@ -9,6 +9,7 @@ from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.db.models import signals +from common.djangoapps.course_modes.tests.factories import CourseModeFactory from edx_proctoring.exceptions import ProctoredExamNotFoundException from edx_toggles.toggles.testutils import override_waffle_flag from edx_when.api import set_dates_for_course @@ -167,6 +168,8 @@ class UserCourseOutlineTestCase(CacheIsolationTestCase): @classmethod def setUpTestData(cls): # lint-amnesty, pylint: disable=super-method-not-called course_key = CourseKey.from_string("course-v1:OpenEdX+Outline+T1") + CourseModeFactory.create(course_id=course_key, mode_slug='verified') + # Users... cls.global_staff = UserFactory.create( username='global_staff', email='gstaff@example.com', is_staff=True @@ -176,6 +179,9 @@ def setUpTestData(cls): # lint-amnesty, pylint: disable=super-method-not-called ) cls.beta_tester = BetaTesterFactory(course_key=course_key) cls.anonymous_user = AnonymousUser() + cls.verified_student = UserFactory.create( + username='verified', email='verified@example.com', is_staff=False + ) # Seed with data cls.course_key = course_key @@ -196,6 +202,10 @@ def setUpTestData(cls): # lint-amnesty, pylint: disable=super-method-not-called cls.student.courseenrollment_set.create(course_id=cls.course_key, is_active=True, mode="audit") # Enroll beta tester in the course cls.beta_tester.courseenrollment_set.create(course_id=cls.course_key, is_active=True, mode="audit") + # Enroll verified student in the course as verified + cls.verified_student.courseenrollment_set.create( + course_id=cls.course_key, is_active=True, mode=CourseMode.VERIFIED + ) def test_simple_outline(self): """This outline is the same for everyone.""" @@ -228,6 +238,87 @@ def test_simple_outline(self): ) assert global_staff_outline_details.outline == global_staff_outline + def test_audit_preview_of_verified_content_enabled(self): + # Given an outline where some content is restricted to verified only + audit_outline = self.simple_outline + verified_sequence = attr.evolve( + audit_outline.sections[0].sequences[0], + user_partition_groups={ + ENROLLMENT_TRACK_PARTITION_ID: [2] # restrict to verified only + } + ) + audit_outline.sections[0].sequences[0] = verified_sequence + replace_course_outline(audit_outline) + at_time = datetime(2020, 5, 21, tzinfo=timezone.utc) + + # ... and where the audit learner verified preview feature is enabled + # When I access them as an audit user + audit_student_outline = get_user_course_outline( + self.course_key, self.student, at_time, preview_verified_content=True + ) + + # Then verified-only content is marked as previewable for the audit user + assert verified_sequence.usage_key in audit_student_outline.previewable_sequences + + # When I access them as a verified user, which would disable this preview check + verified_student_outline = get_user_course_outline( + self.course_key, self.verified_student, at_time + ) + + global_staff_outline = get_user_course_outline( + self.course_key, self.global_staff, at_time + ) + + # For verified and staff, the outline is unchanged + assert verified_student_outline.sections == global_staff_outline.sections + + # ... and do not contain any previewable sequences + assert verified_student_outline.previewable_sequences == set() + assert global_staff_outline.previewable_sequences == set() + + def test_audit_preview_of_verified_content_disabled(self): + """ + This outline has verified content that an audit user can preview + only when the feature is enabled. + """ + # Given an outline where some content is restricted to verified only + audit_outline = self.simple_outline + verified_sequence = attr.evolve( + audit_outline.sections[0].sequences[0], + user_partition_groups={ + ENROLLMENT_TRACK_PARTITION_ID: [2] # restrict to verified only + } + ) + audit_outline.sections[0].sequences[0] = verified_sequence + replace_course_outline(audit_outline) + at_time = datetime(2020, 5, 21, tzinfo=timezone.utc) + + # ... and where the audit learner verified preview feature is disabled + # When I access them as an audit user + audit_student_outline = get_user_course_outline( + self.course_key, self.student, at_time, + preview_verified_content=False + ) + + # Then verified-only content is removed from the outline for the audit user + assert verified_sequence not in audit_student_outline.sections[0].sequences + # ... and is not marked as previewable + assert audit_student_outline.previewable_sequences == set() + + verified_student_outline = get_user_course_outline( + self.course_key, self.verified_student, at_time + ) + global_staff_outline = get_user_course_outline( + self.course_key, self.global_staff, at_time + ) + + # For verified and staff, the outline is unchanged + assert verified_student_outline.sections == global_staff_outline.sections + + # ... and do not contain any previewable sequences + assert verified_student_outline.previewable_sequences == set() + assert global_staff_outline.previewable_sequences == set() + class OutlineProcessorTestCase(CacheIsolationTestCase): # lint-amnesty, pylint: disable=missing-class-docstring @classmethod diff --git a/openedx/core/djangoapps/content/learning_sequences/data.py b/openedx/core/djangoapps/content/learning_sequences/data.py index c13b451490ab..4a6e6da3a0d5 100644 --- a/openedx/core/djangoapps/content/learning_sequences/data.py +++ b/openedx/core/djangoapps/content/learning_sequences/data.py @@ -361,6 +361,9 @@ class is a pretty dumb container that doesn't understand anything about how # not be able to access anything inside. accessible_sequences: FrozenSet[UsageKey] + # Sequences that are not accessible, but are previewable by an audit learner. + previewable_sequences: FrozenSet[UsageKey] + @attr.s(frozen=True, auto_attribs=True) class UserCourseOutlineDetailsData: diff --git a/openedx/core/djangoapps/content/learning_sequences/services.py b/openedx/core/djangoapps/content/learning_sequences/services.py index a43d6ddd598c..e1c1a1402f8a 100644 --- a/openedx/core/djangoapps/content/learning_sequences/services.py +++ b/openedx/core/djangoapps/content/learning_sequences/services.py @@ -2,7 +2,6 @@ Learning Sequences Runtime Service """ - from .api import get_user_course_outline, get_user_course_outline_details @@ -17,8 +16,12 @@ def get_user_course_outline_details(self, course_key, user, at_time): """ return get_user_course_outline_details(course_key, user, at_time) - def get_user_course_outline(self, course_key, user, at_time): + def get_user_course_outline( + self, course_key, user, at_time, preview_verified_content=False + ): """ Returns UserCourseOutlineData """ - return get_user_course_outline(course_key, user, at_time) + return get_user_course_outline( + course_key, user, at_time, preview_verified_content=preview_verified_content + ) diff --git a/openedx/features/course_experience/utils.py b/openedx/features/course_experience/utils.py index c20b7f077136..8cc9b854f377 100644 --- a/openedx/features/course_experience/utils.py +++ b/openedx/features/course_experience/utils.py @@ -76,11 +76,11 @@ def recurse_num_graded_problems(block): def recurse_mark_auth_denial(block): """ - Mark this block as 'scored' if any of its descendents are 'scored' (that is, 'has_score' and 'weight' > 0). + Mark this block access as denied for any reason found in its descendents. """ own_denial_reason = {block['authorization_denial_reason']} if 'authorization_denial_reason' in block else set() # Use a list comprehension to force the recursion over all children, rather than just stopping - # at the first child that is scored. + # at the first child that is blocked. child_denial_reasons = own_denial_reason.union( *(recurse_mark_auth_denial(child) for child in block.get('children', [])) ) From d97e9437c432f86086779804f9a4cd3b3abd9bf5 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Thu, 4 Dec 2025 12:13:25 +0500 Subject: [PATCH 36/54] fix: restrict rendering PDFs from other origins (#51) * fix: restrict rendering PDFs from other origins * test: fix tests according to code * fix: handle both URL scheme types in textbook URL checks * fix: test cases for staticbook --------- Co-authored-by: Muhammad Faraz Maqsood Co-authored-by: Vivek Ambaliya --- lms/djangoapps/staticbook/tests.py | 8 ++++---- lms/djangoapps/staticbook/views.py | 16 ++++++++++++---- lms/templates/static_pdfbook.html | 22 +++++++++++++--------- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/lms/djangoapps/staticbook/tests.py b/lms/djangoapps/staticbook/tests.py index 919ee16c4fef..a4e0d09bf096 100644 --- a/lms/djangoapps/staticbook/tests.py +++ b/lms/djangoapps/staticbook/tests.py @@ -138,7 +138,7 @@ def test_book_chapter(self): url = self.make_url('pdf_book', book_index=0, chapter=2) response = self.client.get(url) self.assertContains(response, "Chapter 2 for PDF") - self.assertContains(response, "file={}".format(PDF_BOOK['chapters'][1]['url'])) + self.assertNotContains(response, "file={}".format(PDF_BOOK['chapters'][1]['url'])) self.assertNotContains(response, "page=") def test_book_page(self): @@ -148,7 +148,7 @@ def test_book_page(self): response = self.client.get(url) self.assertContains(response, "Chapter 1 for PDF") self.assertNotContains(response, "options.chapterNum =") - self.assertContains(response, "page=17") + self.assertNotContains(response, "page=17") def test_book_chapter_page(self): # We can access a book at a particular chapter and page. @@ -156,8 +156,8 @@ def test_book_chapter_page(self): url = self.make_url('pdf_book', book_index=0, chapter=2, page=17) response = self.client.get(url) self.assertContains(response, "Chapter 2 for PDF") - self.assertContains(response, "file={}".format(PDF_BOOK['chapters'][1]['url'])) - self.assertContains(response, "page=17") + self.assertNotContains(response, "file={}".format(PDF_BOOK['chapters'][1]['url'])) + self.assertNotContains(response, "page=17") def test_bad_book_id(self): # If the book id isn't an int, we'll get a 404. diff --git a/lms/djangoapps/staticbook/views.py b/lms/djangoapps/staticbook/views.py index 2e6d1c9a8ed5..a9a6e922fa08 100644 --- a/lms/djangoapps/staticbook/views.py +++ b/lms/djangoapps/staticbook/views.py @@ -86,13 +86,15 @@ def pdf_index(request, course_id, book_index, chapter=None, page=None): raise Http404(f"Invalid book index value: {book_index}") textbook = course.pdf_textbooks[book_index] - viewer_params = '&file=' + viewer_params = '' current_url = '' if 'url' in textbook: textbook['url'] = remap_static_url(textbook['url'], course) - viewer_params += textbook['url'] current_url = textbook['url'] + if not current_url.startswith(('http://', 'https://')): + viewer_params = '&file=' + viewer_params += current_url # then remap all the chapter URLs as well, if they are provided. current_chapter = None @@ -103,14 +105,20 @@ def pdf_index(request, course_id, book_index, chapter=None, page=None): current_chapter = textbook['chapters'][int(chapter) - 1] else: current_chapter = textbook['chapters'][0] - viewer_params += current_chapter['url'] + current_url = current_chapter['url'] + if not current_url.startswith(('http://', 'https://')): + viewer_params = '&file=' + viewer_params += current_url viewer_params += '#zoom=page-fit&disableRange=true' if page is not None: viewer_params += f'&page={page}' - if request.GET.get('viewer', '') == 'true': + if current_url.startswith('https://'): + current_url = '' + template = 'static_pdfbook.html' + elif request.GET.get('viewer', '') == 'true': template = 'pdf_viewer.html' else: template = 'static_pdfbook.html' diff --git a/lms/templates/static_pdfbook.html b/lms/templates/static_pdfbook.html index 813db47a2da0..24e152f06e0e 100644 --- a/lms/templates/static_pdfbook.html +++ b/lms/templates/static_pdfbook.html @@ -47,15 +47,19 @@ %endif
- + % if 'file' in viewer_params: + + % else: + + % endif
From 0a400fbd1c54c48f0c0889eb8278382e10288893 Mon Sep 17 00:00:00 2001 From: Pradeep Date: Mon, 8 Dec 2025 17:52:26 +0530 Subject: [PATCH 37/54] fix: upgrade hls.js to version 1.6.15 and update related imports in video player tests (#57) * fix: upgrade hls.js to version 1.6.15 and update related imports in video player tests * fix: update hls import statements to use 'hls.js' for consistency across video modules * fix: update import statements for consistency in video player tests * fix: add eslint directive to suppress no-shadow-restricted-names warning in video player spec --- package-lock.json | 103 ++------------------- package.json | 2 +- xmodule/js/spec/video/video_player_spec.js | 8 +- xmodule/js/src/video/02_html5_hls_video.js | 16 ++-- xmodule/js/src/video/03_video_player.js | 4 +- 5 files changed, 25 insertions(+), 108 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0da387590a61..6db7903427b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,7 @@ "exports-loader": "0.6.4", "file-loader": "^6.2.0", "font-awesome": "4.7.0", - "hls.js": "0.14.17", + "hls.js": "^1.6.15", "imports-loader": "0.8.0", "jest-environment-jsdom": "^29.0.0", "jquery": "2.2.4", @@ -3556,15 +3556,12 @@ } }, "node_modules/@keyv/serialize": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.0.3.tgz", - "integrity": "sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "buffer": "^6.0.3" - } + "peer": true }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", @@ -5668,28 +5665,6 @@ "node": ">= 0.6.0" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "peer": true - }, "node_modules/base64id": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-1.0.0.tgz", @@ -5869,32 +5844,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/buffer-alloc": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", @@ -8703,6 +8652,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, "license": "MIT" }, "node_modules/events": { @@ -10094,14 +10044,10 @@ } }, "node_modules/hls.js": { - "version": "0.14.17", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-0.14.17.tgz", - "integrity": "sha512-25A7+m6qqp6UVkuzUQ//VVh2EEOPYlOBg32ypr34bcPO7liBMOkKFvbjbCBfiPAOTA/7BSx1Dujft3Th57WyFg==", - "license": "Apache-2.0", - "dependencies": { - "eventemitter3": "^4.0.3", - "url-toolkit": "^2.1.6" - } + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", @@ -10341,28 +10287,6 @@ "postcss": "^8.1.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause", - "peer": true - }, "node_modules/ignore": { "version": "3.3.10", "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.10.tgz", @@ -15439,7 +15363,6 @@ "integrity": "sha512-ijt0LhxWClXBtc1RCt8H0WhlZLAdVX26nWbpsJy+Hblmp81d2F/pFsvlrJhJDDruFHM+ECtxP0H0HzGSrARkwg==", "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", "dev": true, - "license": "MIT", "engines": { "node": ">=0.6.0", "teleport": ">=0.2.0" @@ -20172,12 +20095,6 @@ "requires-port": "^1.0.0" } }, - "node_modules/url-toolkit": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/url-toolkit/-/url-toolkit-2.2.5.tgz", - "integrity": "sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg==", - "license": "Apache-2.0" - }, "node_modules/use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", diff --git a/package.json b/package.json index a8b762450e44..a3dfdd45feff 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "exports-loader": "0.6.4", "file-loader": "^6.2.0", "font-awesome": "4.7.0", - "hls.js": "0.14.17", + "hls.js": "^1.6.15", "imports-loader": "0.8.0", "jest-environment-jsdom": "^29.0.0", "jquery": "2.2.4", diff --git a/xmodule/js/spec/video/video_player_spec.js b/xmodule/js/spec/video/video_player_spec.js index 4845fc80fa1d..5782d760d830 100644 --- a/xmodule/js/spec/video/video_player_spec.js +++ b/xmodule/js/spec/video/video_player_spec.js @@ -1,12 +1,12 @@ /* global YT */ // eslint-disable-next-line no-shadow-restricted-names -(function(require, define, undefined) { +(function() { 'use strict'; require( ['video/03_video_player.js', 'hls', 'underscore'], - function(VideoPlayer, HLS, _) { + function(VideoPlayer, Hls, _) { describe('VideoPlayer', function() { var STATUS = window.STATUS, state, @@ -982,7 +982,7 @@ describe('on safari', function() { beforeEach(function() { - spyOn(HLS, 'isSupported').and.returnValue(false); + spyOn(Hls, 'isSupported').and.returnValue(false); state = jasmine.initializeHLSPlayer(); state.canPlayHLS = true; state.browserIsSafari = true; @@ -996,7 +996,7 @@ describe('HLS Video Errors', function() { beforeEach(function() { - spyOn(HLS, 'isSupported').and.returnValue(false); + spyOn(Hls, 'isSupported').and.returnValue(false); state = jasmine.initializeHLSPlayer({sources: ['/base/fixtures/hls/hls.m3u8']}); }); diff --git a/xmodule/js/src/video/02_html5_hls_video.js b/xmodule/js/src/video/02_html5_hls_video.js index cb6a1a2fda27..00cc967731f8 100644 --- a/xmodule/js/src/video/02_html5_hls_video.js +++ b/xmodule/js/src/video/02_html5_hls_video.js @@ -8,7 +8,7 @@ 'use strict'; define('video/02_html5_hls_video.js', ['underscore', 'video/02_html5_video.js', 'hls'], - function(_, HTML5Video, HLS) { + function(_, HTML5Video, Hls) { var HLSVideo = {}; HLSVideo.Player = (function() { @@ -45,16 +45,16 @@ } else { // load auto start if auto_advance is enabled if (config.state.auto_advance) { - this.hls = new HLS({autoStartLoad: true}); + this.hls = new Hls({autoStartLoad: true}); } else { - this.hls = new HLS({autoStartLoad: false}); + this.hls = new Hls({autoStartLoad: false}); } this.hls.loadSource(config.videoSources[0]); this.hls.attachMedia(this.video); - this.hls.on(HLS.Events.ERROR, this.onError.bind(this)); + this.hls.on(Hls.Events.ERROR, this.onError.bind(this)); - this.hls.on(HLS.Events.MANIFEST_PARSED, function(event, data) { + this.hls.on(Hls.Events.MANIFEST_PARSED, function(event, data) { console.log( '[HLS Video]: MANIFEST_PARSED, qualityLevelsInfo: ', data.levels.map(function(level) { @@ -66,7 +66,7 @@ ); self.config.onReadyHLS(); }); - this.hls.on(HLS.Events.LEVEL_SWITCHED, function(event, data) { + this.hls.on(Hls.Events.LEVEL_SWITCHED, function(event, data) { var level = self.hls.levels[data.level]; console.log( '[HLS Video]: LEVEL_SWITCHED, qualityLevelInfo: ', @@ -114,14 +114,14 @@ Player.prototype.onError = function(event, data) { if (data.fatal) { switch (data.type) { - case HLS.ErrorTypes.NETWORK_ERROR: + case Hls.ErrorTypes.NETWORK_ERROR: console.error( '[HLS Video]: Fatal network error encountered, try to recover. Details: %s', data.details ); this.hls.startLoad(); break; - case HLS.ErrorTypes.MEDIA_ERROR: + case Hls.ErrorTypes.MEDIA_ERROR: console.error( '[HLS Video]: Fatal media error encountered, try to recover. Details: %s', data.details diff --git a/xmodule/js/src/video/03_video_player.js b/xmodule/js/src/video/03_video_player.js index 6215d54689ad..d682a6ccfac7 100644 --- a/xmodule/js/src/video/03_video_player.js +++ b/xmodule/js/src/video/03_video_player.js @@ -4,7 +4,7 @@ define( 'video/03_video_player.js', ['video/02_html5_video.js', 'video/02_html5_hls_video.js', 'video/00_resizer.js', 'hls', 'underscore', '../time.js'], - function(HTML5Video, HTML5HLSVideo, Resizer, HLS, _, Time) { + function(HTML5Video, HTML5HLSVideo, Resizer, Hls, _, Time) { var dfd = $.Deferred(), VideoPlayer = function(state) { state.videoPlayer = {}; @@ -157,7 +157,7 @@ // Browser can play HLS videos if either `Media Source Extensions` // feature is supported or browser is safari (native HLS support) - state.canPlayHLS = state.HLSVideoSources.length > 0 && (HLS.isSupported() || state.browserIsSafari); + state.canPlayHLS = state.HLSVideoSources.length > 0 && (Hls.isSupported() || state.browserIsSafari); state.HLSOnlySources = state.config.sources.length > 0 && state.config.sources.length === state.HLSVideoSources.length; From 70330921c1854b78a6d3bcf4420b7355174f0a04 Mon Sep 17 00:00:00 2001 From: Krish Tyagi Date: Tue, 9 Dec 2025 16:01:28 +0530 Subject: [PATCH 38/54] fix: update geolite workflow (#40) * fix: update geolite workflow --- .github/workflows/update-geolite-database.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/update-geolite-database.yml b/.github/workflows/update-geolite-database.yml index 484fa167a371..c13cd5469722 100644 --- a/.github/workflows/update-geolite-database.yml +++ b/.github/workflows/update-geolite-database.yml @@ -8,7 +8,7 @@ on: branch: description: "Target branch against which to create PR" required: false - default: "master" + default: "release-ulmo" env: MAXMIND_URL: "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${{ secrets.MAXMIND_LICENSE_KEY }}&suffix=tar.gz" @@ -79,13 +79,13 @@ jobs: --title "Update GeoLite Database" \ --body "PR generated by workflow `${{ github.workflow }}` on behalf of @${{ github.actor }}." \ --head $BRANCH \ - --base 'master' \ - --reviewer 'feanil' \ + --base 'release-ulmo' \ + --reviewer 'edx/orbi-bom' \ | grep -o 'https://github.com/.*/pull/[0-9]*') echo "PR Created: ${PR_URL}" echo "pull-request-url=$PR_URL" >> $GITHUB_OUTPUT env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.GH_PAT_WITH_ORG }} - name: Job summary run: | From f348776bd6440a381216eb54831f8ad001cba95e Mon Sep 17 00:00:00 2001 From: Pradeep Date: Thu, 11 Dec 2025 14:48:19 +0530 Subject: [PATCH 39/54] =?UTF-8?q?Revert=20"fix:=20upgrade=20hls.js=20to=20?= =?UTF-8?q?version=201.6.15=20and=20update=20related=20imports=20in=20v?= =?UTF-8?q?=E2=80=A6"=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 0a400fbd1c54c48f0c0889eb8278382e10288893. --- package-lock.json | 103 +++++++++++++++++++-- package.json | 2 +- xmodule/js/spec/video/video_player_spec.js | 8 +- xmodule/js/src/video/02_html5_hls_video.js | 16 ++-- xmodule/js/src/video/03_video_player.js | 4 +- 5 files changed, 108 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6db7903427b4..0da387590a61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,7 @@ "exports-loader": "0.6.4", "file-loader": "^6.2.0", "font-awesome": "4.7.0", - "hls.js": "^1.6.15", + "hls.js": "0.14.17", "imports-loader": "0.8.0", "jest-environment-jsdom": "^29.0.0", "jquery": "2.2.4", @@ -3556,12 +3556,15 @@ } }, "node_modules/@keyv/serialize": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", - "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.0.3.tgz", + "integrity": "sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==", "dev": true, "license": "MIT", - "peer": true + "peer": true, + "dependencies": { + "buffer": "^6.0.3" + } }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", @@ -5665,6 +5668,28 @@ "node": ">= 0.6.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, "node_modules/base64id": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-1.0.0.tgz", @@ -5844,6 +5869,32 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-alloc": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", @@ -8652,7 +8703,6 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true, "license": "MIT" }, "node_modules/events": { @@ -10044,10 +10094,14 @@ } }, "node_modules/hls.js": { - "version": "1.6.15", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", - "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", - "license": "Apache-2.0" + "version": "0.14.17", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-0.14.17.tgz", + "integrity": "sha512-25A7+m6qqp6UVkuzUQ//VVh2EEOPYlOBg32ypr34bcPO7liBMOkKFvbjbCBfiPAOTA/7BSx1Dujft3Th57WyFg==", + "license": "Apache-2.0", + "dependencies": { + "eventemitter3": "^4.0.3", + "url-toolkit": "^2.1.6" + } }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", @@ -10287,6 +10341,28 @@ "postcss": "^8.1.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/ignore": { "version": "3.3.10", "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.10.tgz", @@ -15363,6 +15439,7 @@ "integrity": "sha512-ijt0LhxWClXBtc1RCt8H0WhlZLAdVX26nWbpsJy+Hblmp81d2F/pFsvlrJhJDDruFHM+ECtxP0H0HzGSrARkwg==", "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", "dev": true, + "license": "MIT", "engines": { "node": ">=0.6.0", "teleport": ">=0.2.0" @@ -20095,6 +20172,12 @@ "requires-port": "^1.0.0" } }, + "node_modules/url-toolkit": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/url-toolkit/-/url-toolkit-2.2.5.tgz", + "integrity": "sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg==", + "license": "Apache-2.0" + }, "node_modules/use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", diff --git a/package.json b/package.json index a3dfdd45feff..a8b762450e44 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "exports-loader": "0.6.4", "file-loader": "^6.2.0", "font-awesome": "4.7.0", - "hls.js": "^1.6.15", + "hls.js": "0.14.17", "imports-loader": "0.8.0", "jest-environment-jsdom": "^29.0.0", "jquery": "2.2.4", diff --git a/xmodule/js/spec/video/video_player_spec.js b/xmodule/js/spec/video/video_player_spec.js index 5782d760d830..4845fc80fa1d 100644 --- a/xmodule/js/spec/video/video_player_spec.js +++ b/xmodule/js/spec/video/video_player_spec.js @@ -1,12 +1,12 @@ /* global YT */ // eslint-disable-next-line no-shadow-restricted-names -(function() { +(function(require, define, undefined) { 'use strict'; require( ['video/03_video_player.js', 'hls', 'underscore'], - function(VideoPlayer, Hls, _) { + function(VideoPlayer, HLS, _) { describe('VideoPlayer', function() { var STATUS = window.STATUS, state, @@ -982,7 +982,7 @@ describe('on safari', function() { beforeEach(function() { - spyOn(Hls, 'isSupported').and.returnValue(false); + spyOn(HLS, 'isSupported').and.returnValue(false); state = jasmine.initializeHLSPlayer(); state.canPlayHLS = true; state.browserIsSafari = true; @@ -996,7 +996,7 @@ describe('HLS Video Errors', function() { beforeEach(function() { - spyOn(Hls, 'isSupported').and.returnValue(false); + spyOn(HLS, 'isSupported').and.returnValue(false); state = jasmine.initializeHLSPlayer({sources: ['/base/fixtures/hls/hls.m3u8']}); }); diff --git a/xmodule/js/src/video/02_html5_hls_video.js b/xmodule/js/src/video/02_html5_hls_video.js index 00cc967731f8..cb6a1a2fda27 100644 --- a/xmodule/js/src/video/02_html5_hls_video.js +++ b/xmodule/js/src/video/02_html5_hls_video.js @@ -8,7 +8,7 @@ 'use strict'; define('video/02_html5_hls_video.js', ['underscore', 'video/02_html5_video.js', 'hls'], - function(_, HTML5Video, Hls) { + function(_, HTML5Video, HLS) { var HLSVideo = {}; HLSVideo.Player = (function() { @@ -45,16 +45,16 @@ } else { // load auto start if auto_advance is enabled if (config.state.auto_advance) { - this.hls = new Hls({autoStartLoad: true}); + this.hls = new HLS({autoStartLoad: true}); } else { - this.hls = new Hls({autoStartLoad: false}); + this.hls = new HLS({autoStartLoad: false}); } this.hls.loadSource(config.videoSources[0]); this.hls.attachMedia(this.video); - this.hls.on(Hls.Events.ERROR, this.onError.bind(this)); + this.hls.on(HLS.Events.ERROR, this.onError.bind(this)); - this.hls.on(Hls.Events.MANIFEST_PARSED, function(event, data) { + this.hls.on(HLS.Events.MANIFEST_PARSED, function(event, data) { console.log( '[HLS Video]: MANIFEST_PARSED, qualityLevelsInfo: ', data.levels.map(function(level) { @@ -66,7 +66,7 @@ ); self.config.onReadyHLS(); }); - this.hls.on(Hls.Events.LEVEL_SWITCHED, function(event, data) { + this.hls.on(HLS.Events.LEVEL_SWITCHED, function(event, data) { var level = self.hls.levels[data.level]; console.log( '[HLS Video]: LEVEL_SWITCHED, qualityLevelInfo: ', @@ -114,14 +114,14 @@ Player.prototype.onError = function(event, data) { if (data.fatal) { switch (data.type) { - case Hls.ErrorTypes.NETWORK_ERROR: + case HLS.ErrorTypes.NETWORK_ERROR: console.error( '[HLS Video]: Fatal network error encountered, try to recover. Details: %s', data.details ); this.hls.startLoad(); break; - case Hls.ErrorTypes.MEDIA_ERROR: + case HLS.ErrorTypes.MEDIA_ERROR: console.error( '[HLS Video]: Fatal media error encountered, try to recover. Details: %s', data.details diff --git a/xmodule/js/src/video/03_video_player.js b/xmodule/js/src/video/03_video_player.js index d682a6ccfac7..6215d54689ad 100644 --- a/xmodule/js/src/video/03_video_player.js +++ b/xmodule/js/src/video/03_video_player.js @@ -4,7 +4,7 @@ define( 'video/03_video_player.js', ['video/02_html5_video.js', 'video/02_html5_hls_video.js', 'video/00_resizer.js', 'hls', 'underscore', '../time.js'], - function(HTML5Video, HTML5HLSVideo, Resizer, Hls, _, Time) { + function(HTML5Video, HTML5HLSVideo, Resizer, HLS, _, Time) { var dfd = $.Deferred(), VideoPlayer = function(state) { state.videoPlayer = {}; @@ -157,7 +157,7 @@ // Browser can play HLS videos if either `Media Source Extensions` // feature is supported or browser is safari (native HLS support) - state.canPlayHLS = state.HLSVideoSources.length > 0 && (Hls.isSupported() || state.browserIsSafari); + state.canPlayHLS = state.HLSVideoSources.length > 0 && (HLS.isSupported() || state.browserIsSafari); state.HLSOnlySources = state.config.sources.length > 0 && state.config.sources.length === state.HLSVideoSources.length; From 34bd6597b23daec36845d5604197839440c5547f Mon Sep 17 00:00:00 2001 From: Pradeep Date: Thu, 11 Dec 2025 16:53:17 +0530 Subject: [PATCH 40/54] =?UTF-8?q?Revert=20"Revert=20"fix:=20upgrade=20hls.?= =?UTF-8?q?js=20to=20version=201.6.15=20and=20update=20related=20impo?= =?UTF-8?q?=E2=80=A6"=20(#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit f348776bd6440a381216eb54831f8ad001cba95e. --- package-lock.json | 103 ++------------------- package.json | 2 +- xmodule/js/spec/video/video_player_spec.js | 8 +- xmodule/js/src/video/02_html5_hls_video.js | 16 ++-- xmodule/js/src/video/03_video_player.js | 4 +- 5 files changed, 25 insertions(+), 108 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0da387590a61..6db7903427b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,7 @@ "exports-loader": "0.6.4", "file-loader": "^6.2.0", "font-awesome": "4.7.0", - "hls.js": "0.14.17", + "hls.js": "^1.6.15", "imports-loader": "0.8.0", "jest-environment-jsdom": "^29.0.0", "jquery": "2.2.4", @@ -3556,15 +3556,12 @@ } }, "node_modules/@keyv/serialize": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.0.3.tgz", - "integrity": "sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "buffer": "^6.0.3" - } + "peer": true }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", @@ -5668,28 +5665,6 @@ "node": ">= 0.6.0" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "peer": true - }, "node_modules/base64id": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-1.0.0.tgz", @@ -5869,32 +5844,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/buffer-alloc": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", @@ -8703,6 +8652,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, "license": "MIT" }, "node_modules/events": { @@ -10094,14 +10044,10 @@ } }, "node_modules/hls.js": { - "version": "0.14.17", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-0.14.17.tgz", - "integrity": "sha512-25A7+m6qqp6UVkuzUQ//VVh2EEOPYlOBg32ypr34bcPO7liBMOkKFvbjbCBfiPAOTA/7BSx1Dujft3Th57WyFg==", - "license": "Apache-2.0", - "dependencies": { - "eventemitter3": "^4.0.3", - "url-toolkit": "^2.1.6" - } + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", @@ -10341,28 +10287,6 @@ "postcss": "^8.1.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause", - "peer": true - }, "node_modules/ignore": { "version": "3.3.10", "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.10.tgz", @@ -15439,7 +15363,6 @@ "integrity": "sha512-ijt0LhxWClXBtc1RCt8H0WhlZLAdVX26nWbpsJy+Hblmp81d2F/pFsvlrJhJDDruFHM+ECtxP0H0HzGSrARkwg==", "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", "dev": true, - "license": "MIT", "engines": { "node": ">=0.6.0", "teleport": ">=0.2.0" @@ -20172,12 +20095,6 @@ "requires-port": "^1.0.0" } }, - "node_modules/url-toolkit": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/url-toolkit/-/url-toolkit-2.2.5.tgz", - "integrity": "sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg==", - "license": "Apache-2.0" - }, "node_modules/use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", diff --git a/package.json b/package.json index a8b762450e44..a3dfdd45feff 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "exports-loader": "0.6.4", "file-loader": "^6.2.0", "font-awesome": "4.7.0", - "hls.js": "0.14.17", + "hls.js": "^1.6.15", "imports-loader": "0.8.0", "jest-environment-jsdom": "^29.0.0", "jquery": "2.2.4", diff --git a/xmodule/js/spec/video/video_player_spec.js b/xmodule/js/spec/video/video_player_spec.js index 4845fc80fa1d..5782d760d830 100644 --- a/xmodule/js/spec/video/video_player_spec.js +++ b/xmodule/js/spec/video/video_player_spec.js @@ -1,12 +1,12 @@ /* global YT */ // eslint-disable-next-line no-shadow-restricted-names -(function(require, define, undefined) { +(function() { 'use strict'; require( ['video/03_video_player.js', 'hls', 'underscore'], - function(VideoPlayer, HLS, _) { + function(VideoPlayer, Hls, _) { describe('VideoPlayer', function() { var STATUS = window.STATUS, state, @@ -982,7 +982,7 @@ describe('on safari', function() { beforeEach(function() { - spyOn(HLS, 'isSupported').and.returnValue(false); + spyOn(Hls, 'isSupported').and.returnValue(false); state = jasmine.initializeHLSPlayer(); state.canPlayHLS = true; state.browserIsSafari = true; @@ -996,7 +996,7 @@ describe('HLS Video Errors', function() { beforeEach(function() { - spyOn(HLS, 'isSupported').and.returnValue(false); + spyOn(Hls, 'isSupported').and.returnValue(false); state = jasmine.initializeHLSPlayer({sources: ['/base/fixtures/hls/hls.m3u8']}); }); diff --git a/xmodule/js/src/video/02_html5_hls_video.js b/xmodule/js/src/video/02_html5_hls_video.js index cb6a1a2fda27..00cc967731f8 100644 --- a/xmodule/js/src/video/02_html5_hls_video.js +++ b/xmodule/js/src/video/02_html5_hls_video.js @@ -8,7 +8,7 @@ 'use strict'; define('video/02_html5_hls_video.js', ['underscore', 'video/02_html5_video.js', 'hls'], - function(_, HTML5Video, HLS) { + function(_, HTML5Video, Hls) { var HLSVideo = {}; HLSVideo.Player = (function() { @@ -45,16 +45,16 @@ } else { // load auto start if auto_advance is enabled if (config.state.auto_advance) { - this.hls = new HLS({autoStartLoad: true}); + this.hls = new Hls({autoStartLoad: true}); } else { - this.hls = new HLS({autoStartLoad: false}); + this.hls = new Hls({autoStartLoad: false}); } this.hls.loadSource(config.videoSources[0]); this.hls.attachMedia(this.video); - this.hls.on(HLS.Events.ERROR, this.onError.bind(this)); + this.hls.on(Hls.Events.ERROR, this.onError.bind(this)); - this.hls.on(HLS.Events.MANIFEST_PARSED, function(event, data) { + this.hls.on(Hls.Events.MANIFEST_PARSED, function(event, data) { console.log( '[HLS Video]: MANIFEST_PARSED, qualityLevelsInfo: ', data.levels.map(function(level) { @@ -66,7 +66,7 @@ ); self.config.onReadyHLS(); }); - this.hls.on(HLS.Events.LEVEL_SWITCHED, function(event, data) { + this.hls.on(Hls.Events.LEVEL_SWITCHED, function(event, data) { var level = self.hls.levels[data.level]; console.log( '[HLS Video]: LEVEL_SWITCHED, qualityLevelInfo: ', @@ -114,14 +114,14 @@ Player.prototype.onError = function(event, data) { if (data.fatal) { switch (data.type) { - case HLS.ErrorTypes.NETWORK_ERROR: + case Hls.ErrorTypes.NETWORK_ERROR: console.error( '[HLS Video]: Fatal network error encountered, try to recover. Details: %s', data.details ); this.hls.startLoad(); break; - case HLS.ErrorTypes.MEDIA_ERROR: + case Hls.ErrorTypes.MEDIA_ERROR: console.error( '[HLS Video]: Fatal media error encountered, try to recover. Details: %s', data.details diff --git a/xmodule/js/src/video/03_video_player.js b/xmodule/js/src/video/03_video_player.js index 6215d54689ad..d682a6ccfac7 100644 --- a/xmodule/js/src/video/03_video_player.js +++ b/xmodule/js/src/video/03_video_player.js @@ -4,7 +4,7 @@ define( 'video/03_video_player.js', ['video/02_html5_video.js', 'video/02_html5_hls_video.js', 'video/00_resizer.js', 'hls', 'underscore', '../time.js'], - function(HTML5Video, HTML5HLSVideo, Resizer, HLS, _, Time) { + function(HTML5Video, HTML5HLSVideo, Resizer, Hls, _, Time) { var dfd = $.Deferred(), VideoPlayer = function(state) { state.videoPlayer = {}; @@ -157,7 +157,7 @@ // Browser can play HLS videos if either `Media Source Extensions` // feature is supported or browser is safari (native HLS support) - state.canPlayHLS = state.HLSVideoSources.length > 0 && (HLS.isSupported() || state.browserIsSafari); + state.canPlayHLS = state.HLSVideoSources.length > 0 && (Hls.isSupported() || state.browserIsSafari); state.HLSOnlySources = state.config.sources.length > 0 && state.config.sources.length === state.HLSVideoSources.length; From b25d1bcf3137d04e2120e112ccca1eb948dc3657 Mon Sep 17 00:00:00 2001 From: Krish Tyagi Date: Sat, 13 Dec 2025 12:04:47 +0530 Subject: [PATCH 41/54] fix: improving geolite workflow (#63) --- .github/workflows/update-geolite-database.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update-geolite-database.yml b/.github/workflows/update-geolite-database.yml index c13cd5469722..d9ad767ce57e 100644 --- a/.github/workflows/update-geolite-database.yml +++ b/.github/workflows/update-geolite-database.yml @@ -69,7 +69,7 @@ jobs: - name: Create a branch, commit the code and make a PR id: create-pr run: | - BRANCH="${{ github.actor }}/geoip2-bot-update-country-database-$(echo "${{ github.sha }}" | cut -c 1-7)" + BRANCH="${{ github.actor }}/geoip2-bot-update-country-database-${{ github.run_id }}" git checkout -b $BRANCH git add . git status From f987e8e83469758c28f9bf87780343cb473c28da Mon Sep 17 00:00:00 2001 From: Robert Raposa Date: Wed, 17 Dec 2025 14:30:40 -0500 Subject: [PATCH 42/54] fix: CourseLimitedStaffRole should not be able to access studio. (#65) We previously fixed this when the CourseLimitedStaffRole was applied to a course but did not handle the case where the role is applied to a user for a whole org. The underlying issue is that the CourseLimitedStaffRole is a subclass of the CourseStaffRole and much of the system assumes that subclesses are for giving more access not less access. To prevent that from happening for the case of the CourseLimitedStaffRole, when we do CourseStaffRole access checks, we use the strict_role_checking context manager to ensure that we're not accidentally granting the limited_staff role too much access. Co-authored-by: Feanil Patel (cherry-picked from openedx/edx-platform) --- .../contentstore/tests/test_course_listing.py | 44 +++++++++++++++++++ cms/djangoapps/contentstore/views/course.py | 5 ++- common/djangoapps/student/auth.py | 6 ++- common/djangoapps/student/tests/test_authz.py | 18 ++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_course_listing.py b/cms/djangoapps/contentstore/tests/test_course_listing.py index e46b493b7b39..a2b6f07d15ef 100644 --- a/cms/djangoapps/contentstore/tests/test_course_listing.py +++ b/cms/djangoapps/contentstore/tests/test_course_listing.py @@ -24,8 +24,10 @@ get_courses_accessible_to_user ) from common.djangoapps.course_action_state.models import CourseRerunState +from common.djangoapps.student.models.user import CourseAccessRole from common.djangoapps.student.roles import ( CourseInstructorRole, + CourseLimitedStaffRole, CourseStaffRole, GlobalStaff, OrgInstructorRole, @@ -188,6 +190,48 @@ def test_staff_course_listing(self): with self.assertNumQueries(2): list(_accessible_courses_summary_iter(self.request)) + def test_course_limited_staff_course_listing(self): + # Setup a new course + course_location = self.store.make_course_key('Org', 'CreatedCourse', 'Run') + CourseFactory.create( + org=course_location.org, + number=course_location.course, + run=course_location.run + ) + course = CourseOverviewFactory.create(id=course_location, org=course_location.org) + + # Add the user as a course_limited_staff on the course + CourseLimitedStaffRole(course.id).add_users(self.user) + self.assertTrue(CourseLimitedStaffRole(course.id).has_user(self.user)) + + # Fetch accessible courses list & verify their count + courses_list_by_staff, __ = get_courses_accessible_to_user(self.request) + + # Limited Course Staff should not be able to list courses in Studio + assert len(list(courses_list_by_staff)) == 0 + + def test_org_limited_staff_course_listing(self): + + # Setup a new course + course_location = self.store.make_course_key('Org', 'CreatedCourse', 'Run') + CourseFactory.create( + org=course_location.org, + number=course_location.course, + run=course_location.run + ) + course = CourseOverviewFactory.create(id=course_location, org=course_location.org) + + # Add a user as course_limited_staff on the org + # This is not possible using the course roles classes but is possible via Django admin so we + # insert a row into the model directly to test that scenario. + CourseAccessRole.objects.create(user=self.user, org=course_location.org, role=CourseLimitedStaffRole.ROLE) + + # Fetch accessible courses list & verify their count + courses_list_by_staff, __ = get_courses_accessible_to_user(self.request) + + # Limited Course Staff should not be able to list courses in Studio + assert len(list(courses_list_by_staff)) == 0 + def test_get_course_list_with_invalid_course_location(self): """ Test getting courses with invalid course location (course deleted from modulestore). diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index fa8769dc0cb9..a93a35cf1d3c 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -56,6 +56,7 @@ GlobalStaff, UserBasedRole, OrgStaffRole, + strict_role_checking, ) from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json from common.djangoapps.util.string_utils import _has_non_ascii_characters @@ -536,7 +537,9 @@ def filter_ccx(course_access): return not isinstance(course_access.course_id, CCXLocator) instructor_courses = UserBasedRole(request.user, CourseInstructorRole.ROLE).courses_with_role() - staff_courses = UserBasedRole(request.user, CourseStaffRole.ROLE).courses_with_role() + with strict_role_checking(): + staff_courses = UserBasedRole(request.user, CourseStaffRole.ROLE).courses_with_role() + all_courses = list(filter(filter_ccx, instructor_courses | staff_courses)) courses_list = [] course_keys = {} diff --git a/common/djangoapps/student/auth.py b/common/djangoapps/student/auth.py index e199142fe377..047f0174a062 100644 --- a/common/djangoapps/student/auth.py +++ b/common/djangoapps/student/auth.py @@ -24,6 +24,7 @@ OrgInstructorRole, OrgLibraryUserRole, OrgStaffRole, + strict_role_checking, ) # Studio permissions: @@ -115,8 +116,9 @@ def get_user_permissions(user, course_key, org=None, service_variant=None): return STUDIO_NO_PERMISSIONS # Staff have all permissions except EDIT_ROLES: - if OrgStaffRole(org=org).has_user(user) or (course_key and user_has_role(user, CourseStaffRole(course_key))): - return STUDIO_VIEW_USERS | STUDIO_EDIT_CONTENT | STUDIO_VIEW_CONTENT + with strict_role_checking(): + if OrgStaffRole(org=org).has_user(user) or (course_key and user_has_role(user, CourseStaffRole(course_key))): + return STUDIO_VIEW_USERS | STUDIO_EDIT_CONTENT | STUDIO_VIEW_CONTENT # Otherwise, for libraries, users can view only: if course_key and isinstance(course_key, LibraryLocator): diff --git a/common/djangoapps/student/tests/test_authz.py b/common/djangoapps/student/tests/test_authz.py index c0b88e6318b5..70636e04b68a 100644 --- a/common/djangoapps/student/tests/test_authz.py +++ b/common/djangoapps/student/tests/test_authz.py @@ -11,6 +11,7 @@ from django.test import TestCase, override_settings from opaque_keys.edx.locator import CourseLocator +from common.djangoapps.student.models.user import CourseAccessRole from common.djangoapps.student.auth import ( add_users, has_studio_read_access, @@ -305,6 +306,23 @@ def test_limited_staff_no_studio_access_cms(self): assert not has_studio_read_access(self.limited_staff, self.course_key) assert not has_studio_write_access(self.limited_staff, self.course_key) + @override_settings(SERVICE_VARIANT='cms') + def test_limited_org_staff_no_studio_access_cms(self): + """ + Verifies that course limited staff have no read and no write access when SERVICE_VARIANT is not 'lms'. + """ + # Add a user as course_limited_staff on the org + # This is not possible using the course roles classes but is possible via Django admin so we + # insert a row into the model directly to test that scenario. + CourseAccessRole.objects.create( + user=self.limited_staff, + org=self.course_key.org, + role=CourseLimitedStaffRole.ROLE, + ) + + assert not has_studio_read_access(self.limited_staff, self.course_key) + assert not has_studio_write_access(self.limited_staff, self.course_key) + class CourseOrgGroupTest(TestCase): """ From 4094b3a51b95469b299ac36df9acfd9bf89e29c4 Mon Sep 17 00:00:00 2001 From: Akanshu Aich Date: Fri, 19 Dec 2025 13:23:37 +0530 Subject: [PATCH 43/54] fix: added logs and record exception for datadog monitoring (#68) Ticket: https://2u-internal.atlassian.net/browse/BOMS-290 --- .../djangoapps/user_api/accounts/views.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 2a5b38fbe3fa..f338fd510177 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -23,6 +23,7 @@ from drf_yasg.utils import swagger_auto_schema from edx_ace import ace from edx_ace.recipient import Recipient +from edx_django_utils.monitoring import record_exception from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser, PendingEnterpriseCustomerUser @@ -1107,10 +1108,32 @@ def post(self, request): user=retirement.user, ) except UserRetirementStatus.DoesNotExist: + log.error( + 'UserRetirementStatus not found for retirement action' + ) + record_exception() return Response(status=status.HTTP_404_NOT_FOUND) except RetirementStateError as exc: + try: + user_id = retirement.user.id + except AttributeError: + user_id = 'unknown' + log.error( + 'RetirementStateError during user retirement: user_id=%s, error=%s', + user_id, str(exc) + ) + record_exception() return Response(str(exc), status=status.HTTP_400_BAD_REQUEST) except Exception as exc: # pylint: disable=broad-except + try: + user_id = retirement.user.id + except AttributeError: + user_id = 'unknown' + log.error( + 'Unexpected error during user retirement: user_id=%s, error=%s', + user_id, str(exc) + ) + record_exception() return Response(str(exc), status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response(status=status.HTTP_204_NO_CONTENT) From 30f0dab4e9942db6fed453adf17cc31dd50567f7 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 19 Dec 2025 22:12:36 +0530 Subject: [PATCH 44/54] feat: extend beta component types and improve editor behavior (#67) * feat: add games to beta component types * feat: auto-open editor on block creation --------- Co-authored-by: pganesh-apphelix --- cms/djangoapps/contentstore/views/component.py | 6 ++++-- cms/static/js/views/pages/container.js | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 470d4274d1b0..82db51f958fe 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -69,11 +69,13 @@ def is_games_xblock_enabled(): 'openassessment', 'drag-and-drop-v2', ] -if is_games_xblock_enabled(): - COMPONENT_TYPES.append('games') BETA_COMPONENT_TYPES = ['library_v2', 'itembank'] +if is_games_xblock_enabled(): + COMPONENT_TYPES.append('games') + BETA_COMPONENT_TYPES.append('games') + ADVANCED_COMPONENT_TYPES = sorted({name for name, class_ in XBlock.load_classes()} - set(COMPONENT_TYPES)) ADVANCED_PROBLEM_TYPES = settings.ADVANCED_PROBLEM_TYPES diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index 9f8c5ddc6d51..88a0f3397f2a 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -301,6 +301,12 @@ function($, _, Backbone, gettext, BasePage, this.xblockView.refresh(xblockView, block_added, is_duplicate); // Update publish and last modified information from the server. this.model.fetch(); + + // Auto-open editor for games blocks when first added + if (block_added && !is_duplicate && xblockView && xblockView.$el && + xblockView.$el.find('.xblock-header-primary').attr('data-block-type') === 'games') { + setTimeout(function() { xblockView.$el.find('.edit-button').first().trigger('click'); }, 500); + } }, renderAddXBlockComponents: function() { From 1e8714f6c0b27f3bfc50ab33a317bc860846d978 Mon Sep 17 00:00:00 2001 From: Chintan Joshi Date: Tue, 6 Jan 2026 19:01:33 +0530 Subject: [PATCH 45/54] feat: soft delete (#37) Implements soft delete functionality for discussion threads, responses, and comments using the is_deleted flag instead of permanently deleting records. This enables safe deletion and restoration of discussion content while preserving existing data. --- lms/djangoapps/discussion/rest_api/api.py | 1272 ++++++++++++---- lms/djangoapps/discussion/rest_api/forms.py | 73 +- .../discussion/rest_api/serializers.py | 287 +++- lms/djangoapps/discussion/rest_api/tasks.py | 110 +- .../discussion/rest_api/tests/test_api_v2.py | 105 +- .../discussion/rest_api/tests/test_forms.py | 99 +- .../rest_api/tests/test_serializers.py | 592 ++++---- .../discussion/rest_api/tests/test_views.py | 1284 ++++++++++------- .../rest_api/tests/test_views_v2.py | 65 +- .../discussion/rest_api/tests/utils.py | 440 +++--- lms/djangoapps/discussion/rest_api/urls.py | 64 +- lms/djangoapps/discussion/rest_api/views.py | 583 ++++++-- .../comment_client/comment.py | 142 +- .../comment_client/models.py | 96 +- .../comment_client/thread.py | 325 +++-- 15 files changed, 3737 insertions(+), 1800 deletions(-) diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index b87852c16cfa..fcc13efc40b8 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -1,17 +1,17 @@ """ Discussion API internal interface """ + from __future__ import annotations import itertools +import logging import re from collections import defaultdict from datetime import datetime - from enum import Enum from typing import Dict, Iterable, List, Literal, Optional, Set, Tuple from urllib.parse import urlencode, urlunparse -from pytz import UTC from django.conf import settings from django.contrib.auth import get_user_model @@ -19,24 +19,26 @@ from django.db.models import Q from django.http import Http404 from django.urls import reverse +from django.utils.html import strip_tags from edx_django_utils.monitoring import function_trace from opaque_keys import InvalidKeyError from opaque_keys.edx.locator import CourseKey +from pytz import UTC from rest_framework import status from rest_framework.exceptions import PermissionDenied from rest_framework.request import Request from rest_framework.response import Response -from common.djangoapps.student.roles import ( - CourseInstructorRole, - CourseStaffRole, -) - +from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole +from forum import api as forum_api from lms.djangoapps.course_api.blocks.api import get_blocks from lms.djangoapps.courseware.courses import get_course_with_access from lms.djangoapps.courseware.exceptions import CourseAccessRedirect from lms.djangoapps.discussion.rate_limit import is_content_creation_rate_limited -from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE, ONLY_VERIFIED_USERS_CAN_POST +from lms.djangoapps.discussion.toggles import ( + ENABLE_DISCUSSIONS_MFE, + ONLY_VERIFIED_USERS_CAN_POST, +) from lms.djangoapps.discussion.views import is_privileged_user from openedx.core.djangoapps.discussions.models import ( DiscussionsConfiguration, @@ -48,12 +50,12 @@ from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment from openedx.core.djangoapps.django_comment_common.comment_client.course import ( get_course_commentable_counts, - get_course_user_stats + get_course_user_stats, ) from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread from openedx.core.djangoapps.django_comment_common.comment_client.utils import ( CommentClient500Error, - CommentClientRequestError + CommentClientRequestError, ) from openedx.core.djangoapps.django_comment_common.models import ( FORUM_ROLE_ADMINISTRATOR, @@ -61,13 +63,13 @@ FORUM_ROLE_GROUP_MODERATOR, FORUM_ROLE_MODERATOR, CourseDiscussionSettings, - Role + Role, ) from openedx.core.djangoapps.django_comment_common.signals import ( comment_created, comment_deleted, - comment_endorsed, comment_edited, + comment_endorsed, comment_flagged, comment_voted, thread_created, @@ -75,11 +77,15 @@ thread_edited, thread_flagged, thread_followed, + thread_unfollowed, thread_voted, - thread_unfollowed ) from openedx.core.djangoapps.user_api.accounts.api import get_account_settings -from openedx.core.lib.exceptions import CourseNotFoundError, DiscussionNotFoundError, PageNotFoundError +from openedx.core.lib.exceptions import ( + CourseNotFoundError, + DiscussionNotFoundError, + PageNotFoundError, +) from xmodule.course_block import CourseBlock from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore @@ -88,21 +94,27 @@ from ..django_comment_client.base.views import ( track_comment_created_event, track_comment_deleted_event, + track_discussion_reported_event, + track_discussion_unreported_event, + track_forum_search_event, track_thread_created_event, track_thread_deleted_event, + track_thread_followed_event, track_thread_viewed_event, track_voted_event, - track_discussion_reported_event, - track_discussion_unreported_event, - track_forum_search_event, track_thread_followed_event ) from ..django_comment_client.utils import ( get_group_id_for_user, get_user_role_names, has_discussion_privileges, - is_commentable_divided + is_commentable_divided, +) +from .exceptions import ( + CommentNotFoundError, + DiscussionBlackOutException, + DiscussionDisabledError, + ThreadNotFoundError, ) -from .exceptions import CommentNotFoundError, DiscussionBlackOutException, DiscussionDisabledError, ThreadNotFoundError from .forms import CommentActionsForm, ThreadActionsForm, UserOrdering from .pagination import DiscussionAPIPagination from .permissions import ( @@ -110,7 +122,7 @@ can_take_action_on_spam, get_editable_fields, get_initializable_comment_fields, - get_initializable_thread_fields + get_initializable_thread_fields, ) from .serializers import ( CommentSerializer, @@ -119,20 +131,23 @@ ThreadSerializer, TopicOrdering, UserStatsSerializer, - get_context + get_context, ) from .utils import ( AttributeDict, add_stats_for_users_with_no_discussion_content, + can_user_notify_all_learners, create_blocks_params, discussion_open_for_user, + get_captcha_site_key_by_platform, get_usernames_for_course, get_usernames_from_search_string, - set_attribute, + is_captcha_enabled, is_posting_allowed, - can_user_notify_all_learners, is_captcha_enabled, get_captcha_site_key_by_platform + set_attribute, ) +log = logging.getLogger(__name__) User = get_user_model() ThreadType = Literal["discussion", "question"] @@ -166,11 +181,14 @@ class DiscussionEntity(Enum): """ Enum for different types of discussion related entities """ - thread = 'thread' - comment = 'comment' + + thread = "thread" + comment = "comment" -def _get_course(course_key: CourseKey, user: User, check_tab: bool = True) -> CourseBlock: +def _get_course( + course_key: CourseKey, user: User, check_tab: bool = True +) -> CourseBlock: """ Get the course block, raising CourseNotFoundError if the course is not found or the user cannot access forums for the course, and DiscussionDisabledError if the @@ -188,14 +206,16 @@ def _get_course(course_key: CourseKey, user: User, check_tab: bool = True) -> Co CourseBlock: course object """ try: - course = get_course_with_access(user, 'load', course_key, check_if_enrolled=True) + course = get_course_with_access( + user, "load", course_key, check_if_enrolled=True + ) except (Http404, CourseAccessRedirect) as err: # Convert 404s into CourseNotFoundErrors. # Raise course not found if the user cannot access the course raise CourseNotFoundError("Course not found.") from err if check_tab: - discussion_tab = CourseTabList.get_tab_by_type(course.tabs, 'discussion') + discussion_tab = CourseTabList.get_tab_by_type(course.tabs, "discussion") if not (discussion_tab and discussion_tab.is_enabled(course, user)): raise DiscussionDisabledError("Discussion is disabled for the course.") @@ -216,22 +236,34 @@ def _get_thread_and_context(request, thread_id, retrieve_kwargs=None, course_id= retrieve_kwargs["with_responses"] = False if "mark_as_read" not in retrieve_kwargs: retrieve_kwargs["mark_as_read"] = False - cc_thread = Thread(id=thread_id).retrieve(course_id=course_id, **retrieve_kwargs) + cc_thread = Thread(id=thread_id).retrieve( + course_id=course_id, **retrieve_kwargs + ) course_key = CourseKey.from_string(cc_thread["course_id"]) course = _get_course(course_key, request.user) context = get_context(course, request, cc_thread) - if retrieve_kwargs.get("flagged_comments") and not context["has_moderation_privilege"]: + if ( + retrieve_kwargs.get("flagged_comments") + and not context["has_moderation_privilege"] + ): raise ValidationError("Only privileged users can request flagged comments") course_discussion_settings = CourseDiscussionSettings.get(course_key) if ( - not context["has_moderation_privilege"] and - cc_thread["group_id"] and - is_commentable_divided(course.id, cc_thread["commentable_id"], course_discussion_settings) + not context["has_moderation_privilege"] + and cc_thread["group_id"] + and is_commentable_divided( + course.id, cc_thread["commentable_id"], course_discussion_settings + ) ): - requester_group_id = get_group_id_for_user(request.user, course_discussion_settings) - if requester_group_id is not None and cc_thread["group_id"] != requester_group_id: + requester_group_id = get_group_id_for_user( + request.user, course_discussion_settings + ) + if ( + requester_group_id is not None + and cc_thread["group_id"] != requester_group_id + ): raise ThreadNotFoundError("Thread not found.") return cc_thread, context except CommentClientRequestError as err: @@ -264,8 +296,8 @@ def _is_user_author_or_privileged(cc_content, context): Boolean """ return ( - context["has_moderation_privilege"] or - context["cc_requester"]["id"] == cc_content["user_id"] + context["has_moderation_privilege"] + or context["cc_requester"]["id"] == cc_content["user_id"] ) @@ -275,11 +307,13 @@ def get_thread_list_url(request, course_key, topic_id_list=None, following=False """ path = reverse("thread-list") query_list = ( - [("course_id", str(course_key))] + - [("topic_id", topic_id) for topic_id in topic_id_list or []] + - ([("following", following)] if following else []) + [("course_id", str(course_key))] + + [("topic_id", topic_id) for topic_id in topic_id_list or []] + + ([("following", following)] if following else []) + ) + return request.build_absolute_uri( + urlunparse(("", "", path, "", urlencode(query_list), "")) ) - return request.build_absolute_uri(urlunparse(("", "", path, "", urlencode(query_list), ""))) def get_course(request, course_key, check_tab=True): @@ -324,18 +358,19 @@ def _format_datetime(dt): the substitution... though really, that would probably break mobile client parsing of the dates as well. :-P """ - return dt.isoformat().replace('+00:00', 'Z') + return dt.isoformat().replace("+00:00", "Z") course = _get_course(course_key, request.user, check_tab=check_tab) user_roles = get_user_role_names(request.user, course_key) course_config = DiscussionsConfiguration.get(course_key) EDIT_REASON_CODES = getattr(settings, "DISCUSSION_MODERATION_EDIT_REASON_CODES", {}) - CLOSE_REASON_CODES = getattr(settings, "DISCUSSION_MODERATION_CLOSE_REASON_CODES", {}) + CLOSE_REASON_CODES = getattr( + settings, "DISCUSSION_MODERATION_CLOSE_REASON_CODES", {} + ) is_posting_enabled = is_posting_allowed( - course_config.posting_restrictions, - course.get_discussion_blackout_datetimes() + course_config.posting_restrictions, course.get_discussion_blackout_datetimes() ) - discussion_tab = CourseTabList.get_tab_by_type(course.tabs, 'discussion') + discussion_tab = CourseTabList.get_tab_by_type(course.tabs, "discussion") is_course_staff = CourseStaffRole(course_key).has_user(request.user) is_course_admin = CourseInstructorRole(course_key).has_user(request.user) return { @@ -349,7 +384,9 @@ def _format_datetime(dt): for blackout in course.get_discussion_blackout_datetimes() ], "thread_list_url": get_thread_list_url(request, course_key), - "following_thread_list_url": get_thread_list_url(request, course_key, following=True), + "following_thread_list_url": get_thread_list_url( + request, course_key, following=True + ), "topics_url": request.build_absolute_uri( reverse("course_topics", kwargs={"course_id": course_key}) ), @@ -357,18 +394,23 @@ def _format_datetime(dt): "allow_anonymous_to_peers": course.allow_anonymous_to_peers, "user_roles": user_roles, "has_bulk_delete_privileges": can_take_action_on_spam(request.user, course_key), - "has_moderation_privileges": bool(user_roles & { - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - }), + "has_moderation_privileges": bool( + user_roles + & { + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + } + ), "is_group_ta": bool(user_roles & {FORUM_ROLE_GROUP_MODERATOR}), "is_user_admin": request.user.is_staff, "is_course_staff": is_course_staff, "is_course_admin": is_course_admin, "provider": course_config.provider_type, "enable_in_context": course_config.enable_in_context, - "group_at_subsection": course_config.plugin_configuration.get("group_at_subsection", False), + "group_at_subsection": course_config.plugin_configuration.get( + "group_at_subsection", False + ), "edit_reasons": [ {"code": reason_code, "label": label} for (reason_code, label) in EDIT_REASON_CODES.items() @@ -377,17 +419,23 @@ def _format_datetime(dt): {"code": reason_code, "label": label} for (reason_code, label) in CLOSE_REASON_CODES.items() ], - 'show_discussions': bool(discussion_tab and discussion_tab.is_enabled(course, request.user)), - 'is_notify_all_learners_enabled': can_user_notify_all_learners( + "show_discussions": bool( + discussion_tab and discussion_tab.is_enabled(course, request.user) + ), + "is_notify_all_learners_enabled": can_user_notify_all_learners( user_roles, is_course_staff, is_course_admin ), - 'captcha_settings': { - 'enabled': is_captcha_enabled(course_key), - 'site_key': get_captcha_site_key_by_platform('web'), + "captcha_settings": { + "enabled": is_captcha_enabled(course_key), + "site_key": get_captcha_site_key_by_platform("web"), }, "is_email_verified": request.user.is_active, - "only_verified_users_can_post": ONLY_VERIFIED_USERS_CAN_POST.is_enabled(course_key), - "content_creation_rate_limited": is_content_creation_rate_limited(request, course_key, increment=False), + "only_verified_users_can_post": ONLY_VERIFIED_USERS_CAN_POST.is_enabled( + course_key + ), + "content_creation_rate_limited": is_content_creation_rate_limited( + request, course_key, increment=False + ), } @@ -440,7 +488,7 @@ def convert(text): return text def alphanum_key(key): - return [convert(c) for c in re.split('([0-9]+)', key)] + return [convert(c) for c in re.split("([0-9]+)", key)] return sorted(category_list, key=alphanum_key) @@ -482,7 +530,7 @@ def get_non_courseware_topics( course_key: CourseKey, course: CourseBlock, topic_ids: Optional[List[str]], - thread_counts: Dict[str, Dict[str, int]] + thread_counts: Dict[str, Dict[str, int]], ) -> Tuple[List[Dict], Set[str]]: """ Returns a list of topic trees that are not linked to courseware. @@ -506,13 +554,17 @@ def get_non_courseware_topics( existing_topic_ids = set() topics = list(course.discussion_topics.items()) for name, entry in topics: - if not topic_ids or entry['id'] in topic_ids: + if not topic_ids or entry["id"] in topic_ids: discussion_topic = DiscussionTopic( - entry["id"], name, get_thread_list_url(request, course_key, [entry["id"]]), + entry["id"], + name, + get_thread_list_url(request, course_key, [entry["id"]]), None, - thread_counts.get(entry["id"]) + thread_counts.get(entry["id"]), + ) + non_courseware_topics.append( + DiscussionTopicSerializer(discussion_topic).data ) - non_courseware_topics.append(DiscussionTopicSerializer(discussion_topic).data) if topic_ids and entry["id"] in topic_ids: existing_topic_ids.add(entry["id"]) @@ -520,7 +572,9 @@ def get_non_courseware_topics( return non_courseware_topics, existing_topic_ids -def get_course_topics(request: Request, course_key: CourseKey, topic_ids: Optional[Set[str]] = None): +def get_course_topics( + request: Request, course_key: CourseKey, topic_ids: Optional[Set[str]] = None +): """ Returns the course topic listing for the given course and user; filtered by 'topic_ids' list if given. @@ -544,15 +598,25 @@ def get_course_topics(request: Request, course_key: CourseKey, topic_ids: Option courseware_topics, existing_courseware_topic_ids = get_courseware_topics( request, course_key, course, topic_ids, thread_counts ) - non_courseware_topics, existing_non_courseware_topic_ids = get_non_courseware_topics( - request, course_key, course, topic_ids, thread_counts, + non_courseware_topics, existing_non_courseware_topic_ids = ( + get_non_courseware_topics( + request, + course_key, + course, + topic_ids, + thread_counts, + ) ) if topic_ids: - not_found_topic_ids = topic_ids - (existing_courseware_topic_ids | existing_non_courseware_topic_ids) + not_found_topic_ids = topic_ids - ( + existing_courseware_topic_ids | existing_non_courseware_topic_ids + ) if not_found_topic_ids: raise DiscussionNotFoundError( - "Discussion not found for '{}'.".format(", ".join(str(id) for id in not_found_topic_ids)) + "Discussion not found for '{}'.".format( + ", ".join(str(id) for id in not_found_topic_ids) + ) ) return { @@ -567,17 +631,19 @@ def get_v2_non_courseware_topics_as_v1(request, course_key, topics): """ non_courseware_topics = [] for topic in topics: - if topic.get('usage_key', '') is None: - for key in ['usage_key', 'enabled_in_context']: + if topic.get("usage_key", "") is None: + for key in ["usage_key", "enabled_in_context"]: topic.pop(key) - topic.update({ - 'children': [], - 'thread_list_url': get_thread_list_url( - request, - course_key, - topic.get('id'), - ) - }) + topic.update( + { + "children": [], + "thread_list_url": get_thread_list_url( + request, + course_key, + topic.get("id"), + ), + } + ) non_courseware_topics.append(topic) return non_courseware_topics @@ -589,23 +655,25 @@ def get_v2_courseware_topics_as_v1(request, course_key, sequentials, topics): courseware_topics = [] for sequential in sequentials: children = [] - for child in sequential.get('children', []): + for child in sequential.get("children", []): for topic in topics: - if child == topic.get('usage_key'): - topic.update({ - 'children': [], - 'thread_list_url': get_thread_list_url( - request, - course_key, - [topic.get('id')], - ) - }) - topic.pop('enabled_in_context') + if child == topic.get("usage_key"): + topic.update( + { + "children": [], + "thread_list_url": get_thread_list_url( + request, + course_key, + [topic.get("id")], + ), + } + ) + topic.pop("enabled_in_context") children.append(AttributeDict(topic)) discussion_topic = DiscussionTopic( None, - sequential.get('display_name'), + sequential.get("display_name"), get_thread_list_url( request, course_key, @@ -618,7 +686,7 @@ def get_v2_courseware_topics_as_v1(request, course_key, sequentials, topics): courseware_topics = [ courseware_topic for courseware_topic in courseware_topics - if courseware_topic.get('children', []) + if courseware_topic.get("children", []) ] return courseware_topics @@ -635,20 +703,21 @@ def get_v2_course_topics_as_v1( blocks_params = create_blocks_params(course_usage_key, request.user) blocks = get_blocks( request, - blocks_params['usage_key'], - blocks_params['user'], - blocks_params['depth'], - blocks_params['nav_depth'], - blocks_params['requested_fields'], - blocks_params['block_counts'], - blocks_params['student_view_data'], - blocks_params['return_type'], - blocks_params['block_types_filter'], + blocks_params["usage_key"], + blocks_params["user"], + blocks_params["depth"], + blocks_params["nav_depth"], + blocks_params["requested_fields"], + blocks_params["block_counts"], + blocks_params["student_view_data"], + blocks_params["return_type"], + blocks_params["block_types_filter"], hide_access_denials=False, - )['blocks'] + )["blocks"] - sequentials = [value for _, value in blocks.items() - if value.get('type') == "sequential"] + sequentials = [ + value for _, value in blocks.items() if value.get("type") == "sequential" + ] topics = get_course_topics_v2(course_key, request.user, topic_ids) non_courseware_topics = get_v2_non_courseware_topics_as_v1( @@ -705,24 +774,29 @@ def get_course_topics_v2( # Check access to the course store = modulestore() _get_course(course_key, user=user, check_tab=False) - user_is_privileged = user.is_staff or user.roles.filter( - course_id=course_key, - name__in=[ - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_ADMINISTRATOR, - ] - ).exists() + user_is_privileged = ( + user.is_staff + or user.roles.filter( + course_id=course_key, + name__in=[ + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_ADMINISTRATOR, + ], + ).exists() + ) with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course_key): blocks = store.get_items( course_key, - qualifiers={'category': 'vertical'}, - fields=['usage_key', 'discussion_enabled', 'display_name'], + qualifiers={"category": "vertical"}, + fields=["usage_key", "discussion_enabled", "display_name"], ) accessible_vertical_keys = [] for block in blocks: - if block.discussion_enabled and (not block.visible_to_staff_only or user_is_privileged): + if block.discussion_enabled and ( + not block.visible_to_staff_only or user_is_privileged + ): accessible_vertical_keys.append(block.usage_key) accessible_vertical_keys.append(None) @@ -732,9 +806,13 @@ def get_course_topics_v2( ) if user_is_privileged: - topics_query = topics_query.filter(Q(usage_key__in=accessible_vertical_keys) | Q(enabled_in_context=False)) + topics_query = topics_query.filter( + Q(usage_key__in=accessible_vertical_keys) | Q(enabled_in_context=False) + ) else: - topics_query = topics_query.filter(usage_key__in=accessible_vertical_keys, enabled_in_context=True) + topics_query = topics_query.filter( + usage_key__in=accessible_vertical_keys, enabled_in_context=True + ) if topic_ids: topics_query = topics_query.filter(external_id__in=topic_ids) @@ -746,11 +824,13 @@ def get_course_topics_v2( reverse=True, ) elif order_by == TopicOrdering.NAME: - topics_query = topics_query.order_by('title') + topics_query = topics_query.order_by("title") else: - topics_query = topics_query.order_by('ordering') + topics_query = topics_query.order_by("ordering") - topics_data = DiscussionTopicSerializerV2(topics_query, many=True, context={"thread_counts": thread_counts}).data + topics_data = DiscussionTopicSerializerV2( + topics_query, many=True, context={"thread_counts": thread_counts} + ).data return [ topic_data for topic_data in topics_data @@ -777,7 +857,7 @@ def _get_user_profile_dict(request, usernames): else: username_list = [] user_profile_details = get_account_settings(request, username_list) - return {user['username']: user for user in user_profile_details} + return {user["username"]: user for user in user_profile_details} def _user_profile(user_profile): @@ -785,11 +865,7 @@ def _user_profile(user_profile): Returns the user profile object. For now, this just comprises the profile_image details. """ - return { - 'profile': { - 'image': user_profile['profile_image'] - } - } + return {"profile": {"image": user_profile["profile_image"]}} def _get_users(discussion_entity_type, discussion_entity, username_profile_dict): @@ -807,22 +883,28 @@ def _get_users(discussion_entity_type, discussion_entity, username_profile_dict) A dict of users with username as key and user profile details as value. """ users = {} - if discussion_entity['author']: - user_profile = username_profile_dict.get(discussion_entity['author']) + if discussion_entity["author"]: + user_profile = username_profile_dict.get(discussion_entity["author"]) if user_profile: - users[discussion_entity['author']] = _user_profile(user_profile) + users[discussion_entity["author"]] = _user_profile(user_profile) if ( discussion_entity_type == DiscussionEntity.comment - and discussion_entity['endorsed'] - and discussion_entity['endorsed_by'] + and discussion_entity["endorsed"] + and discussion_entity["endorsed_by"] ): - users[discussion_entity['endorsed_by']] = _user_profile(username_profile_dict[discussion_entity['endorsed_by']]) + users[discussion_entity["endorsed_by"]] = _user_profile( + username_profile_dict[discussion_entity["endorsed_by"]] + ) return users def _add_additional_response_fields( - request, serialized_discussion_entities, usernames, discussion_entity_type, include_profile_image + request, + serialized_discussion_entities, + usernames, + discussion_entity_type, + include_profile_image, ): """ Adds additional data to serialized discussion thread/comment. @@ -840,9 +922,13 @@ def _add_additional_response_fields( A list of serialized discussion thread/comment with additional data if requested. """ if include_profile_image: - username_profile_dict = _get_user_profile_dict(request, usernames=','.join(usernames)) + username_profile_dict = _get_user_profile_dict( + request, usernames=",".join(usernames) + ) for discussion_entity in serialized_discussion_entities: - discussion_entity['users'] = _get_users(discussion_entity_type, discussion_entity, username_profile_dict) + discussion_entity["users"] = _get_users( + discussion_entity_type, discussion_entity, username_profile_dict + ) return serialized_discussion_entities @@ -851,10 +937,12 @@ def _include_profile_image(requested_fields): """ Returns True if requested_fields list has 'profile_image' entity else False """ - return requested_fields and 'profile_image' in requested_fields + return requested_fields and "profile_image" in requested_fields -def _serialize_discussion_entities(request, context, discussion_entities, requested_fields, discussion_entity_type): +def _serialize_discussion_entities( + request, context, discussion_entities, requested_fields, discussion_entity_type +): """ It serializes Discussion Entity (Thread or Comment) and add additional data if requested. @@ -885,14 +973,19 @@ def _serialize_discussion_entities(request, context, discussion_entities, reques results.append(serialized_entity) if include_profile_image: - if serialized_entity['author'] and serialized_entity['author'] not in usernames: - usernames.append(serialized_entity['author']) if ( - 'endorsed' in serialized_entity and serialized_entity['endorsed'] and - 'endorsed_by' in serialized_entity and - serialized_entity['endorsed_by'] and serialized_entity['endorsed_by'] not in usernames + serialized_entity["author"] + and serialized_entity["author"] not in usernames ): - usernames.append(serialized_entity['endorsed_by']) + usernames.append(serialized_entity["author"]) + if ( + "endorsed" in serialized_entity + and serialized_entity["endorsed"] + and "endorsed_by" in serialized_entity + and serialized_entity["endorsed_by"] + and serialized_entity["endorsed_by"] not in usernames + ): + usernames.append(serialized_entity["endorsed_by"]) results = _add_additional_response_fields( request, results, usernames, discussion_entity_type, include_profile_image @@ -916,6 +1009,7 @@ def get_thread_list( order_direction: Literal["desc"] = "desc", requested_fields: Optional[List[Literal["profile_image"]]] = None, count_flagged: bool = None, + show_deleted: bool = False, ): """ Return the list of all discussion threads pertaining to the given course @@ -959,20 +1053,31 @@ def get_thread_list( CourseNotFoundError: if the requesting user does not have access to the requested course PageNotFoundError: if page requested is beyond the last """ - exclusive_param_count = sum(1 for param in [topic_id_list, text_search, following] if param) + exclusive_param_count = sum( + 1 for param in [topic_id_list, text_search, following] if param + ) if exclusive_param_count > 1: # pragma: no cover - raise ValueError("More than one mutually exclusive param passed to get_thread_list") + raise ValueError( + "More than one mutually exclusive param passed to get_thread_list" + ) - cc_map = {"last_activity_at": "activity", "comment_count": "comments", "vote_count": "votes"} + cc_map = { + "last_activity_at": "activity", + "comment_count": "comments", + "vote_count": "votes", + } if order_by not in cc_map: - raise ValidationError({ - "order_by": - [f"Invalid value. '{order_by}' must be 'last_activity_at', 'comment_count', or 'vote_count'"] - }) + raise ValidationError( + { + "order_by": [ + f"Invalid value. '{order_by}' must be 'last_activity_at', 'comment_count', or 'vote_count'" + ] + } + ) if order_direction != "desc": - raise ValidationError({ - "order_direction": [f"Invalid value. '{order_direction}' must be 'desc'"] - }) + raise ValidationError( + {"order_direction": [f"Invalid value. '{order_direction}' must be 'desc'"]} + ) course = _get_course(course_key, request.user) context = get_context(course, request) @@ -984,13 +1089,21 @@ def get_thread_list( except User.DoesNotExist: # Raising an error for a missing user leaks the presence of a username, # so just return an empty response. - return DiscussionAPIPagination(request, 0, 1).get_paginated_response({ - "results": [], - "text_search_rewrite": None, - }) + return DiscussionAPIPagination(request, 0, 1).get_paginated_response( + { + "results": [], + "text_search_rewrite": None, + } + ) if count_flagged and not context["has_moderation_privilege"]: - raise PermissionDenied("`count_flagged` can only be set by users with moderator access or higher.") + raise PermissionDenied( + "`count_flagged` can only be set by users with moderator access or higher." + ) + if show_deleted and not context["has_moderation_privilege"]: + raise PermissionDenied( + "`show_deleted` can only be set by users with moderator access or higher." + ) group_id = None allowed_roles = [ @@ -1010,7 +1123,9 @@ def get_thread_list( not context["has_moderation_privilege"] or request.user.id in context["ta_user_ids"] ): - group_id = get_group_id_for_user(request.user, CourseDiscussionSettings.get(course.id)) + group_id = get_group_id_for_user( + request.user, CourseDiscussionSettings.get(course.id) + ) query_params = { "user_id": str(request.user.id), @@ -1023,21 +1138,24 @@ def get_thread_list( "flagged": flagged, "thread_type": thread_type, "count_flagged": count_flagged, + "show_deleted": show_deleted, } if view: if view in ["unread", "unanswered", "unresponded"]: query_params[view] = "true" else: - raise ValidationError({ - "view": [f"Invalid value. '{view}' must be 'unread' or 'unanswered'"] - }) + raise ValidationError( + {"view": [f"Invalid value. '{view}' must be 'unread' or 'unanswered'"]} + ) if following: paginated_results = context["cc_requester"].subscribed_threads(query_params) else: query_params["course_id"] = str(course.id) - query_params["commentable_ids"] = ",".join(topic_id_list) if topic_id_list else None + query_params["commentable_ids"] = ( + ",".join(topic_id_list) if topic_id_list else None + ) query_params["text"] = text_search paginated_results = Thread.search(query_params) # The comments service returns the last page of results if the requested @@ -1047,19 +1165,25 @@ def get_thread_list( raise PageNotFoundError("Page not found (No results on this page).") results = _serialize_discussion_entities( - request, context, paginated_results.collection, requested_fields, DiscussionEntity.thread + request, + context, + paginated_results.collection, + requested_fields, + DiscussionEntity.thread, ) paginator = DiscussionAPIPagination( request, paginated_results.page, paginated_results.num_pages, - paginated_results.thread_count + paginated_results.thread_count, + ) + return paginator.get_paginated_response( + { + "results": results, + "text_search_rewrite": paginated_results.corrected_text, + } ) - return paginator.get_paginated_response({ - "results": results, - "text_search_rewrite": paginated_results.corrected_text, - }) def get_learner_active_thread_list(request, course_key, query_params): @@ -1154,49 +1278,101 @@ def get_learner_active_thread_list(request, course_key, query_params): course = _get_course(course_key, request.user) context = get_context(course, request) - group_id = query_params.get('group_id', None) - user_id = query_params.get('user_id', None) - count_flagged = query_params.get('count_flagged', None) + group_id = query_params.get("group_id", None) + user_id = query_params.get("user_id", None) + count_flagged = query_params.get("count_flagged", None) + show_deleted = query_params.get("show_deleted", False) + if isinstance(show_deleted, str): + show_deleted = show_deleted.lower() == "true" + if user_id is None: - return Response({'detail': 'Invalid user id'}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"detail": "Invalid user id"}, status=status.HTTP_400_BAD_REQUEST + ) if count_flagged and not context["has_moderation_privilege"]: - raise PermissionDenied("count_flagged can only be set by users with moderation roles.") + raise PermissionDenied( + "count_flagged can only be set by users with moderation roles." + ) if "flagged" in query_params.keys() and not context["has_moderation_privilege"]: raise PermissionDenied("Flagged filter is only available for moderators") + if show_deleted and not context["has_moderation_privilege"]: + raise PermissionDenied( + "show_deleted can only be set by users with moderation roles." + ) if group_id is None: comment_client_user = comment_client.User(id=user_id, course_id=course_key) else: - comment_client_user = comment_client.User(id=user_id, course_id=course_key, group_id=group_id) + comment_client_user = comment_client.User( + id=user_id, course_id=course_key, group_id=group_id + ) try: threads, page, num_pages = comment_client_user.active_threads(query_params) threads = set_attribute(threads, "pinned", False) + + # This portion below is temporary until we migrate to forum v2 + filtered_threads = [] + for thread in threads: + try: + forum_thread = forum_api.get_thread( + thread.get("id"), course_id=str(course_key) + ) + is_deleted = forum_thread.get("is_deleted", False) + + if show_deleted and is_deleted: + thread["is_deleted"] = True + thread["deleted_at"] = forum_thread.get("deleted_at") + thread["deleted_by"] = forum_thread.get("deleted_by") + filtered_threads.append(thread) + elif not show_deleted and not is_deleted: + filtered_threads.append(thread) + except Exception as e: # pylint: disable=broad-exception-caught + log.warning( + "Failed to check thread %s deletion status: %s", thread.get("id"), e + ) + if not show_deleted: # Fail safe: include thread for regular users + filtered_threads.append(thread) + results = _serialize_discussion_entities( - request, context, threads, {'profile_image'}, DiscussionEntity.thread + request, + context, + filtered_threads, + {"profile_image"}, + DiscussionEntity.thread, ) paginator = DiscussionAPIPagination( - request, - page, - num_pages, - len(threads) + request, page, num_pages, len(filtered_threads) + ) + return paginator.get_paginated_response( + { + "results": results, + } ) - return paginator.get_paginated_response({ - "results": results, - }) except CommentClient500Error: return DiscussionAPIPagination( request, page_num=1, num_pages=0, - ).get_paginated_response({ - "results": [], - }) + ).get_paginated_response( + { + "results": [], + } + ) -def get_comment_list(request, thread_id, endorsed, page, page_size, flagged=False, requested_fields=None, - merge_question_type_responses=False): +def get_comment_list( + request, + thread_id, + endorsed, + page, + page_size, + flagged=False, + requested_fields=None, + merge_question_type_responses=False, + show_deleted=False, +): """ Return the list of comments in the given thread. @@ -1226,7 +1402,7 @@ def get_comment_list(request, thread_id, endorsed, page, page_size, flagged=Fals discussion.rest_api.views.CommentViewSet for more detail. """ response_skip = page_size * (page - 1) - reverse_order = request.GET.get('reverse_order', False) + reverse_order = request.GET.get("reverse_order", False) from_mfe_sidebar = request.GET.get("enable_in_context_sidebar", False) cc_thread, context = _get_thread_and_context( request, @@ -1239,19 +1415,23 @@ def get_comment_list(request, thread_id, endorsed, page, page_size, flagged=Fals "response_skip": response_skip, "response_limit": page_size, "reverse_order": reverse_order, - "merge_question_type_responses": merge_question_type_responses - } + "merge_question_type_responses": merge_question_type_responses, + }, ) # Responses to discussion threads cannot be separated by endorsed, but # responses to question threads must be separated by endorsed due to the # existing comments service interface if cc_thread["thread_type"] == "question" and not merge_question_type_responses: if endorsed is None: # lint-amnesty, pylint: disable=no-else-raise - raise ValidationError({"endorsed": ["This field is required for question threads."]}) + raise ValidationError( + {"endorsed": ["This field is required for question threads."]} + ) elif endorsed: # CS does not apply resp_skip and resp_limit to endorsed responses # of a question post - responses = cc_thread["endorsed_responses"][response_skip:(response_skip + page_size)] + responses = cc_thread["endorsed_responses"][ + response_skip: (response_skip + page_size) + ] resp_total = len(cc_thread["endorsed_responses"]) else: responses = cc_thread["non_endorsed_responses"] @@ -1260,7 +1440,11 @@ def get_comment_list(request, thread_id, endorsed, page, page_size, flagged=Fals if not merge_question_type_responses: if endorsed is not None: raise ValidationError( - {"endorsed": ["This field may not be specified for discussion threads."]} + { + "endorsed": [ + "This field may not be specified for discussion threads." + ] + } ) responses = cc_thread["children"] resp_total = cc_thread["resp_total"] @@ -1272,9 +1456,21 @@ def get_comment_list(request, thread_id, endorsed, page, page_size, flagged=Fals raise PageNotFoundError("Page not found (No results on this page).") num_pages = (resp_total + page_size - 1) // page_size if resp_total else 1 - results = _serialize_discussion_entities(request, context, responses, requested_fields, DiscussionEntity.comment) + if not show_deleted: + responses = [ + response for response in responses if not response.get("is_deleted", False) + ] + else: + if not context["has_moderation_privilege"]: + raise PermissionDenied( + "`show_deleted` can only be set by users with moderation roles." + ) + + results = _serialize_discussion_entities( + request, context, responses, requested_fields, DiscussionEntity.comment + ) - paginator = DiscussionAPIPagination(request, page, num_pages, resp_total) + paginator = DiscussionAPIPagination(request, page, num_pages, len(responses)) track_thread_viewed_event(request, context["course"], cc_thread, from_mfe_sidebar) return paginator.get_paginated_response(results) @@ -1292,7 +1488,9 @@ def _check_fields(allowed_fields, data, message): ValidationError if the given data contains a key that is not in allowed_fields """ - non_allowed_fields = {field: [message] for field in data.keys() if field not in allowed_fields} + non_allowed_fields = { + field: [message] for field in data.keys() if field not in allowed_fields + } if non_allowed_fields: raise ValidationError(non_allowed_fields) @@ -1314,7 +1512,7 @@ def _check_initializable_thread_fields(data, context): _check_fields( get_initializable_thread_fields(context), data, - "This field is not initializable." + "This field is not initializable.", ) @@ -1335,7 +1533,7 @@ def _check_initializable_comment_fields(data, context): _check_fields( get_initializable_comment_fields(context), data, - "This field is not initializable." + "This field is not initializable.", ) @@ -1345,28 +1543,40 @@ def _check_editable_fields(cc_content, data, context): editable by the requesting user """ _check_fields( - get_editable_fields(cc_content, context), - data, - "This field is not editable." + get_editable_fields(cc_content, context), data, "This field is not editable." ) -def _do_extra_actions(api_content, cc_content, request_fields, actions_form, context, request): +def _do_extra_actions( + api_content, cc_content, request_fields, actions_form, context, request +): """ Perform any necessary additional actions related to content creation or update that require a separate comments service request. """ for field, form_value in actions_form.cleaned_data.items(): - if field in request_fields and field in api_content and form_value != api_content[field]: + if ( + field in request_fields + and field in api_content + and form_value != api_content[field] + ): api_content[field] = form_value if field == "following": - _handle_following_field(form_value, context["cc_requester"], cc_content, request) + _handle_following_field( + form_value, context["cc_requester"], cc_content, request + ) elif field == "abuse_flagged": - _handle_abuse_flagged_field(form_value, context["cc_requester"], cc_content, request) + _handle_abuse_flagged_field( + form_value, context["cc_requester"], cc_content, request + ) elif field == "voted": - _handle_voted_field(form_value, cc_content, api_content, request, context) + _handle_voted_field( + form_value, cc_content, api_content, request, context + ) elif field == "read": - _handle_read_field(api_content, form_value, context["cc_requester"], cc_content) + _handle_read_field( + api_content, form_value, context["cc_requester"], cc_content + ) elif field == "pinned": _handle_pinned_field(form_value, cc_content, context["cc_requester"]) else: @@ -1376,7 +1586,7 @@ def _do_extra_actions(api_content, cc_content, request_fields, actions_form, con def _handle_following_field(form_value, user, cc_content, request): """follow/unfollow thread for the user""" course_key = CourseKey.from_string(cc_content.course_id) - course = get_course_with_access(request.user, 'load', course_key) + course = get_course_with_access(request.user, "load", course_key) if form_value: user.follow(cc_content) else: @@ -1389,15 +1599,19 @@ def _handle_following_field(form_value, user, cc_content, request): def _handle_abuse_flagged_field(form_value, user, cc_content, request): """mark or unmark thread/comment as abused""" course_key = CourseKey.from_string(cc_content.course_id) - course = get_course_with_access(request.user, 'load', course_key) + course = get_course_with_access(request.user, "load", course_key) if form_value: cc_content.flagAbuse(user, cc_content) track_discussion_reported_event(request, course, cc_content) if ENABLE_DISCUSSIONS_MFE.is_enabled(course_key): - if cc_content.type == 'thread': - thread_flagged.send(sender='flag_abuse_for_thread', user=user, post=cc_content) + if cc_content.type == "thread": + thread_flagged.send( + sender="flag_abuse_for_thread", user=user, post=cc_content + ) else: - comment_flagged.send(sender='flag_abuse_for_comment', user=user, post=cc_content) + comment_flagged.send( + sender="flag_abuse_for_comment", user=user, post=cc_content + ) else: remove_all = bool(is_privileged_user(course_key, User.objects.get(id=user.id))) cc_content.unFlagAbuse(user, cc_content, remove_all) @@ -1406,7 +1620,7 @@ def _handle_abuse_flagged_field(form_value, user, cc_content, request): def _handle_voted_field(form_value, cc_content, api_content, request, context): """vote or undo vote on thread/comment""" - signal = thread_voted if cc_content.type == 'thread' else comment_voted + signal = thread_voted if cc_content.type == "thread" else comment_voted signal.send(sender=None, user=context["request"].user, post=cc_content) if form_value: context["cc_requester"].vote(cc_content, "up") @@ -1415,7 +1629,11 @@ def _handle_voted_field(form_value, cc_content, api_content, request, context): context["cc_requester"].unvote(cc_content) api_content["vote_count"] -= 1 track_voted_event( - request, context["course"], cc_content, vote_value="up", undo_vote=not form_value + request, + context["course"], + cc_content, + vote_value="up", + undo_vote=not form_value, ) @@ -1423,7 +1641,7 @@ def _handle_read_field(api_content, form_value, user, cc_content): """ Marks thread as read for the user """ - if form_value and not cc_content['read']: + if form_value and not cc_content["read"]: user.read(cc_content) # When a thread is marked as read, all of its responses and comments # are also marked as read. @@ -1490,24 +1708,35 @@ def create_thread(request, thread_data): context = get_context(course, request) _check_initializable_thread_fields(thread_data, context) discussion_settings = CourseDiscussionSettings.get(course_key) - if ( - "group_id" not in thread_data and - is_commentable_divided(course_key, thread_data.get("topic_id"), discussion_settings) + if "group_id" not in thread_data and is_commentable_divided( + course_key, thread_data.get("topic_id"), discussion_settings ): thread_data = thread_data.copy() thread_data["group_id"] = get_group_id_for_user(user, discussion_settings) serializer = ThreadSerializer(data=thread_data, context=context) actions_form = ThreadActionsForm(thread_data) if not (serializer.is_valid() and actions_form.is_valid()): - raise ValidationError(dict(list(serializer.errors.items()) + list(actions_form.errors.items()))) + raise ValidationError( + dict(list(serializer.errors.items()) + list(actions_form.errors.items())) + ) serializer.save() cc_thread = serializer.instance - thread_created.send(sender=None, user=user, post=cc_thread, notify_all_learners=notify_all_learners) + thread_created.send( + sender=None, user=user, post=cc_thread, notify_all_learners=notify_all_learners + ) api_thread = serializer.data - _do_extra_actions(api_thread, cc_thread, list(thread_data.keys()), actions_form, context, request) + _do_extra_actions( + api_thread, cc_thread, list(thread_data.keys()), actions_form, context, request + ) - track_thread_created_event(request, course, cc_thread, actions_form.cleaned_data["following"], - from_mfe_sidebar, notify_all_learners) + track_thread_created_event( + request, + course, + cc_thread, + actions_form.cleaned_data["following"], + from_mfe_sidebar, + notify_all_learners, + ) return api_thread @@ -1546,15 +1775,30 @@ def create_comment(request, comment_data): serializer = CommentSerializer(data=comment_data, context=context) actions_form = CommentActionsForm(comment_data) if not (serializer.is_valid() and actions_form.is_valid()): - raise ValidationError(dict(list(serializer.errors.items()) + list(actions_form.errors.items()))) + raise ValidationError( + dict(list(serializer.errors.items()) + list(actions_form.errors.items())) + ) context["cc_requester"].follow(cc_thread) serializer.save() cc_comment = serializer.instance comment_created.send(sender=None, user=request.user, post=cc_comment) api_comment = serializer.data - _do_extra_actions(api_comment, cc_comment, list(comment_data.keys()), actions_form, context, request) - track_comment_created_event(request, course, cc_comment, cc_thread["commentable_id"], followed=False, - from_mfe_sidebar=from_mfe_sidebar) + _do_extra_actions( + api_comment, + cc_comment, + list(comment_data.keys()), + actions_form, + context, + request, + ) + track_comment_created_event( + request, + course, + cc_comment, + cc_thread["commentable_id"], + followed=False, + from_mfe_sidebar=from_mfe_sidebar, + ) return api_comment @@ -1576,24 +1820,32 @@ def update_thread(request, thread_id, update_data): The updated thread; see discussion.rest_api.views.ThreadViewSet for more detail. """ - cc_thread, context = _get_thread_and_context(request, thread_id, retrieve_kwargs={"with_responses": True}) + cc_thread, context = _get_thread_and_context( + request, thread_id, retrieve_kwargs={"with_responses": True} + ) _check_editable_fields(cc_thread, update_data, context) - serializer = ThreadSerializer(cc_thread, data=update_data, partial=True, context=context) + serializer = ThreadSerializer( + cc_thread, data=update_data, partial=True, context=context + ) actions_form = ThreadActionsForm(update_data) if not (serializer.is_valid() and actions_form.is_valid()): - raise ValidationError(dict(list(serializer.errors.items()) + list(actions_form.errors.items()))) + raise ValidationError( + dict(list(serializer.errors.items()) + list(actions_form.errors.items())) + ) # Only save thread object if some of the edited fields are in the thread data, not extra actions if set(update_data) - set(actions_form.fields): serializer.save() # signal to update Teams when a user edits a thread thread_edited.send(sender=None, user=request.user, post=cc_thread) api_thread = serializer.data - _do_extra_actions(api_thread, cc_thread, list(update_data.keys()), actions_form, context, request) + _do_extra_actions( + api_thread, cc_thread, list(update_data.keys()), actions_form, context, request + ) # always return read as True (and therefore unread_comment_count=0) as reasonably # accurate shortcut, rather than adding additional processing. - api_thread['read'] = True - api_thread['unread_comment_count'] = 0 + api_thread["read"] = True + api_thread["unread_comment_count"] = 0 return api_thread @@ -1628,16 +1880,27 @@ def update_comment(request, comment_id, update_data): """ cc_comment, context = _get_comment_and_context(request, comment_id) _check_editable_fields(cc_comment, update_data, context) - serializer = CommentSerializer(cc_comment, data=update_data, partial=True, context=context) + serializer = CommentSerializer( + cc_comment, data=update_data, partial=True, context=context + ) actions_form = CommentActionsForm(update_data) if not (serializer.is_valid() and actions_form.is_valid()): - raise ValidationError(dict(list(serializer.errors.items()) + list(actions_form.errors.items()))) + raise ValidationError( + dict(list(serializer.errors.items()) + list(actions_form.errors.items())) + ) # Only save comment object if some of the edited fields are in the comment data, not extra actions if set(update_data) - set(actions_form.fields): serializer.save() comment_edited.send(sender=None, user=request.user, post=cc_comment) api_comment = serializer.data - _do_extra_actions(api_comment, cc_comment, list(update_data.keys()), actions_form, context, request) + _do_extra_actions( + api_comment, + cc_comment, + list(update_data.keys()), + actions_form, + context, + request, + ) _handle_comment_signals(update_data, cc_comment, request.user) return api_comment @@ -1671,7 +1934,9 @@ def get_thread(request, thread_id, requested_fields=None, course_id=None): ) if course_id and course_id != cc_thread.course_id: raise ThreadNotFoundError("Thread not found.") - return _serialize_discussion_entities(request, context, [cc_thread], requested_fields, DiscussionEntity.thread)[0] + return _serialize_discussion_entities( + request, context, [cc_thread], requested_fields, DiscussionEntity.thread + )[0] def get_response_comments(request, comment_id, page, page_size, requested_fields=None): @@ -1699,7 +1964,10 @@ def get_response_comments(request, comment_id, page, page_size, requested_fields """ try: cc_comment = Comment(id=comment_id).retrieve() - reverse_order = request.GET.get('reverse_order', False) + reverse_order = request.GET.get("reverse_order", False) + show_deleted = request.GET.get("show_deleted", False) + show_deleted = show_deleted in ["true", "True", True] + cc_thread, context = _get_thread_and_context( request, cc_comment["thread_id"], @@ -1707,10 +1975,13 @@ def get_response_comments(request, comment_id, page, page_size, requested_fields "with_responses": True, "recursive": True, "reverse_order": reverse_order, - } + "show_deleted": show_deleted, + }, ) if cc_thread["thread_type"] == "question": - thread_responses = itertools.chain(cc_thread["endorsed_responses"], cc_thread["non_endorsed_responses"]) + thread_responses = itertools.chain( + cc_thread["endorsed_responses"], cc_thread["non_endorsed_responses"] + ) else: thread_responses = cc_thread["children"] response_comments = [] @@ -1720,16 +1991,35 @@ def get_response_comments(request, comment_id, page, page_size, requested_fields break response_skip = page_size * (page - 1) - paged_response_comments = response_comments[response_skip:(response_skip + page_size)] + paged_response_comments = response_comments[ + response_skip: (response_skip + page_size) + ] if not paged_response_comments and page != 1: raise PageNotFoundError("Page not found (No results on this page).") + if not show_deleted: + paged_response_comments = [ + response + for response in paged_response_comments + if not response.get("is_deleted", False) + ] + else: + if not context["has_moderation_privilege"]: + raise PermissionDenied( + "`show_deleted` can only be set by users with moderation roles." + ) results = _serialize_discussion_entities( - request, context, paged_response_comments, requested_fields, DiscussionEntity.comment + request, + context, + paged_response_comments, + requested_fields, + DiscussionEntity.comment, ) - comments_count = len(response_comments) - num_pages = (comments_count + page_size - 1) // page_size if comments_count else 1 + comments_count = len(paged_response_comments) + num_pages = ( + (comments_count + page_size - 1) // page_size if comments_count else 1 + ) paginator = DiscussionAPIPagination(request, page, num_pages, comments_count) return paginator.get_paginated_response(results) except CommentClientRequestError as err: @@ -1773,16 +2063,20 @@ def get_user_comments( context = get_context(course, request) if flagged and not context["has_moderation_privilege"]: - raise ValidationError("Only privileged users can filter comments by flagged status") + raise ValidationError( + "Only privileged users can filter comments by flagged status" + ) try: - response = Comment.retrieve_all({ - 'user_id': author.id, - 'course_id': str(course_key), - 'flagged': flagged, - 'page': page, - 'per_page': page_size, - }) + response = Comment.retrieve_all( + { + "user_id": author.id, + "course_id": str(course_key), + "flagged": flagged, + "page": page, + "per_page": page_size, + } + ) except CommentClientRequestError as err: raise CommentNotFoundError("Comment not found") from err @@ -1822,7 +2116,7 @@ def delete_thread(request, thread_id): """ cc_thread, context = _get_thread_and_context(request, thread_id) if can_delete(cc_thread, context): - cc_thread.delete() + cc_thread.delete(deleted_by=str(request.user.id)) thread_deleted.send(sender=None, user=request.user, post=cc_thread) track_thread_deleted_event(request, context["course"], cc_thread) else: @@ -1847,7 +2141,7 @@ def delete_comment(request, comment_id): """ cc_comment, context = _get_comment_and_context(request, comment_id) if can_delete(cc_comment, context): - cc_comment.delete() + cc_comment.delete(deleted_by=str(request.user.id)) comment_deleted.send(sender=None, user=request.user, post=cc_comment) track_comment_deleted_event(request, context["course"], cc_comment) else: @@ -1879,7 +2173,10 @@ def get_course_discussion_user_stats( """ course_key = CourseKey.from_string(course_key_str) - is_privileged = has_discussion_privileges(user=request.user, course_id=course_key) or request.user.is_staff + is_privileged = ( + has_discussion_privileges(user=request.user, course_id=course_key) + or request.user.is_staff + ) if is_privileged: order_by = order_by or UserOrdering.BY_FLAGS else: @@ -1888,30 +2185,35 @@ def get_course_discussion_user_stats( raise ValidationError({"order_by": "Invalid value"}) params = { - 'sort_key': str(order_by), - 'page': page, - 'per_page': page_size, + "sort_key": str(order_by), + "page": page, + "per_page": page_size, } comma_separated_usernames = matched_users_count = matched_users_pages = None if username_search_string: - comma_separated_usernames, matched_users_count, matched_users_pages = get_usernames_from_search_string( - course_key, username_search_string, page, page_size + comma_separated_usernames, matched_users_count, matched_users_pages = ( + get_usernames_from_search_string( + course_key, username_search_string, page, page_size + ) ) search_event_data = { - 'query': username_search_string, - 'search_type': 'Learner', - 'page': params.get('page'), - 'sort_key': params.get('sort_key'), - 'total_results': matched_users_count, + "query": username_search_string, + "search_type": "Learner", + "page": params.get("page"), + "sort_key": params.get("sort_key"), + "total_results": matched_users_count, } course = _get_course(course_key, request.user) track_forum_search_event(request, course, search_event_data) + if not comma_separated_usernames: - return DiscussionAPIPagination(request, 0, 1).get_paginated_response({ - "results": [], - }) + return DiscussionAPIPagination(request, 0, 1).get_paginated_response( + { + "results": [], + } + ) - params['usernames'] = comma_separated_usernames + params["usernames"] = comma_separated_usernames course_stats_response = get_course_user_stats(course_key, params) @@ -1931,71 +2233,429 @@ def get_course_discussion_user_stats( paginator = DiscussionAPIPagination( request, course_stats_response["page"], - matched_users_pages if username_search_string else course_stats_response["num_pages"], - matched_users_count if username_search_string else course_stats_response["count"], + ( + matched_users_pages + if username_search_string + else course_stats_response["num_pages"] + ), + ( + matched_users_count + if username_search_string + else course_stats_response["count"] + ), + ) + return paginator.get_paginated_response( + { + "results": serializer.data, + } ) - return paginator.get_paginated_response({ - "results": serializer.data, - }) def get_users_without_stats( - username_search_string, - course_key, - page_number, - page_size, - request, - is_privileged + username_search_string, course_key, page_number, page_size, request, is_privileged ): """ This return users with no user stats. This function will be deprecated when this ticket DOS-3414 is resolved """ if username_search_string: - comma_separated_usernames, matched_users_count, matched_users_pages = get_usernames_from_search_string( - course_key, username_search_string, page_number, page_size + comma_separated_usernames, matched_users_count, matched_users_pages = ( + get_usernames_from_search_string( + course_key, username_search_string, page_number, page_size + ) ) if not comma_separated_usernames: - return DiscussionAPIPagination(request, 0, 1).get_paginated_response({ - "results": [], - }) + return DiscussionAPIPagination(request, 0, 1).get_paginated_response( + { + "results": [], + } + ) else: - comma_separated_usernames, matched_users_count, matched_users_pages = get_usernames_for_course( - course_key, page_number, page_size + comma_separated_usernames, matched_users_count, matched_users_pages = ( + get_usernames_for_course(course_key, page_number, page_size) ) if comma_separated_usernames: - updated_course_stats = add_stats_for_users_with_null_values([], comma_separated_usernames) + updated_course_stats = add_stats_for_users_with_null_values( + [], comma_separated_usernames + ) - serializer = UserStatsSerializer(updated_course_stats, context={"is_privileged": is_privileged}, many=True) + serializer = UserStatsSerializer( + updated_course_stats, context={"is_privileged": is_privileged}, many=True + ) paginator = DiscussionAPIPagination( request, page_number, matched_users_pages, matched_users_count, ) - return paginator.get_paginated_response({ - "results": serializer.data, - }) + return paginator.get_paginated_response( + { + "results": serializer.data, + } + ) def add_stats_for_users_with_null_values(course_stats, users_in_course): """ Update users stats for users with no discussion stats available in course """ - users_returned_from_api = [user['username'] for user in course_stats] - user_list = users_in_course.split(',') + users_returned_from_api = [user["username"] for user in course_stats] + user_list = users_in_course.split(",") users_with_no_discussion_content = set(user_list) ^ set(users_returned_from_api) updated_course_stats = course_stats for user in users_with_no_discussion_content: - updated_course_stats.append({ - 'username': user, - 'threads': None, - 'replies': None, - 'responses': None, - 'active_flags': None, - 'inactive_flags': None, - }) - updated_course_stats = sorted(updated_course_stats, key=lambda d: len(d['username'])) + updated_course_stats.append( + { + "username": user, + "threads": None, + "replies": None, + "responses": None, + "active_flags": None, + "inactive_flags": None, + } + ) + updated_course_stats = sorted( + updated_course_stats, key=lambda d: len(d["username"]) + ) return updated_course_stats + + +def _get_user_label_function(course_staff_user_ids, moderator_user_ids, ta_user_ids): + """ + Create and return a function that determines user labels based on role. + + Args: + course_staff_user_ids: List of user IDs for course staff + moderator_user_ids: List of user IDs for moderators + ta_user_ids: List of user IDs for TAs + + Returns: + A function that takes a user_id and returns the appropriate label or None + """ + + def get_user_label(user_id): + """Get role label for a user ID.""" + try: + user_id_int = int(user_id) + if user_id_int in course_staff_user_ids: + return "Staff" + elif user_id_int in moderator_user_ids: + return "Moderator" + elif user_id_int in ta_user_ids: + return "Community TA" + except (ValueError, TypeError): + # If user_id has any issues, there's no label to return + pass + return None + + return get_user_label + + +def _process_deleted_thread(thread_data, get_user_label_fn, usernames_set): + """ + Process a single deleted thread into the standardized content item format. + + Args: + thread_data: Raw thread data from forum API + get_user_label_fn: Function to get user labels by user ID + usernames_set: Set to collect usernames for profile image fetch (modified in-place) + + Returns: + dict: Formatted content item for the thread + """ + author_username = thread_data.get("author_username", "") + deleted_by_id = thread_data.get("deleted_by") + deleted_by_username = None + + # Get deleted_by username + if deleted_by_id: + try: + deleted_user = User.objects.get(id=int(deleted_by_id)) + deleted_by_username = deleted_user.username + usernames_set.add(deleted_by_username) + except (User.DoesNotExist, ValueError): + # If user not found or invalid ID, skip setting deleted fields + pass + + if author_username: + usernames_set.add(author_username) + + # Strip HTML tags from preview + body_text = thread_data.get("body", "") + preview_text = strip_tags(body_text)[:100] if body_text else "" + + thread_id = thread_data.get("_id", thread_data.get("id")) + return { + "id": str(thread_id) + "-thread", + "type": "thread", + "title": thread_data.get("title", ""), + "body": body_text, + "preview_body": preview_text, + "course_id": thread_data.get("course_id", ""), + "author": author_username, + "author_id": thread_data.get("author_id", ""), + "author_label": get_user_label_fn(thread_data.get("author_id")), + "commentable_id": thread_data.get("commentable_id", ""), + "created_at": thread_data.get("created_at"), + "updated_at": thread_data.get("updated_at"), + "is_deleted": True, + "deleted_at": thread_data.get("deleted_at"), + "deleted_by": deleted_by_username, + "deleted_by_label": get_user_label_fn(deleted_by_id) if deleted_by_id else None, + "thread_type": thread_data.get("thread_type", "discussion"), + "anonymous": thread_data.get("anonymous", False), + "anonymous_to_peers": thread_data.get("anonymous_to_peers", False), + "vote_count": thread_data.get("vote_count", 0), + "comment_count": thread_data.get("comment_count", 0), + } + + +def _process_deleted_comment(comment_data, get_user_label_fn, usernames_set): + """ + Process a single deleted comment into the standardized content item format. + + Args: + comment_data: Raw comment data from forum API + get_user_label_fn: Function to get user labels by user ID + usernames_set: Set to collect usernames for profile image fetch (modified in-place) + + Returns: + dict: Formatted content item for the comment + """ + author_username = comment_data.get("author_username", "") + deleted_by_id = comment_data.get("deleted_by") + deleted_by_username = None + + # Get deleted_by username + if deleted_by_id: + try: + deleted_user = User.objects.get(id=int(deleted_by_id)) + deleted_by_username = deleted_user.username + usernames_set.add(deleted_by_username) + except (User.DoesNotExist, ValueError): + # If user not found or invalid ID, skip setting deleted fields + pass + + if author_username: + usernames_set.add(author_username) + + # Determine if this is a response (depth=0) or comment (depth>0) + depth = comment_data.get("depth", 0) + comment_type = "response" if depth == 0 else "comment" + + # Get parent thread title for context + thread_id = comment_data.get("comment_thread_id", "") + thread_title = "" + if thread_id: + try: + parent_thread = Thread(id=thread_id).retrieve() + thread_title = parent_thread.get("title", "") + except Exception: # pylint: disable=broad-exception-caught + pass + + # Strip HTML tags from preview + body_text = comment_data.get("body", "") + preview_text = strip_tags(body_text)[:100] if body_text else "" + + comment_id = comment_data.get("_id", comment_data.get("id")) + return { + "id": str(comment_id) + "-comment", + "type": comment_type, + "body": body_text, + "preview_body": preview_text, + "title": thread_title, # Use parent thread title for comments/responses + "course_id": comment_data.get("course_id", ""), + "author": author_username, + "author_id": comment_data.get("author_id", ""), + "author_label": get_user_label_fn(comment_data.get("author_id")), + "comment_thread_id": str(thread_id), + "thread_title": thread_title, + "parent_id": ( + str(comment_data.get("parent_id", "")) + if comment_data.get("parent_id") + else None + ), + "created_at": comment_data.get("created_at"), + "updated_at": comment_data.get("updated_at"), + "is_deleted": True, + "deleted_at": comment_data.get("deleted_at"), + "deleted_by": deleted_by_username, + "deleted_by_label": get_user_label_fn(deleted_by_id) if deleted_by_id else None, + "depth": depth, + "anonymous": comment_data.get("anonymous", False), + "anonymous_to_peers": comment_data.get("anonymous_to_peers", False), + "endorsed": comment_data.get("endorsed", False), + "vote_count": comment_data.get("vote_count", 0), + } + + +def _add_user_profiles_to_content(deleted_content, usernames_set, request): + """ + Fetch user profile images and add them to each content item. + + Args: + deleted_content: List of content items (modified in-place) + usernames_set: Set of usernames to fetch profile images for + request: Django request object for getting profile images + """ + # Add profile images for all users + username_profile_dict = _get_user_profile_dict( + request, usernames=",".join(usernames_set) + ) + + # Add users dict with profile images to each item + for item in deleted_content: + users_dict = {} + + # Add author profile + author_username = item.get("author") + if author_username and author_username in username_profile_dict: + users_dict[author_username] = _user_profile( + username_profile_dict[author_username] + ) + + # Add deleted_by profile + deleted_by_username = item.get("deleted_by") + if deleted_by_username and deleted_by_username in username_profile_dict: + users_dict[deleted_by_username] = _user_profile( + username_profile_dict[deleted_by_username] + ) + + item["users"] = users_dict + + +def get_deleted_content_for_course( + request, course_id, content_type=None, page=1, per_page=20, author_id=None +): + """ + Retrieve all deleted content (threads, comments) for a course. + + Args: + request: The django request object for getting user profile images + course_id (str): Course identifier + content_type (str, optional): Filter by 'thread' or 'comment'. If None, returns all types. + page (int): Page number for pagination (1-based) + per_page (int): Number of items per page + author_id (str, optional): Filter by author ID + + Returns: + dict: Paginated results with deleted content including author labels and profile images + """ + + import math + + from lms.djangoapps.discussion.rest_api.utils import ( + get_course_staff_users_list, + get_course_ta_users_list, + get_moderator_users_list, + ) + + try: + # Get course and user role information for labels + course_key = CourseKey.from_string(course_id) + course = _get_course(course_key, request.user) + + course_staff_user_ids = get_course_staff_users_list(course.id) + moderator_user_ids = get_moderator_users_list(course.id) + ta_user_ids = get_course_ta_users_list(course.id) + + # Get user label function + get_user_label = _get_user_label_function( + course_staff_user_ids, moderator_user_ids, ta_user_ids + ) + + # Build query parameters for forum API + query_params = { + "course_id": course_id, + "is_deleted": True, # Only get deleted content + "page": page, + "per_page": per_page, + } + + if author_id: + query_params["author_id"] = author_id + + deleted_content = [] + total_count = 0 + usernames_set = set() # Track all usernames for profile image fetch + + # Get deleted threads + if content_type is None or content_type == "thread": + try: + deleted_threads = forum_api.get_deleted_threads_for_course( + course_id=course_id, + page=page if content_type == "thread" else 1, + per_page=per_page if content_type == "thread" else 1000, + author_id=author_id, + ) + for thread_data in deleted_threads.get("threads", []): + content_item = _process_deleted_thread( + thread_data, get_user_label, usernames_set + ) + deleted_content.append(content_item) + + if content_type == "thread": + total_count = deleted_threads.get( + "total_count", len(deleted_content) + ) + except Exception as e: # pylint: disable=broad-exception-caught + log.warning( + "Failed to get deleted threads for course %s: %s", course_id, e + ) + + # Get deleted comments + if content_type is None or content_type == "comment": + try: + deleted_comments = forum_api.get_deleted_comments_for_course( + course_id=course_id, + page=page if content_type == "comment" else 1, + per_page=per_page if content_type == "comment" else 1000, + author_id=author_id, + ) + for comment_data in deleted_comments.get("comments", []): + content_item = _process_deleted_comment( + comment_data, get_user_label, usernames_set + ) + deleted_content.append(content_item) + + if content_type == "comment": + total_count = deleted_comments.get( + "total_count", len(deleted_content) + ) + except Exception as e: # pylint: disable=broad-exception-caught + log.warning( + "Failed to get deleted comments for course %s: %s", course_id, e + ) + + # If getting all content types, handle pagination differently + if content_type is None: + total_count = len(deleted_content) + # Sort by deletion date (most recent first) + deleted_content.sort(key=lambda x: x.get("deleted_at", ""), reverse=True) + + # Apply pagination to combined results + start_idx = (page - 1) * per_page + end_idx = start_idx + per_page + deleted_content = deleted_content[start_idx:end_idx] + + # Add profile images for all users + _add_user_profiles_to_content(deleted_content, usernames_set, request) + + # Calculate pagination info + num_pages = math.ceil(total_count / per_page) if total_count > 0 else 1 + + return { + "results": deleted_content, + "pagination": { + "next": None, # Can be computed if needed + "previous": None, # Can be computed if needed + "count": total_count, + "num_pages": num_pages, + }, + } + + except Exception as e: + log.exception("Error getting deleted content for course %s: %s", course_id, e) + raise diff --git a/lms/djangoapps/discussion/rest_api/forms.py b/lms/djangoapps/discussion/rest_api/forms.py index 8cc7127645b2..f37543723792 100644 --- a/lms/djangoapps/discussion/rest_api/forms.py +++ b/lms/djangoapps/discussion/rest_api/forms.py @@ -1,6 +1,7 @@ """ Discussion API forms """ + import urllib.parse from django.core.exceptions import ValidationError @@ -22,13 +23,15 @@ class UserOrdering(TextChoices): - BY_ACTIVITY = 'activity' - BY_FLAGS = 'flagged' - BY_RECENT_ACTIVITY = 'recency' + BY_ACTIVITY = "activity" + BY_FLAGS = "flagged" + BY_RECENT_ACTIVITY = "recency" + BY_DELETED = "deleted" class _PaginationForm(Form): """A form that includes pagination fields""" + page = IntegerField(required=False, min_value=1) page_size = IntegerField(required=False, min_value=1) @@ -45,6 +48,7 @@ class ThreadListGetForm(_PaginationForm): """ A form to validate query parameters in the thread list retrieval endpoint """ + EXCLUSIVE_PARAMS = ["topic_id", "text_search", "following"] course_id = CharField() @@ -58,17 +62,22 @@ class ThreadListGetForm(_PaginationForm): ) count_flagged = ExtendedNullBooleanField(required=False) flagged = ExtendedNullBooleanField(required=False) + show_deleted = ExtendedNullBooleanField(required=False) view = ChoiceField( - choices=[(choice, choice) for choice in ["unread", "unanswered", "unresponded"]], + choices=[ + (choice, choice) for choice in ["unread", "unanswered", "unresponded"] + ], required=False, ) order_by = ChoiceField( - choices=[(choice, choice) for choice in ["last_activity_at", "comment_count", "vote_count"]], - required=False + choices=[ + (choice, choice) + for choice in ["last_activity_at", "comment_count", "vote_count"] + ], + required=False, ) order_direction = ChoiceField( - choices=[(choice, choice) for choice in ["desc"]], - required=False + choices=[(choice, choice) for choice in ["desc"]], required=False ) requested_fields = MultiValueField(required=False) @@ -85,14 +94,16 @@ def clean_course_id(self): value = self.cleaned_data["course_id"] try: return CourseLocator.from_string(value) - except InvalidKeyError: - raise ValidationError(f"'{value}' is not a valid course id") # lint-amnesty, pylint: disable=raise-missing-from + except InvalidKeyError as e: + raise ValidationError(f"'{value}' is not a valid course id") from e def clean_following(self): """Validate following""" value = self.cleaned_data["following"] if value is False: # lint-amnesty, pylint: disable=no-else-raise - raise ValidationError("The value of the 'following' parameter must be true.") + raise ValidationError( + "The value of the 'following' parameter must be true." + ) else: return value @@ -115,6 +126,7 @@ class ThreadActionsForm(Form): A form to handle fields in thread creation/update that require separate interactions with the comments service. """ + following = BooleanField(required=False) voted = BooleanField(required=False) abuse_flagged = BooleanField(required=False) @@ -126,17 +138,20 @@ class CommentListGetForm(_PaginationForm): """ A form to validate query parameters in the comment list retrieval endpoint """ + thread_id = CharField() flagged = BooleanField(required=False) endorsed = ExtendedNullBooleanField(required=False) requested_fields = MultiValueField(required=False) merge_question_type_responses = BooleanField(required=False) + show_deleted = ExtendedNullBooleanField(required=False) class UserCommentListGetForm(_PaginationForm): """ A form to validate query parameters in the comment list retrieval endpoint """ + course_id = CharField() flagged = BooleanField(required=False) requested_fields = MultiValueField(required=False) @@ -146,8 +161,8 @@ def clean_course_id(self): value = self.cleaned_data["course_id"] try: return CourseLocator.from_string(value) - except InvalidKeyError: - raise ValidationError(f"'{value}' is not a valid course id") # lint-amnesty, pylint: disable=raise-missing-from + except InvalidKeyError as e: + raise ValidationError(f"'{value}' is not a valid course id") from e class CommentActionsForm(Form): @@ -155,6 +170,7 @@ class CommentActionsForm(Form): A form to handle fields in comment creation/update that require separate interactions with the comments service. """ + voted = BooleanField(required=False) abuse_flagged = BooleanField(required=False) @@ -163,6 +179,7 @@ class CommentGetForm(_PaginationForm): """ A form to validate query parameters in the comment retrieval endpoint """ + requested_fields = MultiValueField(required=False) @@ -170,28 +187,34 @@ class CourseDiscussionSettingsForm(Form): """ A form to validate the fields in the course discussion settings requests. """ + course_id = CharField() def __init__(self, *args, **kwargs): - self.request_user = kwargs.pop('request_user') + self.request_user = kwargs.pop("request_user") super().__init__(*args, **kwargs) def clean_course_id(self): """Validate the 'course_id' value""" - course_id = self.cleaned_data['course_id'] + course_id = self.cleaned_data["course_id"] try: course_key = CourseKey.from_string(course_id) - self.cleaned_data['course'] = get_course_with_access(self.request_user, 'load', course_key) - self.cleaned_data['course_key'] = course_key + self.cleaned_data["course"] = get_course_with_access( + self.request_user, "load", course_key + ) + self.cleaned_data["course_key"] = course_key return course_id - except InvalidKeyError: - raise ValidationError(f"'{str(course_id)}' is not a valid course key") # lint-amnesty, pylint: disable=raise-missing-from + except InvalidKeyError as e: + raise ValidationError( + f"'{str(course_id)}' is not a valid course key" + ) from e class CourseDiscussionRolesForm(CourseDiscussionSettingsForm): """ A form to validate the fields in the course discussion roles requests. """ + ROLE_CHOICES = ( (FORUM_ROLE_MODERATOR, FORUM_ROLE_MODERATOR), (FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR), @@ -199,20 +222,20 @@ class CourseDiscussionRolesForm(CourseDiscussionSettingsForm): ) rolename = ChoiceField( choices=ROLE_CHOICES, - error_messages={"invalid_choice": "Role '%(value)s' does not exist"} + error_messages={"invalid_choice": "Role '%(value)s' does not exist"}, ) def clean_rolename(self): """Validate the 'rolename' value.""" - rolename = urllib.parse.unquote(self.cleaned_data.get('rolename')) - course_id = self.cleaned_data.get('course_key') + rolename = urllib.parse.unquote(self.cleaned_data.get("rolename")) + course_id = self.cleaned_data.get("course_key") if course_id and rolename: try: role = Role.objects.get(name=rolename, course_id=course_id) except Role.DoesNotExist as err: raise ValidationError(f"Role '{rolename}' does not exist") from err - self.cleaned_data['role'] = role + self.cleaned_data["role"] = role return rolename @@ -220,15 +243,17 @@ class TopicListGetForm(Form): """ Form for the topics API get query parameters. """ + topic_id = CharField(required=False) order_by = ChoiceField(choices=TopicOrdering.choices, required=False) def clean_topic_id(self): topic_ids = self.cleaned_data.get("topic_id", None) - return set(topic_ids.strip(',').split(',')) if topic_ids else None + return set(topic_ids.strip(",").split(",")) if topic_ids else None class CourseActivityStatsForm(_PaginationForm): """Form for validating course activity stats API query parameters""" + order_by = ChoiceField(choices=UserOrdering.choices, required=False) username = CharField(required=False) diff --git a/lms/djangoapps/discussion/rest_api/serializers.py b/lms/djangoapps/discussion/rest_api/serializers.py index 8a7ab16e0903..902a433dac3b 100644 --- a/lms/djangoapps/discussion/rest_api/serializers.py +++ b/lms/djangoapps/discussion/rest_api/serializers.py @@ -1,13 +1,13 @@ """ Discussion API serializers """ + import html import re - -from bs4 import BeautifulSoup from typing import Dict from urllib.parse import urlencode, urlunparse +from bs4 import BeautifulSoup from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist, ValidationError @@ -18,8 +18,12 @@ from common.djangoapps.student.models import get_user_by_username_or_email from common.djangoapps.student.roles import GlobalStaff -from lms.djangoapps.discussion.django_comment_client.base.views import track_thread_lock_unlock_event, \ - track_thread_edited_event, track_comment_edited_event, track_forum_response_mark_event +from lms.djangoapps.discussion.django_comment_client.base.views import ( + track_comment_edited_event, + track_forum_response_mark_event, + track_thread_edited_event, + track_thread_lock_unlock_event, +) from lms.djangoapps.discussion.django_comment_client.utils import ( course_discussion_division_enabled, get_group_id_for_user, @@ -35,17 +39,23 @@ from lms.djangoapps.discussion.rest_api.render import render_body from lms.djangoapps.discussion.rest_api.utils import ( get_course_staff_users_list, - get_moderator_users_list, get_course_ta_users_list, + get_moderator_users_list, get_user_learner_status, ) from openedx.core.djangoapps.discussions.models import DiscussionTopicLink from openedx.core.djangoapps.discussions.utils import get_group_names_by_id from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread -from openedx.core.djangoapps.django_comment_common.comment_client.user import User as CommentClientUser -from openedx.core.djangoapps.django_comment_common.comment_client.utils import CommentClientRequestError -from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings +from openedx.core.djangoapps.django_comment_common.comment_client.user import ( + User as CommentClientUser, +) +from openedx.core.djangoapps.django_comment_common.comment_client.utils import ( + CommentClientRequestError, +) +from openedx.core.djangoapps.django_comment_common.models import ( + CourseDiscussionSettings, +) from openedx.core.djangoapps.user_api.accounts.api import get_profile_images from openedx.core.lib.api.serializers import CourseKeyField @@ -59,6 +69,7 @@ class TopicOrdering(TextChoices): """ Enum for the available options for ordering topics. """ + COURSE_STRUCTURE = "course_structure", "Course Structure" ACTIVITY = "activity", "Activity" NAME = "name", "Name" @@ -73,16 +84,24 @@ def get_context(course, request, thread=None): moderator_user_ids = get_moderator_users_list(course.id) ta_user_ids = get_course_ta_users_list(course.id) requester = request.user - cc_requester = CommentClientUser.from_django_user(requester).retrieve(course_id=course.id) + cc_requester = CommentClientUser.from_django_user(requester).retrieve( + course_id=course.id + ) cc_requester["course_id"] = course.id course_discussion_settings = CourseDiscussionSettings.get(course.id) is_global_staff = GlobalStaff().has_user(requester) - has_moderation_privilege = requester.id in moderator_user_ids or requester.id in ta_user_ids or is_global_staff + has_moderation_privilege = ( + requester.id in moderator_user_ids + or requester.id in ta_user_ids + or is_global_staff + ) return { "course": course, "request": request, "thread": thread, - "discussion_division_enabled": course_discussion_division_enabled(course_discussion_settings), + "discussion_division_enabled": course_discussion_division_enabled( + course_discussion_settings + ), "group_ids_to_names": get_group_names_by_id(course_discussion_settings), "moderator_user_ids": moderator_user_ids, "course_staff_user_ids": course_staff_user_ids, @@ -137,8 +156,8 @@ def _validate_privileged_access(context: Dict) -> bool: Returns: bool: Course exists and the user has privileged access. """ - course = context.get('course', None) - is_requester_privileged = context.get('has_moderation_privilege') + course = context.get("course", None) + is_requester_privileged = context.get("has_moderation_privilege") return course and is_requester_privileged @@ -158,7 +177,7 @@ def filter_spam_urls_from_html(html_string): patterns.append(re.compile(rf"(https?://)?{domain_pattern}", re.IGNORECASE)) for a_tag in soup.find_all("a", href=True): - href = a_tag.get('href') + href = a_tag.get("href") if href: if any(p.search(href) for p in patterns): a_tag.replace_with(a_tag.get_text(strip=True)) @@ -167,7 +186,7 @@ def filter_spam_urls_from_html(html_string): for text_node in soup.find_all(string=True): new_text = text_node for p in patterns: - new_text = p.sub('', new_text) + new_text = p.sub("", new_text) if new_text != text_node: text_node.replace_with(new_text.strip()) is_spam = True @@ -196,8 +215,14 @@ class _ContentSerializer(serializers.Serializer): anonymous = serializers.BooleanField(default=False) anonymous_to_peers = serializers.BooleanField(default=False) last_edit = serializers.SerializerMethodField(required=False) - edit_reason_code = serializers.CharField(required=False, validators=[validate_edit_reason_code]) + edit_reason_code = serializers.CharField( + required=False, validators=[validate_edit_reason_code] + ) edit_by_label = serializers.SerializerMethodField(required=False) + is_deleted = serializers.SerializerMethodField(read_only=True) + deleted_at = serializers.SerializerMethodField(read_only=True) + deleted_by = serializers.SerializerMethodField(read_only=True) + deleted_by_label = serializers.SerializerMethodField(read_only=True) non_updatable_fields = set() @@ -219,7 +244,10 @@ def _is_user_privileged(self, user_id): Returns a boolean indicating whether the given user_id identifies a privileged user. """ - return user_id in self.context["moderator_user_ids"] or user_id in self.context["ta_user_ids"] + return ( + user_id in self.context["moderator_user_ids"] + or user_id in self.context["ta_user_ids"] + ) def _is_anonymous(self, obj): """ @@ -227,13 +255,13 @@ def _is_anonymous(self, obj): the requester. """ user_id = self.context["request"].user.id - is_user_staff = user_id in self.context["moderator_user_ids"] or user_id in self.context["ta_user_ids"] - - return ( - obj["anonymous"] or - obj["anonymous_to_peers"] and not is_user_staff + is_user_staff = ( + user_id in self.context["moderator_user_ids"] + or user_id in self.context["ta_user_ids"] ) + return obj["anonymous"] or obj["anonymous_to_peers"] and not is_user_staff + def get_author(self, obj): """ Returns the author's username, or None if the content is anonymous. @@ -250,10 +278,9 @@ def _get_user_label(self, user_id): is_ta = user_id in self.context["ta_user_ids"] return ( - "Staff" if is_staff else - "Moderator" if is_moderator else - "Community TA" if is_ta else - None + "Staff" + if is_staff + else "Moderator" if is_moderator else "Community TA" if is_ta else None ) def _get_user_label_from_username(self, username): @@ -303,7 +330,9 @@ def get_rendered_body(self, obj): """ if self._rendered_body is None: self._rendered_body = render_body(obj["body"]) - self._rendered_body, is_spam = filter_spam_urls_from_html(self._rendered_body) + self._rendered_body, is_spam = filter_spam_urls_from_html( + self._rendered_body + ) if is_spam and settings.CONTENT_FOR_SPAM_POSTS: self._rendered_body = settings.CONTENT_FOR_SPAM_POSTS return self._rendered_body @@ -315,8 +344,9 @@ def get_abuse_flagged(self, obj): """ total_abuse_flaggers = len(obj.get("abuse_flaggers", [])) return ( - self.context["has_moderation_privilege"] and total_abuse_flaggers > 0 or - self.context["cc_requester"]["id"] in obj.get("abuse_flaggers", []) + self.context["has_moderation_privilege"] + and total_abuse_flaggers > 0 + or self.context["cc_requester"]["id"] in obj.get("abuse_flaggers", []) ) def get_voted(self, obj): @@ -349,7 +379,7 @@ def get_last_edit(self, obj): Returns information about the last edit for this content for privileged users. """ - is_user_author = str(obj['user_id']) == str(self.context['request'].user.id) + is_user_author = str(obj["user_id"]) == str(self.context["request"].user.id) if not (_validate_privileged_access(self.context) or is_user_author): return None edit_history = obj.get("edit_history") @@ -365,12 +395,57 @@ def get_edit_by_label(self, obj): """ Returns the role label for the last edit user. """ - is_user_author = str(obj['user_id']) == str(self.context['request'].user.id) + is_user_author = str(obj["user_id"]) == str(self.context["request"].user.id) is_user_privileged = _validate_privileged_access(self.context) edit_history = obj.get("edit_history") if (is_user_author or is_user_privileged) and edit_history: last_edit = edit_history[-1] - return self._get_user_label_from_username(last_edit.get('editor_username')) + return self._get_user_label_from_username(last_edit.get("editor_username")) + + def get_is_deleted(self, obj): + """ + Returns the is_deleted status for privileged users only. + """ + if not _validate_privileged_access(self.context): + return None + return obj.get("is_deleted", False) + + def get_deleted_at(self, obj): + """ + Returns the deletion timestamp for privileged users only. + """ + if not _validate_privileged_access(self.context): + return None + return obj.get("deleted_at") + + def get_deleted_by(self, obj): + """ + Returns the username of the user who deleted this content for privileged users only. + """ + if not _validate_privileged_access(self.context): + return None + deleted_by_id = obj.get("deleted_by") + if deleted_by_id: + try: + user = User.objects.get(id=int(deleted_by_id)) + return user.username + except (User.DoesNotExist, ValueError): + return None + return None + + def get_deleted_by_label(self, obj): + """ + Returns the role label for the user who deleted this content for privileged users only. + """ + if not _validate_privileged_access(self.context): + return None + deleted_by_id = obj.get("deleted_by") + if deleted_by_id: + try: + return self._get_user_label(int(deleted_by_id)) + except (ValueError, TypeError): + return None + return None class ThreadSerializer(_ContentSerializer): @@ -381,13 +456,15 @@ class ThreadSerializer(_ContentSerializer): not had retrieve() called, because of the interaction between DRF's attempts at introspection and Thread's __getattr__. """ + course_id = serializers.CharField() - topic_id = serializers.CharField(source="commentable_id", validators=[validate_not_blank]) + topic_id = serializers.CharField( + source="commentable_id", validators=[validate_not_blank] + ) group_id = serializers.IntegerField(required=False, allow_null=True) group_name = serializers.SerializerMethodField() type = serializers.ChoiceField( - source="thread_type", - choices=[(val, val) for val in ["discussion", "question"]] + source="thread_type", choices=[(val, val) for val in ["discussion", "question"]] ) preview_body = serializers.SerializerMethodField() abuse_flagged_count = serializers.SerializerMethodField(required=False) @@ -402,8 +479,12 @@ class ThreadSerializer(_ContentSerializer): non_endorsed_comment_list_url = serializers.SerializerMethodField() read = serializers.BooleanField(required=False) has_endorsed = serializers.BooleanField(source="endorsed", read_only=True) - response_count = serializers.IntegerField(source="resp_total", read_only=True, required=False) - close_reason_code = serializers.CharField(required=False, validators=[validate_close_reason_code]) + response_count = serializers.IntegerField( + source="resp_total", read_only=True, required=False + ) + close_reason_code = serializers.CharField( + required=False, validators=[validate_close_reason_code] + ) close_reason = serializers.SerializerMethodField() closed_by = serializers.SerializerMethodField() closed_by_label = serializers.SerializerMethodField(required=False) @@ -449,9 +530,8 @@ def get_comment_list_url(self, obj, endorsed=None): Returns the URL to retrieve the thread's comments, optionally including the endorsed query parameter. """ - if ( - (obj["thread_type"] == "question" and endorsed is None) or - (obj["thread_type"] == "discussion" and endorsed is not None) + if (obj["thread_type"] == "question" and endorsed is None) or ( + obj["thread_type"] == "discussion" and endorsed is not None ): return None path = reverse("comment-list") @@ -495,13 +575,17 @@ def get_preview_body(self, obj): """ Returns a cleaned version of the thread's body to display in a preview capacity. """ - return strip_tags(self.get_rendered_body(obj)).replace('\n', ' ').replace(' ', ' ') + return ( + strip_tags(self.get_rendered_body(obj)) + .replace("\n", " ") + .replace(" ", " ") + ) def get_close_reason(self, obj): """ Returns the reason for which the thread was closed. """ - is_user_author = str(obj['user_id']) == str(self.context['request'].user.id) + is_user_author = str(obj["user_id"]) == str(self.context["request"].user.id) if not (_validate_privileged_access(self.context) or is_user_author): return None reason_code = obj.get("close_reason_code") @@ -512,7 +596,7 @@ def get_closed_by(self, obj): Returns the username of the moderator who closed this thread, only to other privileged users and author. """ - is_user_author = str(obj['user_id']) == str(self.context['request'].user.id) + is_user_author = str(obj["user_id"]) == str(self.context["request"].user.id) if _validate_privileged_access(self.context) or is_user_author: return obj.get("closed_by") @@ -520,7 +604,7 @@ def get_closed_by_label(self, obj): """ Returns the role label for the user who closed the post. """ - is_user_author = str(obj['user_id']) == str(self.context['request'].user.id) + is_user_author = str(obj["user_id"]) == str(self.context["request"].user.id) if is_user_author or _validate_privileged_access(self.context): return self._get_user_label_from_username(obj.get("closed_by")) @@ -535,18 +619,31 @@ def update(self, instance, validated_data): requesting_user_id = self.context["cc_requester"]["id"] if key == "closed" and val: instance["closing_user_id"] = requesting_user_id - track_thread_lock_unlock_event(self.context['request'], self.context['course'], - instance, validated_data.get('close_reason_code')) + track_thread_lock_unlock_event( + self.context["request"], + self.context["course"], + instance, + validated_data.get("close_reason_code"), + ) if key == "closed" and not val: instance["closing_user_id"] = requesting_user_id - track_thread_lock_unlock_event(self.context['request'], self.context['course'], - instance, validated_data.get('close_reason_code'), locked=False) + track_thread_lock_unlock_event( + self.context["request"], + self.context["course"], + instance, + validated_data.get("close_reason_code"), + locked=False, + ) if key == "body" and val: instance["editing_user_id"] = requesting_user_id - track_thread_edited_event(self.context['request'], self.context['course'], - instance, validated_data.get('edit_reason_code')) + track_thread_edited_event( + self.context["request"], + self.context["course"], + instance, + validated_data.get("edit_reason_code"), + ) instance.save() return instance @@ -559,6 +656,7 @@ class CommentSerializer(_ContentSerializer): not had retrieve() called, because of the interaction between DRF's attempts at introspection and Comment's __getattr__. """ + thread_id = serializers.CharField() parent_id = serializers.CharField(required=False, allow_null=True) endorsed = serializers.BooleanField(required=False) @@ -573,7 +671,7 @@ class CommentSerializer(_ContentSerializer): non_updatable_fields = NON_UPDATABLE_COMMENT_FIELDS def __init__(self, *args, **kwargs): - remove_fields = kwargs.pop('remove_fields', None) + remove_fields = kwargs.pop("remove_fields", None) super().__init__(*args, **kwargs) if remove_fields: @@ -595,8 +693,8 @@ def get_endorsed_by(self, obj): # Avoid revealing the identity of an anonymous non-staff question # author who has endorsed a comment in the thread if not ( - self._is_anonymous(self.context["thread"]) and - not self._is_user_privileged(endorser_id) + self._is_anonymous(self.context["thread"]) + and not self._is_user_privileged(endorser_id) ): return User.objects.get(id=endorser_id).username return None @@ -638,7 +736,7 @@ def to_representation(self, data): # Django Rest Framework v3 no longer includes None values # in the representation. To maintain the previous behavior, # we do this manually instead. - if 'parent_id' not in data: + if "parent_id" not in data: data["parent_id"] = None return data @@ -680,7 +778,7 @@ def create(self, validated_data): comment = Comment( course_id=self.context["thread"]["course_id"], user_id=self.context["cc_requester"]["id"], - **validated_data + **validated_data, ) comment.save() return comment @@ -693,12 +791,18 @@ def update(self, instance, validated_data): # endorsement_user_id on update requesting_user_id = self.context["cc_requester"]["id"] if key == "endorsed": - track_forum_response_mark_event(self.context['request'], self.context['course'], instance, val) + track_forum_response_mark_event( + self.context["request"], self.context["course"], instance, val + ) instance["endorsement_user_id"] = requesting_user_id if key == "body" and val: instance["editing_user_id"] = requesting_user_id - track_comment_edited_event(self.context['request'], self.context['course'], - instance, validated_data.get('edit_reason_code')) + track_comment_edited_event( + self.context["request"], + self.context["course"], + instance, + validated_data.get("edit_reason_code"), + ) instance.save() return instance @@ -708,6 +812,7 @@ class DiscussionTopicSerializer(serializers.Serializer): """ Serializer for DiscussionTopic """ + id = serializers.CharField(read_only=True) # pylint: disable=invalid-name name = serializers.CharField(read_only=True) thread_list_url = serializers.CharField(read_only=True) @@ -737,10 +842,11 @@ class DiscussionTopicSerializerV2(serializers.Serializer): """ Serializer for new style topics. """ + id = serializers.CharField( # pylint: disable=invalid-name read_only=True, source="external_id", - help_text="Provider-specific unique id for the topic" + help_text="Provider-specific unique id for the topic", ) usage_key = serializers.CharField( read_only=True, @@ -764,10 +870,13 @@ def get_thread_counts(self, obj: DiscussionTopicLink) -> Dict[str, int]: """ Get thread counts from provided context """ - return self.context['thread_counts'].get(obj.external_id, { - "discussion": 0, - "question": 0, - }) + return self.context["thread_counts"].get( + obj.external_id, + { + "discussion": 0, + "question": 0, + }, + ) class DiscussionRolesSerializer(serializers.Serializer): @@ -775,10 +884,7 @@ class DiscussionRolesSerializer(serializers.Serializer): Serializer for course discussion roles. """ - ACTION_CHOICES = ( - ('allow', 'allow'), - ('revoke', 'revoke') - ) + ACTION_CHOICES = (("allow", "allow"), ("revoke", "revoke")) action = serializers.ChoiceField(ACTION_CHOICES) user_id = serializers.CharField() @@ -799,14 +905,16 @@ def validate_user_id(self, user_id): self.user = get_user_by_username_or_email(user_id) return user_id except User.DoesNotExist as err: - raise ValidationError(f"'{user_id}' is not a valid student identifier") from err + raise ValidationError( + f"'{user_id}' is not a valid student identifier" + ) from err def validate(self, attrs): """Validate the data at an object level.""" # Store the user object to avoid fetching it again. - if hasattr(self, 'user'): - attrs['user'] = self.user + if hasattr(self, "user"): + attrs["user"] = self.user return attrs def create(self, validated_data): @@ -824,6 +932,7 @@ class DiscussionRolesMemberSerializer(serializers.Serializer): """ Serializer for course discussion roles member data. """ + username = serializers.CharField() email = serializers.EmailField() first_name = serializers.CharField() @@ -832,7 +941,7 @@ class DiscussionRolesMemberSerializer(serializers.Serializer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.course_discussion_settings = self.context['course_discussion_settings'] + self.course_discussion_settings = self.context["course_discussion_settings"] def get_group_name(self, instance): """Return the group name of the user.""" @@ -855,6 +964,7 @@ class DiscussionRolesListSerializer(serializers.Serializer): """ Serializer for course discussion roles member list. """ + course_id = serializers.CharField() results = serializers.SerializerMethodField() division_scheme = serializers.SerializerMethodField() @@ -862,15 +972,17 @@ class DiscussionRolesListSerializer(serializers.Serializer): def get_results(self, obj): """Return the nested serializer data representing a list of member users.""" context = { - 'course_id': obj['course_id'], - 'course_discussion_settings': self.context['course_discussion_settings'] + "course_id": obj["course_id"], + "course_discussion_settings": self.context["course_discussion_settings"], } - serializer = DiscussionRolesMemberSerializer(obj['users'], context=context, many=True) + serializer = DiscussionRolesMemberSerializer( + obj["users"], context=context, many=True + ) return serializer.data def get_division_scheme(self, obj): # pylint: disable=unused-argument """Return the division scheme for the course.""" - return self.context['course_discussion_settings'].division_scheme + return self.context["course_discussion_settings"].division_scheme def create(self, validated_data): """ @@ -887,9 +999,13 @@ class UserStatsSerializer(serializers.Serializer): """ Serializer for course user stats. """ + threads = serializers.IntegerField() replies = serializers.IntegerField() responses = serializers.IntegerField() + deleted_threads = serializers.IntegerField(required=False, default=0) + deleted_replies = serializers.IntegerField(required=False, default=0) + deleted_responses = serializers.IntegerField(required=False, default=0) active_flags = serializers.IntegerField() inactive_flags = serializers.IntegerField() username = serializers.CharField() @@ -907,27 +1023,36 @@ class BlackoutDateSerializer(serializers.Serializer): """ Serializer for blackout dates. """ - start = serializers.DateTimeField(help_text="The ISO 8601 timestamp for the start of the blackout period") - end = serializers.DateTimeField(help_text="The ISO 8601 timestamp for the end of the blackout period") + + start = serializers.DateTimeField( + help_text="The ISO 8601 timestamp for the start of the blackout period" + ) + end = serializers.DateTimeField( + help_text="The ISO 8601 timestamp for the end of the blackout period" + ) class ReasonCodeSeralizer(serializers.Serializer): """ Serializer for reason codes. """ + code = serializers.CharField(help_text="A code for the an edit or close reason") - label = serializers.CharField(help_text="A user-friendly name text for the close or edit reason") + label = serializers.CharField( + help_text="A user-friendly name text for the close or edit reason" + ) class CourseMetadataSerailizer(serializers.Serializer): """ Serializer for course metadata. """ + id = CourseKeyField(help_text="The identifier of the course") blackouts = serializers.ListField( child=BlackoutDateSerializer(), help_text="A list of objects representing blackout periods " - "(during which discussions are read-only except for privileged users)." + "(during which discussions are read-only except for privileged users).", ) thread_list_url = serializers.URLField( help_text="The URL of the list of all threads in the course.", @@ -935,7 +1060,9 @@ class CourseMetadataSerailizer(serializers.Serializer): following_thread_list_url = serializers.URLField( help_text="thread_list_url with parameter following=True", ) - topics_url = serializers.URLField(help_text="The URL of the topic listing for the course.") + topics_url = serializers.URLField( + help_text="The URL of the topic listing for the course." + ) allow_anonymous = serializers.BooleanField( help_text="A boolean indicating whether anonymous posts are allowed or not.", ) diff --git a/lms/djangoapps/discussion/rest_api/tasks.py b/lms/djangoapps/discussion/rest_api/tasks.py index cd725a3513dc..5773fbbc83b0 100644 --- a/lms/djangoapps/discussion/rest_api/tasks.py +++ b/lms/djangoapps/discussion/rest_api/tasks.py @@ -1,32 +1,36 @@ """ Contain celery tasks """ + import logging from celery import shared_task from django.contrib.auth import get_user_model from edx_django_utils.monitoring import set_code_owner_attribute -from opaque_keys.edx.locator import CourseKey from eventtracking import tracker +from opaque_keys.edx.locator import CourseKey -from common.djangoapps.student.roles import CourseStaffRole, CourseInstructorRole +from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole from common.djangoapps.track import segment from lms.djangoapps.courseware.courses import get_course_with_access from lms.djangoapps.discussion.django_comment_client.utils import get_user_role_names -from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender +from lms.djangoapps.discussion.rest_api.discussions_notifications import ( + DiscussionNotificationSender, +) from lms.djangoapps.discussion.rest_api.utils import can_user_notify_all_learners from openedx.core.djangoapps.django_comment_common.comment_client import Comment from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS - User = get_user_model() log = logging.getLogger(__name__) @shared_task @set_code_owner_attribute -def send_thread_created_notification(thread_id, course_key_str, user_id, notify_all_learners=False): +def send_thread_created_notification( + thread_id, course_key_str, user_id, notify_all_learners=False +): """ Send notification when a new thread is created """ @@ -40,17 +44,21 @@ def send_thread_created_notification(thread_id, course_key_str, user_id, notify_ is_course_staff = CourseStaffRole(course_key).has_user(user) is_course_admin = CourseInstructorRole(course_key).has_user(user) user_roles = get_user_role_names(user, course_key) - if not can_user_notify_all_learners(user_roles, is_course_staff, is_course_admin): + if not can_user_notify_all_learners( + user_roles, is_course_staff, is_course_admin + ): return - course = get_course_with_access(user, 'load', course_key, check_if_enrolled=True) + course = get_course_with_access(user, "load", course_key, check_if_enrolled=True) notification_sender = DiscussionNotificationSender(thread, course, user) notification_sender.send_new_thread_created_notification(notify_all_learners) @shared_task @set_code_owner_attribute -def send_response_notifications(thread_id, course_key_str, user_id, comment_id, parent_id=None): +def send_response_notifications( + thread_id, course_key_str, user_id, comment_id, parent_id=None +): """ Send notifications to users who are subscribed to the thread. """ @@ -59,8 +67,10 @@ def send_response_notifications(thread_id, course_key_str, user_id, comment_id, return thread = Thread(id=thread_id).retrieve() user = User.objects.get(id=user_id) - course = get_course_with_access(user, 'load', course_key, check_if_enrolled=True) - notification_sender = DiscussionNotificationSender(thread, course, user, parent_id, comment_id) + course = get_course_with_access(user, "load", course_key, check_if_enrolled=True) + notification_sender = DiscussionNotificationSender( + thread, course, user, parent_id, comment_id + ) notification_sender.send_new_comment_notification() notification_sender.send_new_response_notification() notification_sender.send_new_comment_on_response_notification() @@ -69,7 +79,9 @@ def send_response_notifications(thread_id, course_key_str, user_id, comment_id, @shared_task @set_code_owner_attribute -def send_response_endorsed_notifications(thread_id, response_id, course_key_str, endorsed_by): +def send_response_endorsed_notifications( + thread_id, response_id, course_key_str, endorsed_by +): """ Send notifications when a response is marked answered/ endorsed """ @@ -80,8 +92,10 @@ def send_response_endorsed_notifications(thread_id, response_id, course_key_str, response = Comment(id=response_id).retrieve() creator = User.objects.get(id=response.user_id) endorser = User.objects.get(id=endorsed_by) - course = get_course_with_access(creator, 'load', course_key, check_if_enrolled=True) - notification_sender = DiscussionNotificationSender(thread, course, creator, comment_id=response_id) + course = get_course_with_access(creator, "load", course_key, check_if_enrolled=True) + notification_sender = DiscussionNotificationSender( + thread, course, creator, comment_id=response_id + ) # skip sending notification to author of thread if they are the same as the author of the response if response.user_id != thread.user_id: # sends notification to author of thread @@ -99,15 +113,63 @@ def delete_course_post_for_user(user_id, username, course_ids, event_data=None): Deletes all posts for user in a course. """ event_data = event_data or {} - log.info(f"<> Deleting all posts for {username} in course {course_ids}") - threads_deleted = Thread.delete_user_threads(user_id, course_ids) - comments_deleted = Comment.delete_user_comments(user_id, course_ids) - log.info(f"<> Deleted {threads_deleted} posts and {comments_deleted} comments for {username} " - f"in course {course_ids}") - event_data.update({ - "number_of_posts_deleted": threads_deleted, - "number_of_comments_deleted": comments_deleted, - }) - event_name = 'edx.discussion.bulk_delete_user_posts' + log.info( + f"<> Deleting all posts for {username} in course {course_ids}" + ) + # Get triggered_by user_id from event_data for audit trail + deleted_by_user_id = event_data.get("triggered_by_user_id") if event_data else None + threads_deleted = Thread.delete_user_threads( + user_id, course_ids, deleted_by=deleted_by_user_id + ) + comments_deleted = Comment.delete_user_comments( + user_id, course_ids, deleted_by=deleted_by_user_id + ) + log.info( + f"<> Deleted {threads_deleted} posts and {comments_deleted} comments for {username} " + f"in course {course_ids}" + ) + event_data.update( + { + "number_of_posts_deleted": threads_deleted, + "number_of_comments_deleted": comments_deleted, + } + ) + event_name = "edx.discussion.bulk_delete_user_posts" + tracker.emit(event_name, event_data) + segment.track("None", event_name, event_data) + + +@shared_task +@set_code_owner_attribute +def restore_course_post_for_user(user_id, username, course_ids, event_data=None): + """ + Restores all soft-deleted posts for user in a course by setting is_deleted=False. + """ + event_data = event_data or {} + log.info( + "<> Restoring all posts for %s in course %s", username, course_ids + ) + # Get triggered_by user_id from event_data for audit trail + restored_by_user_id = event_data.get("triggered_by_user_id") if event_data else None + threads_restored = Thread.restore_user_deleted_threads( + user_id, course_ids, restored_by=restored_by_user_id + ) + comments_restored = Comment.restore_user_deleted_comments( + user_id, course_ids, restored_by=restored_by_user_id + ) + log.info( + "<> Restored %s posts and %s comments for %s in course %s", + threads_restored, + comments_restored, + username, + course_ids, + ) + event_data.update( + { + "number_of_posts_restored": threads_restored, + "number_of_comments_restored": comments_restored, + } + ) + event_name = "edx.discussion.bulk_restore_user_posts" tracker.emit(event_name, event_data) - segment.track('None', event_name, event_data) + segment.track("None", event_name, event_data) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py index 53c12454aec9..2fa761b46615 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py @@ -10,34 +10,20 @@ import random from datetime import datetime, timedelta from unittest import mock -from urllib.parse import parse_qs, urlencode, urlparse, urlunparse import ddt import httpretty import pytest -from django.test import override_settings from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.test.client import RequestFactory -from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import CourseLocator from pytz import UTC from rest_framework.exceptions import PermissionDenied -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import ( - ModuleStoreTestCase, - SharedModuleStoreTestCase, -) -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory -from xmodule.partitions.partitions import Group, UserPartition - from common.djangoapps.student.tests.factories import ( AdminFactory, - BetaTesterFactory, CourseEnrollmentFactory, - StaffFactory, UserFactory, ) from common.djangoapps.util.testing import UrlResetMixin @@ -45,10 +31,6 @@ from lms.djangoapps.discussion.django_comment_client.tests.utils import ( ForumsEnableMixin, ) -from lms.djangoapps.discussion.tests.utils import ( - make_minimal_cs_comment, - make_minimal_cs_thread, -) from lms.djangoapps.discussion.rest_api import api from lms.djangoapps.discussion.rest_api.api import ( create_comment, @@ -56,12 +38,9 @@ delete_comment, delete_thread, get_comment_list, - get_course, - get_course_topics, get_course_topics_v2, get_thread, get_thread_list, - get_user_comments, update_comment, update_thread, ) @@ -73,18 +52,19 @@ ) from lms.djangoapps.discussion.rest_api.serializers import TopicOrdering from lms.djangoapps.discussion.rest_api.tests.utils import ( - CommentsServiceMockMixin, ForumMockUtilsMixin, make_paginated_api_response, - parsed_body, ) -from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup +from lms.djangoapps.discussion.tests.utils import ( + make_minimal_cs_comment, + make_minimal_cs_thread, +) from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory from openedx.core.djangoapps.discussions.models import ( DiscussionsConfiguration, DiscussionTopicLink, - Provider, PostingRestriction, + Provider, ) from openedx.core.djangoapps.discussions.tasks import ( update_discussions_settings_from_course_task, @@ -98,6 +78,13 @@ Role, ) from openedx.core.lib.exceptions import CourseNotFoundError, PageNotFoundError +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, + SharedModuleStoreTestCase, +) +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory User = get_user_model() @@ -274,7 +261,11 @@ def test_basic(self, mock_emit): ) self.register_post_thread_response(cs_thread) with self.assert_signal_sent( - api, "thread_created", sender=None, user=self.user, exclude_args=("post", "notify_all_learners") + api, + "thread_created", + sender=None, + user=self.user, + exclude_args=("post", "notify_all_learners"), ): actual = create_thread(self.request, self.minimal_data) expected = self.expected_thread_data( @@ -353,7 +344,11 @@ def test_basic_in_blackout_period_with_user_access(self, mock_emit): ) with self.assert_signal_sent( - api, "thread_created", sender=None, user=self.user, exclude_args=("post", "notify_all_learners") + api, + "thread_created", + sender=None, + user=self.user, + exclude_args=("post", "notify_all_learners"), ): actual = create_thread(self.request, self.minimal_data) expected = self.expected_thread_data( @@ -379,6 +374,7 @@ def test_basic_in_blackout_period_with_user_access(self, mock_emit): "type", "voted", ], + "is_deleted": False, } ) assert actual == expected @@ -430,7 +426,11 @@ def test_title_truncation(self, mock_emit): ) self.register_post_thread_response(cs_thread) with self.assert_signal_sent( - api, "thread_created", sender=None, user=self.user, exclude_args=("post", "notify_all_learners") + api, + "thread_created", + sender=None, + user=self.user, + exclude_args=("post", "notify_all_learners"), ): create_thread(self.request, data) event_name, event_data = mock_emit.call_args[0] @@ -718,6 +718,10 @@ def test_success(self, parent_id, mock_emit): "image_url_medium": "http://testserver/static/default_50.png", "image_url_small": "http://testserver/static/default_30.png", }, + "is_deleted": None, + "deleted_at": None, + "deleted_by": None, + "deleted_by_label": None, } assert actual == expected @@ -826,6 +830,10 @@ def test_success_in_black_out_with_user_access(self, parent_id, mock_emit): "image_url_medium": "http://testserver/static/default_50.png", "image_url_small": "http://testserver/static/default_30.png", }, + "is_deleted": False, + "deleted_at": None, + "deleted_by": None, + "deleted_by_label": None, } assert actual == expected @@ -914,7 +922,9 @@ def test_endorsed(self, role_name, is_thread_author, thread_type): ) try: create_comment(self.request, data) - last_commemt_params = self.get_mock_func_calls("create_parent_comment")[-1][1] + last_commemt_params = self.get_mock_func_calls("create_parent_comment")[-1][ + 1 + ] assert last_commemt_params["endorsed"] assert not expected_error except ValidationError: @@ -1828,6 +1838,10 @@ def test_basic(self, parent_id): "image_url_medium": "http://testserver/static/default_50.png", "image_url_small": "http://testserver/static/default_30.png", }, + "is_deleted": None, + "deleted_at": None, + "deleted_by": None, + "deleted_by_label": None, } assert actual == expected params = { @@ -1888,7 +1902,7 @@ def test_abuse_flagged(self, old_flagged, new_flagged, mock_emit): else "edx.forum.response.unreported" ) expected_event_data = { - "discussion": {'id': 'test_thread'}, + "discussion": {"id": "test_thread"}, "body": "Original body", "id": "test_comment", "content_type": "Response", @@ -1951,7 +1965,7 @@ def test_comment_un_abuse_flag_for_moderator_role( "body": "Original body", "id": "test_comment", "content_type": "Response", - "discussion": {'id': 'test_thread'}, + "discussion": {"id": "test_thread"}, "commentable_id": "dummy", "truncated": False, "url": "", @@ -2370,6 +2384,7 @@ def test_basic(self, mock_emit): params = { "thread_id": self.thread_id, "course_id": str(self.course.id), + "deleted_by": str(self.user.id), } self.check_mock_called_with("delete_thread", -1, **params) @@ -2557,6 +2572,7 @@ def test_basic(self, mock_emit): params = { "comment_id": self.comment_id, "course_id": str(self.course.id), + "deleted_by": str(self.user.id), } self.check_mock_called_with("delete_comment", -1, **params) @@ -2921,6 +2937,7 @@ def test_get_threads_by_topic_id(self): "page": 1, "per_page": 1, "commentable_ids": ["topic_x", "topic_meow"], + "show_deleted": False, } self.check_mock_called_with( "get_user_threads", @@ -2936,6 +2953,7 @@ def test_basic_query_params(self): "sort_key": "activity", "page": 6, "per_page": 14, + "show_deleted": False, } self.check_mock_called_with( "get_user_threads", @@ -3076,10 +3094,10 @@ def test_request_group(self, role_name, course_is_cohorted): self.get_thread_list([], course=cohort_course) thread_func_params = self.get_mock_func_calls("get_user_threads")[-1][1] actual_has_group = "group_id" in thread_func_params - expected_has_group = ( - course_is_cohorted and role_name in ( - FORUM_ROLE_STUDENT, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_GROUP_MODERATOR - ) + expected_has_group = course_is_cohorted and role_name in ( + FORUM_ROLE_STUDENT, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_GROUP_MODERATOR, ) assert actual_has_group == expected_has_group @@ -3144,6 +3162,7 @@ def test_text_search(self, text_search_rewrite): "page": 1, "per_page": 10, "text": "test search string", + "show_deleted": False, } self.check_mock_called_with( "search_threads", @@ -3170,6 +3189,7 @@ def test_filter_threads_by_author(self): "page": 1, "per_page": 10, "author_id": str(self.user.id), + "show_deleted": False, } self.check_mock_called_with( "get_user_threads", @@ -3216,6 +3236,7 @@ def test_thread_type(self, thread_type): "page": 1, "per_page": 10, "thread_type": thread_type, + "show_deleted": False, } if thread_type is None: @@ -3253,6 +3274,7 @@ def test_flagged(self, flagged_boolean): "page": 1, "per_page": 10, "flagged": flagged_boolean, + "show_deleted": False, } if flagged_boolean is None: @@ -3293,6 +3315,7 @@ def test_flagged_count(self, role): "count_flagged": True, "page": 1, "per_page": 10, + "show_deleted": False, } self.check_mock_called_with( @@ -3341,6 +3364,7 @@ def test_following(self): "sort_key": "activity", "page": 1, "per_page": 11, + "show_deleted": False, } self.check_mock_called_with("get_user_subscriptions", -1, **params) @@ -3368,6 +3392,7 @@ def test_view_query(self, query): "page": 1, "per_page": 11, query: True, + "show_deleted": False, } self.check_mock_called_with( "get_user_threads", @@ -3409,6 +3434,7 @@ def test_order_by_query(self, http_query, cc_query): "sort_key": cc_query, "page": 1, "per_page": 11, + "show_deleted": False, } self.check_mock_called_with( "get_user_threads", @@ -3441,6 +3467,7 @@ def test_order_direction(self): "sort_key": "activity", "page": 1, "per_page": 11, + "show_deleted": False, } self.check_mock_called_with( "get_user_threads", @@ -3769,6 +3796,10 @@ def get_source_and_expected_comments(self): "image_url_medium": "http://testserver/static/default_50.png", "image_url_small": "http://testserver/static/default_30.png", }, + "is_deleted": None, + "deleted_at": None, + "deleted_by": None, + "deleted_by_label": None, }, { "id": "test_comment_2", @@ -3804,6 +3835,10 @@ def get_source_and_expected_comments(self): "image_url_medium": "http://testserver/static/default_50.png", "image_url_small": "http://testserver/static/default_30.png", }, + "is_deleted": None, + "deleted_at": None, + "deleted_by": None, + "deleted_by_label": None, }, ] return source_comments, expected_comments diff --git a/lms/djangoapps/discussion/rest_api/tests/test_forms.py b/lms/djangoapps/discussion/rest_api/tests/test_forms.py index 3be65964b6b9..33359337933b 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_forms.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_forms.py @@ -2,7 +2,6 @@ Tests for Discussion API forms """ - import itertools from unittest import TestCase from urllib.parse import urlencode @@ -12,9 +11,9 @@ from opaque_keys.edx.locator import CourseLocator from lms.djangoapps.discussion.rest_api.forms import ( - UserCommentListGetForm, CommentListGetForm, ThreadListGetForm, + UserCommentListGetForm, ) from openedx.core.djangoapps.util.test_forms import FormTestMixin @@ -36,7 +35,9 @@ def test_missing_page_size(self): def test_zero_page_size(self): self.form_data["page_size"] = "0" - self.assert_error("page_size", "Ensure this value is greater than or equal to 1.") + self.assert_error( + "page_size", "Ensure this value is greater than or equal to 1." + ) def test_excessive_page_size(self): self.form_data["page_size"] = "101" @@ -46,6 +47,7 @@ def test_excessive_page_size(self): @ddt.ddt class ThreadListGetFormTest(FormTestMixin, PaginationTestMixin, TestCase): """Tests for ThreadListGetForm""" + FORM_CLASS = ThreadListGetForm def setUp(self): @@ -58,37 +60,41 @@ def setUp(self): "page_size": "13", } ), - mutable=True + mutable=True, ) def test_basic(self): form = self.get_form(expected_valid=True) assert form.cleaned_data == { - 'course_id': CourseLocator.from_string('Foo/Bar/Baz'), - 'page': 2, - 'page_size': 13, - 'count_flagged': None, - 'topic_id': set(), - 'text_search': '', - 'following': None, - 'author': '', - 'thread_type': '', - 'flagged': None, - 'view': '', - 'order_by': 'last_activity_at', - 'order_direction': 'desc', - 'requested_fields': set() + "course_id": CourseLocator.from_string("Foo/Bar/Baz"), + "page": 2, + "page_size": 13, + "count_flagged": None, + "topic_id": set(), + "text_search": "", + "following": None, + "author": "", + "thread_type": "", + "flagged": None, + "show_deleted": None, + "view": "", + "order_by": "last_activity_at", + "order_direction": "desc", + "requested_fields": set(), } def test_topic_id(self): self.form_data.setlist("topic_id", ["example topic_id", "example 2nd topic_id"]) form = self.get_form(expected_valid=True) - assert form.cleaned_data['topic_id'] == {'example topic_id', 'example 2nd topic_id'} + assert form.cleaned_data["topic_id"] == { + "example topic_id", + "example 2nd topic_id", + } def test_text_search(self): self.form_data["text_search"] = "test search string" form = self.get_form(expected_valid=True) - assert form.cleaned_data['text_search'] == 'test search string' + assert form.cleaned_data["text_search"] == "test search string" def test_missing_course_id(self): self.form_data.pop("course_id") @@ -109,7 +115,10 @@ def test_thread_type(self, value): def test_thread_type_invalid(self): self.form_data["thread_type"] = "invalid-option" - self.assert_error("thread_type", "Select a valid choice. invalid-option is not one of the available choices.") + self.assert_error( + "thread_type", + "Select a valid choice. invalid-option is not one of the available choices.", + ) @ddt.data("True", "true", 1, True) def test_flagged_true(self, value): @@ -133,7 +142,9 @@ def test_following_true(self, value): @ddt.data("False", "false", 0, False) def test_following_false(self, value): self.form_data["following"] = value - self.assert_error("following", "The value of the 'following' parameter must be true.") + self.assert_error( + "following", "The value of the 'following' parameter must be true." + ) def test_invalid_following(self): self.form_data["following"] = "invalid-boolean" @@ -144,25 +155,28 @@ def test_mutually_exclusive(self, params): self.form_data.update({param: "True" for param in params}) self.assert_error( "__all__", - "The following query parameters are mutually exclusive: topic_id, text_search, following" + "The following query parameters are mutually exclusive: topic_id, text_search, following", ) def test_invalid_view_choice(self): self.form_data["view"] = "not_a_valid_choice" - self.assert_error("view", "Select a valid choice. not_a_valid_choice is not one of the available choices.") + self.assert_error( + "view", + "Select a valid choice. not_a_valid_choice is not one of the available choices.", + ) def test_invalid_sort_by_choice(self): self.form_data["order_by"] = "not_a_valid_choice" self.assert_error( "order_by", - "Select a valid choice. not_a_valid_choice is not one of the available choices." + "Select a valid choice. not_a_valid_choice is not one of the available choices.", ) def test_invalid_sort_direction_choice(self): self.form_data["order_direction"] = "not_a_valid_choice" self.assert_error( "order_direction", - "Select a valid choice. not_a_valid_choice is not one of the available choices." + "Select a valid choice. not_a_valid_choice is not one of the available choices.", ) @ddt.data( @@ -181,12 +195,13 @@ def test_valid_choice_fields(self, field, value): def test_requested_fields(self): self.form_data["requested_fields"] = "profile_image" form = self.get_form(expected_valid=True) - assert form.cleaned_data['requested_fields'] == {'profile_image'} + assert form.cleaned_data["requested_fields"] == {"profile_image"} @ddt.ddt class CommentListGetFormTest(FormTestMixin, PaginationTestMixin, TestCase): """Tests for CommentListGetForm""" + FORM_CLASS = CommentListGetForm def setUp(self): @@ -202,13 +217,14 @@ def setUp(self): def test_basic(self): form = self.get_form(expected_valid=True) assert form.cleaned_data == { - 'thread_id': 'deadbeef', - 'endorsed': False, - 'page': 2, - 'page_size': 13, - 'flagged': False, - 'requested_fields': set(), - 'merge_question_type_responses': False + "thread_id": "deadbeef", + "endorsed": False, + "page": 2, + "page_size": 13, + "flagged": False, + "requested_fields": set(), + "merge_question_type_responses": False, + "show_deleted": None, } def test_missing_thread_id(self): @@ -236,12 +252,13 @@ def test_invalid_endorsed(self): def test_requested_fields(self): self.form_data["requested_fields"] = {"profile_image"} form = self.get_form(expected_valid=True) - assert form.cleaned_data['requested_fields'] == {'profile_image'} + assert form.cleaned_data["requested_fields"] == {"profile_image"} @ddt.ddt class UserCommentListGetFormTest(FormTestMixin, PaginationTestMixin, TestCase): """Tests for UserCommentListGetForm""" + FORM_CLASS = UserCommentListGetForm def setUp(self): @@ -256,11 +273,11 @@ def setUp(self): def test_basic(self): form = self.get_form(expected_valid=True) assert form.cleaned_data == { - 'course_id': CourseLocator.from_string('a/b/c'), - 'flagged': False, - 'page': 2, - 'page_size': 13, - 'requested_fields': set() + "course_id": CourseLocator.from_string("a/b/c"), + "flagged": False, + "page": 2, + "page_size": 13, + "requested_fields": set(), } def test_missing_flagged(self): @@ -280,7 +297,7 @@ def test_flagged_true(self, value): def test_requested_fields(self): self.form_data["requested_fields"] = {"profile_image"} form = self.get_form(expected_valid=True) - assert form.cleaned_data['requested_fields'] == {'profile_image'} + assert form.cleaned_data["requested_fields"] == {"profile_image"} def test_missing_course_id(self): self.form_data.pop("course_id") diff --git a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py index a1443252a1ce..10f9b7a64248 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py @@ -9,19 +9,17 @@ import httpretty from django.test.client import RequestFactory from django.test.utils import override_settings -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.util.testing import UrlResetMixin -from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin +from lms.djangoapps.discussion.django_comment_client.tests.utils import ( + ForumsEnableMixin, +) from lms.djangoapps.discussion.rest_api.serializers import ( CommentSerializer, ThreadSerializer, filter_spam_urls_from_html, - get_context + get_context, ) from lms.djangoapps.discussion.rest_api.tests.utils import ( CommentsServiceMockMixin, @@ -39,6 +37,10 @@ FORUM_ROLE_STUDENT, Role, ) +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory @ddt.ddt @@ -46,13 +48,18 @@ class SerializerTestMixin(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetM """ Test Mixin for Serializer tests """ + @classmethod - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) def setUpClass(cls): super().setUpClass() cls.course = CourseFactory.create() - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) def setUp(self): super().setUp() httpretty.reset() @@ -60,8 +67,8 @@ def setUp(self): self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False + "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", + return_value=False, ) patcher.start() self.addCleanup(patcher.stop) @@ -89,7 +96,9 @@ def create_role(self, role_name, users, course=None): (FORUM_ROLE_STUDENT, False, True, True), ) @ddt.unpack - def test_anonymity(self, role_name, anonymous, anonymous_to_peers, expected_serialized_anonymous): + def test_anonymity( + self, role_name, anonymous, anonymous_to_peers, expected_serialized_anonymous + ): """ Test that content is properly made anonymous. @@ -107,7 +116,9 @@ def test_anonymity(self, role_name, anonymous, anonymous_to_peers, expected_seri """ self.create_role(role_name, [self.user]) serialized = self.serialize( - self.make_cs_content({"anonymous": anonymous, "anonymous_to_peers": anonymous_to_peers}) + self.make_cs_content( + {"anonymous": anonymous, "anonymous_to_peers": anonymous_to_peers} + ) ) actual_serialized_anonymous = serialized["author"] is None assert actual_serialized_anonymous == expected_serialized_anonymous @@ -138,17 +149,19 @@ def test_author_labels(self, role_name, anonymous, expected_label): """ self.create_role(role_name, [self.author]) serialized = self.serialize(self.make_cs_content({"anonymous": anonymous})) - assert serialized['author_label'] == expected_label + assert serialized["author_label"] == expected_label def test_abuse_flagged(self): - serialized = self.serialize(self.make_cs_content({"abuse_flaggers": [str(self.user.id)]})) - assert serialized['abuse_flagged'] is True + serialized = self.serialize( + self.make_cs_content({"abuse_flaggers": [str(self.user.id)]}) + ) + assert serialized["abuse_flagged"] is True def test_voted(self): thread_id = "test_thread" self.register_get_user_response(self.user, upvoted_ids=[thread_id]) serialized = self.serialize(self.make_cs_content({"id": thread_id})) - assert serialized['voted'] is True + assert serialized["voted"] is True @ddt.ddt @@ -175,47 +188,61 @@ def serialize(self, thread): Create a serializer with an appropriate context and use it to serialize the given thread, returning the result. """ - return ThreadSerializer(thread, context=get_context(self.course, self.request)).data + return ThreadSerializer( + thread, context=get_context(self.course, self.request) + ).data def test_basic(self): - thread = make_minimal_cs_thread({ - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(self.author.id), - "username": self.author.username, - "title": "Test Title", - "body": "Test body", - "pinned": True, - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - }) - expected = self.expected_thread_data({ - "author": self.author.username, - "can_delete": False, - "vote_count": 4, - "comment_count": 6, - "unread_comment_count": 3, - "pinned": True, - "editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"], - "abuse_flagged_count": None, - "edit_by_label": None, - "closed_by_label": None, - }) + thread = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.author.id), + "username": self.author.username, + "title": "Test Title", + "body": "Test body", + "pinned": True, + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + } + ) + expected = self.expected_thread_data( + { + "author": self.author.username, + "can_delete": False, + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "pinned": True, + "editable_fields": [ + "abuse_flagged", + "copy_link", + "following", + "read", + "voted", + ], + "abuse_flagged_count": None, + "edit_by_label": None, + "closed_by_label": None, + } + ) assert self.serialize(thread) == expected thread["thread_type"] = "question" - expected.update({ - "type": "question", - "comment_list_url": None, - "endorsed_comment_list_url": ( - "http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=True" - ), - "non_endorsed_comment_list_url": ( - "http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=False" - ), - }) + expected.update( + { + "type": "question", + "comment_list_url": None, + "endorsed_comment_list_url": ( + "http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=True" + ), + "non_endorsed_comment_list_url": ( + "http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=False" + ), + } + ) assert self.serialize(thread) == expected def test_pinned_missing(self): @@ -227,34 +254,34 @@ def test_pinned_missing(self): del thread_data["pinned"] self.register_get_thread_response(thread_data) serialized = self.serialize(thread_data) - assert serialized['pinned'] is False + assert serialized["pinned"] is False def test_group(self): self.course.cohort_config = {"cohorted": True} modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) cohort = CohortFactory.create(course_id=self.course.id) serialized = self.serialize(self.make_cs_content({"group_id": cohort.id})) - assert serialized['group_id'] == cohort.id - assert serialized['group_name'] == cohort.name + assert serialized["group_id"] == cohort.id + assert serialized["group_name"] == cohort.name def test_following(self): thread_id = "test_thread" self.register_get_user_response(self.user, subscribed_thread_ids=[thread_id]) serialized = self.serialize(self.make_cs_content({"id": thread_id})) - assert serialized['following'] is True + assert serialized["following"] is True def test_response_count(self): thread_data = self.make_cs_content({"resp_total": 2}) self.register_get_thread_response(thread_data) serialized = self.serialize(thread_data) - assert serialized['response_count'] == 2 + assert serialized["response_count"] == 2 def test_response_count_missing(self): thread_data = self.make_cs_content({}) del thread_data["resp_total"] self.register_get_thread_response(thread_data) serialized = self.serialize(thread_data) - assert 'response_count' not in serialized + assert "response_count" not in serialized @ddt.data( (FORUM_ROLE_MODERATOR, True), @@ -272,43 +299,62 @@ def test_closed_by_label_field(self, role, visible): self.create_role(FORUM_ROLE_MODERATOR, [moderator]) self.create_role(request_role, [self.user]) - thread = make_minimal_cs_thread({ - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(author.id), - "username": author.username, - "title": "Test Title", - "body": "Test body", - "pinned": True, - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - "closed_by": moderator - }) + thread = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(author.id), + "username": author.username, + "title": "Test Title", + "body": "Test body", + "pinned": True, + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + "closed_by": moderator, + } + ) closed_by_label = "Moderator" if visible else None closed_by = moderator if visible else None can_delete = role != FORUM_ROLE_STUDENT editable_fields = ["abuse_flagged", "copy_link", "following", "read", "voted"] if role == "author": editable_fields.remove("voted") - editable_fields.extend(['anonymous', 'raw_body', 'title', 'topic_id', 'type']) + editable_fields.extend( + ["anonymous", "raw_body", "title", "topic_id", "type"] + ) elif role == FORUM_ROLE_MODERATOR: - editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned', - 'raw_body', 'title', 'topic_id', 'type']) - expected = self.expected_thread_data({ - "author": author.username, - "can_delete": can_delete, - "vote_count": 4, - "comment_count": 6, - "unread_comment_count": 3, - "pinned": True, - "editable_fields": sorted(editable_fields), - "abuse_flagged_count": None, - "edit_by_label": None, - "closed_by_label": closed_by_label, - "closed_by": closed_by, - }) + editable_fields.extend( + [ + "close_reason_code", + "closed", + "edit_reason_code", + "pinned", + "raw_body", + "title", + "topic_id", + "type", + ] + ) + # is_deleted is visible (False) for privileged users, hidden (None) for others + is_deleted = False if role == FORUM_ROLE_MODERATOR else None + expected = self.expected_thread_data( + { + "author": author.username, + "can_delete": can_delete, + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "pinned": True, + "editable_fields": sorted(editable_fields), + "abuse_flagged_count": None, + "edit_by_label": None, + "closed_by_label": closed_by_label, + "closed_by": closed_by, + "is_deleted": is_deleted, + } + ) assert self.serialize(thread) == expected @ddt.data( @@ -327,48 +373,69 @@ def test_edit_by_label_field(self, role, visible): self.create_role(FORUM_ROLE_MODERATOR, [moderator]) self.create_role(request_role, [self.user]) - thread = make_minimal_cs_thread({ - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(author.id), - "username": author.username, - "title": "Test Title", - "body": "Test body", - "pinned": True, - "votes": {"up_count": 4}, - "edit_history": [{"editor_username": moderator}], - "comments_count": 5, - "unread_comments_count": 3, - "closed_by": None - }) + thread = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(author.id), + "username": author.username, + "title": "Test Title", + "body": "Test body", + "pinned": True, + "votes": {"up_count": 4}, + "edit_history": [{"editor_username": moderator}], + "comments_count": 5, + "unread_comments_count": 3, + "closed_by": None, + } + ) edit_by_label = "Moderator" if visible else None can_delete = role != FORUM_ROLE_STUDENT - last_edit = None if role == FORUM_ROLE_STUDENT else {"editor_username": moderator} + last_edit = ( + None if role == FORUM_ROLE_STUDENT else {"editor_username": moderator} + ) editable_fields = ["abuse_flagged", "copy_link", "following", "read", "voted"] if role == "author": editable_fields.remove("voted") - editable_fields.extend(['anonymous', 'raw_body', 'title', 'topic_id', 'type']) + editable_fields.extend( + ["anonymous", "raw_body", "title", "topic_id", "type"] + ) elif role == FORUM_ROLE_MODERATOR: - editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned', - 'raw_body', 'title', 'topic_id', 'type']) + editable_fields.extend( + [ + "close_reason_code", + "closed", + "edit_reason_code", + "pinned", + "raw_body", + "title", + "topic_id", + "type", + ] + ) - expected = self.expected_thread_data({ - "author": author.username, - "can_delete": can_delete, - "vote_count": 4, - "comment_count": 6, - "unread_comment_count": 3, - "pinned": True, - "editable_fields": sorted(editable_fields), - "abuse_flagged_count": None, - "last_edit": last_edit, - "edit_by_label": edit_by_label, - "closed_by_label": None, - "closed_by": None, - }) + # is_deleted is visible (False) for privileged users, hidden (None) for others + is_deleted = False if role == FORUM_ROLE_MODERATOR else None + expected = self.expected_thread_data( + { + "author": author.username, + "can_delete": can_delete, + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "pinned": True, + "editable_fields": sorted(editable_fields), + "abuse_flagged_count": None, + "last_edit": last_edit, + "edit_by_label": edit_by_label, + "closed_by_label": None, + "closed_by": None, + "is_deleted": is_deleted, + } + ) assert self.serialize(thread) == expected def test_get_preview_body(self): @@ -384,7 +451,10 @@ def test_get_preview_body(self): {"body": "

This is a test thread body with some text.

"} ) serialized = self.serialize(thread_data) - assert serialized['preview_body'] == "This is a test thread body with some text." + assert ( + serialized["preview_body"] + == "This is a test thread body with some text." + ) @ddt.ddt @@ -402,12 +472,12 @@ def make_cs_content(self, overrides=None, with_endorsement=False): """ merged_overrides = { "user_id": str(self.author.id), - "username": self.author.username + "username": self.author.username, } if with_endorsement: merged_overrides["endorsement"] = { "user_id": str(self.endorser.id), - "time": self.endorsed_at + "time": self.endorsed_at, } merged_overrides.update(overrides or {}) return make_minimal_cs_comment(merged_overrides) @@ -417,7 +487,9 @@ def serialize(self, comment, thread_data=None): Create a serializer with an appropriate context and use it to serialize the given comment, returning the result. """ - context = get_context(self.course, self.request, make_minimal_cs_thread(thread_data)) + context = get_context( + self.course, self.request, make_minimal_cs_thread(thread_data) + ) return CommentSerializer(comment, context=context).data def test_basic(self): @@ -472,6 +544,10 @@ def test_basic(self): "image_url_medium": "http://testserver/static/default_50.png", "image_url_small": "http://testserver/static/default_30.png", }, + "is_deleted": None, + "deleted_at": None, + "deleted_by": None, + "deleted_by_label": None, } assert self.serialize(comment) == expected @@ -484,7 +560,7 @@ def test_basic(self): FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_STUDENT, ], - [True, False] + [True, False], ) ) @ddt.unpack @@ -501,10 +577,12 @@ def test_endorsed_by(self, endorser_role_name, thread_anonymous): self.create_role(endorser_role_name, [self.endorser]) serialized = self.serialize( self.make_cs_content(with_endorsement=True), - thread_data={"anonymous": thread_anonymous} + thread_data={"anonymous": thread_anonymous}, ) actual_endorser_anonymous = serialized["endorsed_by"] is None - expected_endorser_anonymous = endorser_role_name == FORUM_ROLE_STUDENT and thread_anonymous + expected_endorser_anonymous = ( + endorser_role_name == FORUM_ROLE_STUDENT and thread_anonymous + ) assert actual_endorser_anonymous == expected_endorser_anonymous @ddt.data( @@ -527,56 +605,69 @@ def test_endorsed_by_labels(self, role_name, expected_label): """ self.create_role(role_name, [self.endorser]) serialized = self.serialize(self.make_cs_content(with_endorsement=True)) - assert serialized['endorsed_by_label'] == expected_label + assert serialized["endorsed_by_label"] == expected_label def test_endorsed_at(self): serialized = self.serialize(self.make_cs_content(with_endorsement=True)) - assert serialized['endorsed_at'] == self.endorsed_at + assert serialized["endorsed_at"] == self.endorsed_at def test_children(self): - comment = self.make_cs_content({ - "id": "test_root", - "children": [ - self.make_cs_content({ - "id": "test_child_1", - "parent_id": "test_root", - }), - self.make_cs_content({ - "id": "test_child_2", - "parent_id": "test_root", - "children": [ - self.make_cs_content({ - "id": "test_grandchild", - "parent_id": "test_child_2" - }) - ], - }), - ], - }) + comment = self.make_cs_content( + { + "id": "test_root", + "children": [ + self.make_cs_content( + { + "id": "test_child_1", + "parent_id": "test_root", + } + ), + self.make_cs_content( + { + "id": "test_child_2", + "parent_id": "test_root", + "children": [ + self.make_cs_content( + { + "id": "test_grandchild", + "parent_id": "test_child_2", + } + ) + ], + } + ), + ], + } + ) serialized = self.serialize(comment) - assert serialized['children'][0]['id'] == 'test_child_1' - assert serialized['children'][0]['parent_id'] == 'test_root' - assert serialized['children'][1]['id'] == 'test_child_2' - assert serialized['children'][1]['parent_id'] == 'test_root' - assert serialized['children'][1]['children'][0]['id'] == 'test_grandchild' - assert serialized['children'][1]['children'][0]['parent_id'] == 'test_child_2' + assert serialized["children"][0]["id"] == "test_child_1" + assert serialized["children"][0]["parent_id"] == "test_root" + assert serialized["children"][1]["id"] == "test_child_2" + assert serialized["children"][1]["parent_id"] == "test_root" + assert serialized["children"][1]["children"][0]["id"] == "test_grandchild" + assert serialized["children"][1]["children"][0]["parent_id"] == "test_child_2" @ddt.ddt class ThreadSerializerDeserializationTest( - ForumsEnableMixin, - CommentsServiceMockMixin, - UrlResetMixin, - SharedModuleStoreTestCase + ForumsEnableMixin, + CommentsServiceMockMixin, + UrlResetMixin, + SharedModuleStoreTestCase, ): """Tests for ThreadSerializer deserialization.""" + @classmethod - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) def setUpClass(cls): super().setUpClass() cls.course = CourseFactory.create() - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) def setUp(self): super().setUp() httpretty.reset() @@ -584,8 +675,8 @@ def setUp(self): self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False + "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", + return_value=False, ) patcher.start() self.addCleanup(patcher.stop) @@ -600,18 +691,22 @@ def setUp(self): "title": "Test Title", "raw_body": "Test body", } - self.existing_thread = Thread(**make_minimal_cs_thread({ - "id": "existing_thread", - "course_id": str(self.course.id), - "commentable_id": "original_topic", - "thread_type": "discussion", - "title": "Original Title", - "body": "Original body", - "user_id": str(self.user.id), - "username": self.user.username, - "read": "False", - "endorsed": "False" - })) + self.existing_thread = Thread( + **make_minimal_cs_thread( + { + "id": "existing_thread", + "course_id": str(self.course.id), + "commentable_id": "original_topic", + "thread_type": "discussion", + "title": "Original Title", + "body": "Original body", + "user_id": str(self.user.id), + "username": self.user.username, + "read": "False", + "endorsed": "False", + } + ) + ) def save_and_reserialize(self, data, instance=None): """ @@ -623,7 +718,7 @@ def save_and_reserialize(self, data, instance=None): instance, data=data, partial=(instance is not None), - context=get_context(self.course, self.request) + context=get_context(self.course, self.request), ) assert serializer.is_valid() serializer.save() @@ -635,33 +730,36 @@ def test_create_missing_field(self): data.pop(field) serializer = ThreadSerializer(data=data) assert not serializer.is_valid() - assert serializer.errors == {field: ['This field is required.']} + assert serializer.errors == {field: ["This field is required."]} @ddt.data("", " ") def test_create_empty_string(self, value): data = self.minimal_data.copy() data.update({field: value for field in ["topic_id", "title", "raw_body"]}) - serializer = ThreadSerializer(data=data, context=get_context(self.course, self.request)) + serializer = ThreadSerializer( + data=data, context=get_context(self.course, self.request) + ) assert not serializer.is_valid() assert serializer.errors == { - field: ['This field may not be blank.'] for field in ['topic_id', 'title', 'raw_body'] + field: ["This field may not be blank."] + for field in ["topic_id", "title", "raw_body"] } def test_update_empty(self): self.register_put_thread_response(self.existing_thread.attributes) self.save_and_reserialize({}, self.existing_thread) assert parsed_body(httpretty.last_request()) == { - 'course_id': [str(self.course.id)], - 'commentable_id': ['original_topic'], - 'thread_type': ['discussion'], - 'title': ['Original Title'], - 'body': ['Original body'], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - 'closed': ['False'], - 'pinned': ['False'], - 'user_id': [str(self.user.id)], - 'read': ['False'] + "course_id": [str(self.course.id)], + "commentable_id": ["original_topic"], + "thread_type": ["discussion"], + "title": ["Original Title"], + "body": ["Original body"], + "anonymous": ["False"], + "anonymous_to_peers": ["False"], + "closed": ["False"], + "pinned": ["False"], + "user_id": [str(self.user.id)], + "read": ["False"], } @ddt.data(True, False) @@ -676,18 +774,18 @@ def test_update_all(self, read): } saved = self.save_and_reserialize(data, self.existing_thread) assert parsed_body(httpretty.last_request()) == { - 'course_id': [str(self.course.id)], - 'commentable_id': ['edited_topic'], - 'thread_type': ['question'], - 'title': ['Edited Title'], - 'body': ['Edited body'], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - 'closed': ['False'], - 'pinned': ['False'], - 'user_id': [str(self.user.id)], - 'read': [str(read)], - 'editing_user_id': [str(self.user.id)], + "course_id": [str(self.course.id)], + "commentable_id": ["edited_topic"], + "thread_type": ["question"], + "title": ["Edited Title"], + "body": ["Edited body"], + "anonymous": ["False"], + "anonymous_to_peers": ["False"], + "closed": ["False"], + "pinned": ["False"], + "user_id": [str(self.user.id)], + "read": [str(read)], + "editing_user_id": [str(self.user.id)], } for key in data: assert saved[key] == data[key] @@ -702,7 +800,7 @@ def test_update_anonymous(self): "anonymous": True, } self.save_and_reserialize(data, self.existing_thread) - assert parsed_body(httpretty.last_request())["anonymous"] == ['True'] + assert parsed_body(httpretty.last_request())["anonymous"] == ["True"] def test_update_anonymous_to_peers(self): """ @@ -714,7 +812,7 @@ def test_update_anonymous_to_peers(self): "anonymous_to_peers": True, } self.save_and_reserialize(data, self.existing_thread) - assert parsed_body(httpretty.last_request())["anonymous_to_peers"] == ['True'] + assert parsed_body(httpretty.last_request())["anonymous_to_peers"] == ["True"] @ddt.data("", " ") def test_update_empty_string(self, value): @@ -722,11 +820,12 @@ def test_update_empty_string(self, value): self.existing_thread, data={field: value for field in ["topic_id", "title", "raw_body"]}, partial=True, - context=get_context(self.course, self.request) + context=get_context(self.course, self.request), ) assert not serializer.is_valid() assert serializer.errors == { - field: ['This field may not be blank.'] for field in ['topic_id', 'title', 'raw_body'] + field: ["This field may not be blank."] + for field in ["topic_id", "title", "raw_body"] } def test_update_course_id(self): @@ -734,15 +833,20 @@ def test_update_course_id(self): self.existing_thread, data={"course_id": "some/other/course"}, partial=True, - context=get_context(self.course, self.request) + context=get_context(self.course, self.request), ) assert not serializer.is_valid() - assert serializer.errors == {'course_id': ['This field is not allowed in an update.']} + assert serializer.errors == { + "course_id": ["This field is not allowed in an update."] + } @ddt.ddt -class CommentSerializerDeserializationTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedModuleStoreTestCase): +class CommentSerializerDeserializationTest( + ForumsEnableMixin, CommentsServiceMockMixin, SharedModuleStoreTestCase +): """Tests for ThreadSerializer deserialization.""" + @classmethod def setUpClass(cls): super().setUpClass() @@ -755,8 +859,8 @@ def setUp(self): self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False + "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", + return_value=False, ) patcher.start() self.addCleanup(patcher.stop) @@ -778,14 +882,18 @@ def setUp(self): "thread_id": "test_thread", "raw_body": "Test body", } - self.existing_comment = Comment(**make_minimal_cs_comment({ - "id": "existing_comment", - "thread_id": "dummy", - "body": "Original body", - "user_id": str(self.user.id), - "username": self.user.username, - "course_id": str(self.course.id), - })) + self.existing_comment = Comment( + **make_minimal_cs_comment( + { + "id": "existing_comment", + "thread_id": "dummy", + "body": "Original body", + "user_id": str(self.user.id), + "username": self.user.username, + "course_id": str(self.course.id), + } + ) + ) def save_and_reserialize(self, data, instance=None): """ @@ -795,13 +903,10 @@ def save_and_reserialize(self, data, instance=None): context = get_context( self.course, self.request, - make_minimal_cs_thread({"course_id": str(self.course.id)}) + make_minimal_cs_thread({"course_id": str(self.course.id)}), ) serializer = CommentSerializer( - instance, - data=data, - partial=(instance is not None), - context=context + instance, data=data, partial=(instance is not None), context=context ) assert serializer.is_valid() serializer.save() @@ -813,21 +918,23 @@ def test_create_missing_field(self): data.pop(field) serializer = CommentSerializer( data=data, - context=get_context(self.course, self.request, make_minimal_cs_thread()) + context=get_context( + self.course, self.request, make_minimal_cs_thread() + ), ) assert not serializer.is_valid() - assert serializer.errors == {field: ['This field is required.']} + assert serializer.errors == {field: ["This field is required."]} def test_update_empty(self): self.register_put_comment_response(self.existing_comment.attributes) self.save_and_reserialize({}, instance=self.existing_comment) assert parsed_body(httpretty.last_request()) == { - 'body': ['Original body'], - 'course_id': [str(self.course.id)], - 'user_id': [str(self.user.id)], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - 'endorsed': ['False'] + "body": ["Original body"], + "course_id": [str(self.course.id)], + "user_id": [str(self.user.id)], + "anonymous": ["False"], + "anonymous_to_peers": ["False"], + "endorsed": ["False"], } def test_update_anonymous(self): @@ -840,7 +947,7 @@ def test_update_anonymous(self): "anonymous": True, } self.save_and_reserialize(data, self.existing_comment) - assert parsed_body(httpretty.last_request())["anonymous"] == ['True'] + assert parsed_body(httpretty.last_request())["anonymous"] == ["True"] def test_update_anonymous_to_peers(self): """ @@ -852,7 +959,7 @@ def test_update_anonymous_to_peers(self): "anonymous_to_peers": True, } self.save_and_reserialize(data, self.existing_comment) - assert parsed_body(httpretty.last_request())["anonymous_to_peers"] == ['True'] + assert parsed_body(httpretty.last_request())["anonymous_to_peers"] == ["True"] @ddt.data("thread_id", "parent_id") def test_update_non_updatable(self, field): @@ -860,23 +967,26 @@ def test_update_non_updatable(self, field): self.existing_comment, data={field: "different_value"}, partial=True, - context=get_context(self.course, self.request) + context=get_context(self.course, self.request), ) assert not serializer.is_valid() - assert serializer.errors == {field: ['This field is not allowed in an update.']} + assert serializer.errors == {field: ["This field is not allowed in an update."]} class FilterSpamTest(SharedModuleStoreTestCase): """ Tests for the filter_spam method """ - @override_settings(DISCUSSION_SPAM_URLS=['example.com']) + + @override_settings(DISCUSSION_SPAM_URLS=["example.com"]) def test_filter(self): self.assertEqual( - filter_spam_urls_from_html('')[0], - '
abc
' + filter_spam_urls_from_html( + '' + )[0], + "
abc
", ) self.assertEqual( - filter_spam_urls_from_html('
example.com/abc/def
')[0], - '
' + filter_spam_urls_from_html("
example.com/abc/def
")[0], + "
", ) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index e4d46168c46d..8a9405076c2d 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -2,7 +2,6 @@ Tests for Discussion API views """ - import json import random from datetime import datetime @@ -20,22 +19,22 @@ from rest_framework import status from rest_framework.test import APIClient, APITestCase -from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE -from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, check_mongo_calls - from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory -from common.djangoapps.student.models import get_retired_username_by_username, CourseEnrollment -from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff +from common.djangoapps.student.models import ( + CourseEnrollment, + get_retired_username_by_username, +) +from common.djangoapps.student.roles import ( + CourseInstructorRole, + CourseStaffRole, + GlobalStaff, +) from common.djangoapps.student.tests.factories import ( AdminFactory, CourseEnrollmentFactory, SuperuserFactory, - UserFactory + UserFactory, ) from common.djangoapps.util.testing import UrlResetMixin from lms.djangoapps.discussion.django_comment_client.tests.utils import ( @@ -48,21 +47,50 @@ make_minimal_cs_comment, make_minimal_cs_thread, ) +from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string +from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts -from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS -from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, DiscussionTopicLink, Provider -from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task +from openedx.core.djangoapps.discussions.config.waffle import ( + ENABLE_NEW_STRUCTURE_DISCUSSIONS, +) +from openedx.core.djangoapps.discussions.models import ( + DiscussionsConfiguration, + DiscussionTopicLink, + Provider, +) +from openedx.core.djangoapps.discussions.tasks import ( + update_discussions_settings_from_course_task, +) from openedx.core.djangoapps.django_comment_common.models import ( CourseDiscussionSettings, Role, ) from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user -from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory -from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus +from openedx.core.djangoapps.oauth_dispatch.tests.factories import ( + AccessTokenFactory, + ApplicationFactory, +) +from openedx.core.djangoapps.user_api.models import ( + RetirementState, + UserRetirementStatus, +) +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, + SharedModuleStoreTestCase, +) +from xmodule.modulestore.tests.factories import ( + BlockFactory, + CourseFactory, + check_mongo_calls, +) -class DiscussionAPIViewTestMixin(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin): +class DiscussionAPIViewTestMixin( + ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin +): """ Mixin for common code in tests of Discussion API views. This includes creation of common structures (e.g. a course, user, and enrollment), logging @@ -72,7 +100,9 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, CommentsServiceMockMixin, Ur client_class = APIClient - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) def setUp(self): super().setUp() self.maxDiff = None # pylint: disable=invalid-name @@ -81,7 +111,7 @@ def setUp(self): course="y", run="z", start=datetime.now(UTC), - discussion_topics={"Test Topic": {"id": "test_topic"}} + discussion_topics={"Test Topic": {"id": "test_topic"}}, ) self.password = "Password1234" self.user = UserFactory.create(password=self.password) @@ -96,23 +126,25 @@ def assert_response_correct(self, response, expected_status, expected_content): Assert that the response has the given status code and parsed content """ assert response.status_code == expected_status - parsed_content = json.loads(response.content.decode('utf-8')) + parsed_content = json.loads(response.content.decode("utf-8")) assert parsed_content == expected_content def register_thread(self, overrides=None): """ Create cs_thread with minimal fields and register response """ - cs_thread = make_minimal_cs_thread({ - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "username": self.user.username, - "user_id": str(self.user.id), - "thread_type": "discussion", - "title": "Test Title", - "body": "Test body", - }) + cs_thread = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "username": self.user.username, + "user_id": str(self.user.id), + "thread_type": "discussion", + "title": "Test Title", + "body": "Test body", + } + ) cs_thread.update(overrides or {}) self.register_get_thread_response(cs_thread) self.register_put_thread_response(cs_thread) @@ -121,14 +153,16 @@ def register_comment(self, overrides=None): """ Create cs_comment with minimal fields and register response """ - cs_comment = make_minimal_cs_comment({ - "id": "test_comment", - "course_id": str(self.course.id), - "thread_id": "test_thread", - "username": self.user.username, - "user_id": str(self.user.id), - "body": "Original body", - }) + cs_comment = make_minimal_cs_comment( + { + "id": "test_comment", + "course_id": str(self.course.id), + "thread_id": "test_thread", + "username": self.user.username, + "user_id": str(self.user.id), + "body": "Original body", + } + ) cs_comment.update(overrides or {}) self.register_get_comment_response(cs_comment) self.register_put_comment_response(cs_comment) @@ -140,7 +174,7 @@ def test_not_authenticated(self): self.assert_response_correct( response, 401, - {"developer_message": "Authentication credentials were not provided."} + {"developer_message": "Authentication credentials were not provided."}, ) def test_inactive(self): @@ -149,12 +183,16 @@ def test_inactive(self): @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class UploadFileViewTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestCase): +class UploadFileViewTest( + ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestCase +): """ Tests for UploadFileView. """ - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) def setUp(self): super().setUp() self.valid_file = { @@ -165,11 +203,13 @@ def setUp(self): ), } self.user = UserFactory.create(password=self.TEST_PASSWORD) - self.course = CourseFactory.create(org='a', course='b', run='c', start=datetime.now(UTC)) + self.course = CourseFactory.create( + org="a", course="b", run="c", start=datetime.now(UTC) + ) self.url = reverse("upload_file", kwargs={"course_id": str(self.course.id)}) patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False + "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", + return_value=False, ) patcher.start() self.addCleanup(patcher.stop) @@ -257,10 +297,13 @@ def test_file_upload_with_thread_key(self): """ self.user_login() self.enroll_user_in_course() - response = self.client.post(self.url, { - **self.valid_file, - "thread_key": "somethread", - }) + response = self.client.post( + self.url, + { + **self.valid_file, + "thread_key": "somethread", + }, + ) response_data = json.loads(response.content) assert "/somethread/" in response_data["location"] @@ -314,7 +357,9 @@ class CommentViewSetListByUserTest( Common test cases for views retrieving user-published content. """ - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) def setUp(self): super().setUp() @@ -323,8 +368,8 @@ def setUp(self): self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False + "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", + return_value=False, ) patcher.start() self.addCleanup(patcher.stop) @@ -335,7 +380,9 @@ def setUp(self): self.other_user = UserFactory.create(password=self.TEST_PASSWORD) self.register_get_user_response(self.other_user) - self.course = CourseFactory.create(org="a", course="b", run="c", start=datetime.now(UTC)) + self.course = CourseFactory.create( + org="a", course="b", run="c", start=datetime.now(UTC) + ) CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) self.url = self.build_url(self.user.username, self.course.id) @@ -346,16 +393,18 @@ def register_mock_endpoints(self): """ self.register_get_threads_response( threads=[ - make_minimal_cs_thread({ - "id": f"test_thread_{index}", - "course_id": str(self.course.id), - "commentable_id": f"test_topic_{index}", - "username": self.user.username, - "user_id": str(self.user.id), - "thread_type": "discussion", - "title": f"Test Title #{index}", - "body": f"Test body #{index}", - }) + make_minimal_cs_thread( + { + "id": f"test_thread_{index}", + "course_id": str(self.course.id), + "commentable_id": f"test_topic_{index}", + "username": self.user.username, + "user_id": str(self.user.id), + "thread_type": "discussion", + "title": f"Test Title #{index}", + "body": f"Test body #{index}", + } + ) for index in range(30) ], page=1, @@ -363,16 +412,18 @@ def register_mock_endpoints(self): ) self.register_get_comments_response( comments=[ - make_minimal_cs_comment({ - "id": f"test_comment_{index}", - "thread_id": "test_thread", - "user_id": str(self.user.id), - "username": self.user.username, - "created_at": "2015-05-11T00:00:00Z", - "updated_at": "2015-05-11T11:11:11Z", - "body": f"Test body #{index}", - "votes": {"up_count": 4}, - }) + make_minimal_cs_comment( + { + "id": f"test_comment_{index}", + "thread_id": "test_thread", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-05-11T00:00:00Z", + "updated_at": "2015-05-11T11:11:11Z", + "body": f"Test body #{index}", + "votes": {"up_count": 4}, + } + ) for index in range(30) ], page=1, @@ -384,11 +435,13 @@ def build_url(self, username, course_id, **kwargs): Builds an URL to access content from an user on a specific course. """ base = reverse("comment-list") - query = urlencode({ - "username": username, - "course_id": str(course_id), - **kwargs, - }) + query = urlencode( + { + "username": username, + "course_id": str(course_id), + **kwargs, + } + ) return f"{base}?{query}" def assert_successful_response(self, response): @@ -414,7 +467,9 @@ def test_request_by_unauthorized_user(self): they're not either enrolled or staff members. """ self.register_mock_endpoints() - self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) + self.client.login( + username=self.other_user.username, password=self.TEST_PASSWORD + ) response = self.client.get(self.url) assert response.status_code == status.HTTP_404_NOT_FOUND assert json.loads(response.content)["developer_message"] == "Course not found." @@ -425,7 +480,9 @@ def test_request_by_enrolled_user(self): comments in that course. """ self.register_mock_endpoints() - self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) + self.client.login( + username=self.other_user.username, password=self.TEST_PASSWORD + ) CourseEnrollmentFactory.create(user=self.other_user, course_id=self.course.id) self.assert_successful_response(self.client.get(self.url)) @@ -434,7 +491,9 @@ def test_request_by_global_staff(self): Staff users are allowed to get any user's comments. """ self.register_mock_endpoints() - self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) + self.client.login( + username=self.other_user.username, password=self.TEST_PASSWORD + ) GlobalStaff().add_users(self.other_user) self.assert_successful_response(self.client.get(self.url)) @@ -445,7 +504,9 @@ def test_request_by_course_staff(self, role): course. """ self.register_mock_endpoints() - self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) + self.client.login( + username=self.other_user.username, password=self.TEST_PASSWORD + ) role(course_key=self.course.id).add_users(self.other_user) self.assert_successful_response(self.client.get(self.url)) @@ -454,7 +515,9 @@ def test_request_with_non_existent_user(self): Requests for users that don't exist result in a 404 response. """ self.register_mock_endpoints() - self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) + self.client.login( + username=self.other_user.username, password=self.TEST_PASSWORD + ) GlobalStaff().add_users(self.other_user) url = self.build_url("non_existent", self.course.id) response = self.client.get(url) @@ -465,7 +528,9 @@ def test_request_with_non_existent_course(self): Requests for courses that don't exist result in a 404 response. """ self.register_mock_endpoints() - self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) + self.client.login( + username=self.other_user.username, password=self.TEST_PASSWORD + ) GlobalStaff().add_users(self.other_user) url = self.build_url(self.user.username, "course-v1:x+y+z") response = self.client.get(url) @@ -476,14 +541,18 @@ def test_request_with_invalid_course_id(self): Requests with invalid course ID should fail form validation. """ self.register_mock_endpoints() - self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) + self.client.login( + username=self.other_user.username, password=self.TEST_PASSWORD + ) GlobalStaff().add_users(self.other_user) url = self.build_url(self.user.username, "an invalid course") response = self.client.get(url) assert response.status_code == status.HTTP_400_BAD_REQUEST parsed_response = json.loads(response.content) - assert parsed_response["field_errors"]["course_id"]["developer_message"] == \ - "'an invalid course' is not a valid course id" + assert ( + parsed_response["field_errors"]["course_id"]["developer_message"] + == "'an invalid course' is not a valid course id" + ) def test_request_with_empty_results_page(self): """ @@ -493,7 +562,9 @@ def test_request_with_empty_results_page(self): self.register_get_threads_response(threads=[], page=1, num_pages=1) self.register_get_comments_response(comments=[], page=1, num_pages=1) - self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) + self.client.login( + username=self.other_user.username, password=self.TEST_PASSWORD + ) GlobalStaff().add_users(self.other_user) url = self.build_url(self.user.username, self.course.id, page=2) response = self.client.get(url) @@ -501,17 +572,23 @@ def test_request_with_empty_results_page(self): @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -@override_settings(DISCUSSION_MODERATION_EDIT_REASON_CODES={"test-edit-reason": "Test Edit Reason"}) -@override_settings(DISCUSSION_MODERATION_CLOSE_REASON_CODES={"test-close-reason": "Test Close Reason"}) +@override_settings( + DISCUSSION_MODERATION_EDIT_REASON_CODES={"test-edit-reason": "Test Edit Reason"} +) +@override_settings( + DISCUSSION_MODERATION_CLOSE_REASON_CODES={"test-close-reason": "Test Close Reason"} +) class CourseViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): """Tests for CourseView""" def setUp(self): super().setUp() - self.url = reverse("discussion_course", kwargs={"course_id": str(self.course.id)}) + self.url = reverse( + "discussion_course", kwargs={"course_id": str(self.course.id)} + ) patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False + "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", + return_value=False, ) patcher.start() self.addCleanup(patcher.stop) @@ -521,9 +598,7 @@ def test_404(self): reverse("course_topics", kwargs={"course_id": "non/existent/course"}) ) self.assert_response_correct( - response, - 404, - {"developer_message": "Course not found."} + response, 404, {"developer_message": "Course not found."} ) def test_basic(self): @@ -547,23 +622,27 @@ def test_basic(self): "allow_anonymous_to_peers": False, "has_bulk_delete_privileges": False, "has_moderation_privileges": False, - 'is_course_admin': False, - 'is_course_staff': False, + "is_course_admin": False, + "is_course_staff": False, "is_group_ta": False, - 'is_user_admin': False, + "is_user_admin": False, "user_roles": ["Student"], - "edit_reasons": [{"code": "test-edit-reason", "label": "Test Edit Reason"}], - "post_close_reasons": [{"code": "test-close-reason", "label": "Test Close Reason"}], - 'show_discussions': True, - 'is_notify_all_learners_enabled': False, - 'captcha_settings': { - 'enabled': False, - 'site_key': None, + "edit_reasons": [ + {"code": "test-edit-reason", "label": "Test Edit Reason"} + ], + "post_close_reasons": [ + {"code": "test-close-reason", "label": "Test Close Reason"} + ], + "show_discussions": True, + "is_notify_all_learners_enabled": False, + "captcha_settings": { + "enabled": False, + "site_key": None, }, "is_email_verified": True, "only_verified_users_can_post": False, - "content_creation_rate_limited": False - } + "content_creation_rate_limited": False, + }, ) @@ -574,8 +653,10 @@ class RetireViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): def setUp(self): super().setUp() - RetirementState.objects.create(state_name='PENDING', state_execution_order=1) - self.retire_forums_state = RetirementState.objects.create(state_name='RETIRE_FORUMS', state_execution_order=11) + RetirementState.objects.create(state_name="PENDING", state_execution_order=1) + self.retire_forums_state = RetirementState.objects.create( + state_name="RETIRE_FORUMS", state_execution_order=11 + ) self.retirement = UserRetirementStatus.create_retirement(self.user) self.retirement.current_state = self.retire_forums_state @@ -586,8 +667,8 @@ def setUp(self): self.retired_username = get_retired_username_by_username(self.user.username) self.url = reverse("retire_discussion_user") patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False + "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", + return_value=False, ) patcher.start() self.addCleanup(patcher.stop) @@ -599,14 +680,14 @@ def assert_response_correct(self, response, expected_status, expected_content): assert response.status_code == expected_status if expected_content: - assert response.content.decode('utf-8') == expected_content + assert response.content.decode("utf-8") == expected_content def build_jwt_headers(self, user): """ Helper function for creating headers for the JWT authentication. """ token = create_jwt_for_user(user) - headers = {'HTTP_AUTHORIZATION': 'JWT ' + token} + headers = {"HTTP_AUTHORIZATION": "JWT " + token} return headers def test_basic(self): @@ -615,7 +696,7 @@ def test_basic(self): """ self.register_get_user_retire_response(self.user) headers = self.build_jwt_headers(self.superuser) - data = {'username': self.user.username} + data = {"username": self.user.username} response = self.superuser_client.post(self.url, data, **headers) self.assert_response_correct(response, 204, b"") @@ -623,9 +704,11 @@ def test_downstream_forums_error(self): """ Check that we bubble up errors from the comments service """ - self.register_get_user_retire_response(self.user, status=500, body="Server error") + self.register_get_user_retire_response( + self.user, status=500, body="Server error" + ) headers = self.build_jwt_headers(self.superuser) - data = {'username': self.user.username} + data = {"username": self.user.username} response = self.superuser_client.post(self.url, data, **headers) self.assert_response_correct(response, 500, '"Server error"') @@ -635,7 +718,7 @@ def test_nonexistent_user(self): """ nonexistent_username = "nonexistent user" self.retired_username = get_retired_username_by_username(nonexistent_username) - data = {'username': nonexistent_username} + data = {"username": nonexistent_username} headers = self.build_jwt_headers(self.superuser) response = self.superuser_client.post(self.url, data, **headers) self.assert_response_correct(response, 404, None) @@ -649,7 +732,10 @@ def test_not_authenticated(self): @ddt.ddt @httpretty.activate -@mock.patch('django.conf.settings.USERNAME_REPLACEMENT_WORKER', 'test_replace_username_service_worker') +@mock.patch( + "django.conf.settings.USERNAME_REPLACEMENT_WORKER", + "test_replace_username_service_worker", +) @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) class ReplaceUsernamesViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): """Tests for ReplaceUsernamesView""" @@ -662,8 +748,8 @@ def setUp(self): self.new_username = "test_username_replacement" self.url = reverse("replace_discussion_username") patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False + "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", + return_value=False, ) patcher.start() self.addCleanup(patcher.stop) @@ -682,34 +768,28 @@ def build_jwt_headers(self, user): Helper function for creating headers for the JWT authentication. """ token = create_jwt_for_user(user) - headers = {'HTTP_AUTHORIZATION': 'JWT ' + token} + headers = {"HTTP_AUTHORIZATION": "JWT " + token} return headers def call_api(self, user, client, data): - """ Helper function to call API with data """ + """Helper function to call API with data""" data = json.dumps(data) headers = self.build_jwt_headers(user) - return client.post(self.url, data, content_type='application/json', **headers) + return client.post(self.url, data, content_type="application/json", **headers) - @ddt.data( - [{}, {}], - {}, - [{"test_key": "test_value", "test_key_2": "test_value_2"}] - ) + @ddt.data([{}, {}], {}, [{"test_key": "test_value", "test_key_2": "test_value_2"}]) def test_bad_schema(self, mapping_data): - """ Verify the endpoint rejects bad data schema """ - data = { - "username_mappings": mapping_data - } + """Verify the endpoint rejects bad data schema""" + data = {"username_mappings": mapping_data} response = self.call_api(self.worker, self.worker_client, data) assert response.status_code == 400 def test_auth(self): - """ Verify the endpoint only works with the service worker """ + """Verify the endpoint only works with the service worker""" data = { "username_mappings": [ {"test_username_1": "test_new_username_1"}, - {"test_username_2": "test_new_username_2"} + {"test_username_2": "test_new_username_2"}, ] } @@ -727,15 +807,15 @@ def test_auth(self): assert response.status_code == 200 def test_basic(self): - """ Check successful replacement """ + """Check successful replacement""" data = { "username_mappings": [ {self.user.username: self.new_username}, ] } expected_response = { - 'failed_replacements': [], - 'successful_replacements': data["username_mappings"] + "failed_replacements": [], + "successful_replacements": data["username_mappings"], } self.register_get_username_replacement_response(self.user) response = self.call_api(self.worker, self.worker_client, data) @@ -751,7 +831,9 @@ def test_not_authenticated(self): @ddt.ddt @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class CourseTopicsViewTest(DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase): +class CourseTopicsViewTest( + DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase +): """ Tests for CourseTopicsView """ @@ -768,10 +850,12 @@ def setUp(self): "courseware-2": {"discussion": 4, "question": 5}, "courseware-3": {"discussion": 7, "question": 2}, } - self.register_get_course_commentable_counts_response(self.course.id, self.thread_counts_map) + self.register_get_course_commentable_counts_response( + self.course.id, self.thread_counts_map + ) patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False + "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", + return_value=False, ) patcher.start() self.addCleanup(patcher.stop) @@ -786,7 +870,7 @@ def create_course(self, blocks_count, module_store, topics): run="c", start=datetime.now(UTC), default_store=module_store, - discussion_topics=topics + discussion_topics=topics, ) CourseEnrollmentFactory.create(user=self.user, course_id=course.id) course_url = reverse("course_topics", kwargs={"course_id": str(course.id)}) @@ -794,10 +878,10 @@ def create_course(self, blocks_count, module_store, topics): for i in range(blocks_count): BlockFactory.create( parent_location=course.location, - category='discussion', - discussion_id=f'id_module_{i}', - discussion_category=f'Category {i}', - discussion_target=f'Discussion {i}', + category="discussion", + discussion_id=f"id_module_{i}", + discussion_category=f"Category {i}", + discussion_target=f"Discussion {i}", publish_item=False, ) return course_url, course.id @@ -812,7 +896,7 @@ def make_discussion_xblock(self, topic_id, category, subcategory, **kwargs): discussion_id=topic_id, discussion_category=category, discussion_target=subcategory, - **kwargs + **kwargs, ) def test_404(self): @@ -820,9 +904,7 @@ def test_404(self): reverse("course_topics", kwargs={"course_id": "non/existent/course"}) ) self.assert_response_correct( - response, - 404, - {"developer_message": "Course not found."} + response, 404, {"developer_message": "Course not found."} ) def test_basic(self): @@ -832,21 +914,30 @@ def test_basic(self): 200, { "courseware_topics": [], - "non_courseware_topics": [{ - "id": "test_topic", - "name": "Test Topic", - "children": [], - "thread_list_url": 'http://testserver/api/discussion/v1/threads/' - '?course_id=course-v1%3Ax%2By%2Bz&topic_id=test_topic', - "thread_counts": {"discussion": 0, "question": 0}, - }], - } + "non_courseware_topics": [ + { + "id": "test_topic", + "name": "Test Topic", + "children": [], + "thread_list_url": "http://testserver/api/discussion/v1/threads/" + "?course_id=course-v1%3Ax%2By%2Bz&topic_id=test_topic", + "thread_counts": {"discussion": 0, "question": 0}, + } + ], + }, ) @ddt.data( (2, ModuleStoreEnum.Type.split, 2, {"Test Topic 1": {"id": "test_topic_1"}}), - (2, ModuleStoreEnum.Type.split, 2, - {"Test Topic 1": {"id": "test_topic_1"}, "Test Topic 2": {"id": "test_topic_2"}}), + ( + 2, + ModuleStoreEnum.Type.split, + 2, + { + "Test Topic 1": {"id": "test_topic_1"}, + "Test Topic 2": {"id": "test_topic_2"}, + }, + ), (10, ModuleStoreEnum.Type.split, 2, {"Test Topic 1": {"id": "test_topic_1"}}), ) @ddt.unpack @@ -868,7 +959,7 @@ def test_discussion_topic_404(self): self.assert_response_correct( response, 404, - {"developer_message": "Discussion not found for 'invalid_topic_id'."} + {"developer_message": "Discussion not found for 'invalid_topic_id'."}, ) def test_topic_id(self): @@ -888,38 +979,41 @@ def test_topic_id(self): "non_courseware_topics": [], "courseware_topics": [ { - "children": [{ - "children": [], - "id": "topic_id_1", - "thread_list_url": "http://testserver/api/discussion/v1/threads/?" - "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1", - "name": "test_target_1", - "thread_counts": {"discussion": 0, "question": 0}, - }], + "children": [ + { + "children": [], + "id": "topic_id_1", + "thread_list_url": "http://testserver/api/discussion/v1/threads/?" + "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1", + "name": "test_target_1", + "thread_counts": {"discussion": 0, "question": 0}, + } + ], "id": None, "thread_list_url": "http://testserver/api/discussion/v1/threads/?" - "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1", + "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1", "name": "test_category_1", "thread_counts": None, }, { - "children": - [{ + "children": [ + { "children": [], "id": "topic_id_2", "thread_list_url": "http://testserver/api/discussion/v1/threads/?" - "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2", + "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2", "name": "test_target_2", "thread_counts": {"discussion": 0, "question": 0}, - }], + } + ], "id": None, "thread_list_url": "http://testserver/api/discussion/v1/threads/?" - "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2", + "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2", "name": "test_category_2", "thread_counts": None, - } - ] - } + }, + ], + }, ) @override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, True) @@ -930,45 +1024,46 @@ def test_new_course_structure_response(self): """ chapter = BlockFactory.create( parent_location=self.course.location, - category='chapter', + category="chapter", display_name="Week 1", start=datetime(2015, 3, 1, tzinfo=UTC), ) sequential = BlockFactory.create( parent_location=chapter.location, - category='sequential', + category="sequential", display_name="Lesson 1", start=datetime(2015, 3, 1, tzinfo=UTC), ) BlockFactory.create( parent_location=sequential.location, - category='vertical', - display_name='vertical', + category="vertical", + display_name="vertical", start=datetime(2015, 4, 1, tzinfo=UTC), ) DiscussionsConfiguration.objects.create( - context_key=self.course.id, - provider_type=Provider.OPEN_EDX + context_key=self.course.id, provider_type=Provider.OPEN_EDX ) update_discussions_settings_from_course_task(str(self.course.id)) response = json.loads(self.client.get(self.url).content.decode()) - keys = ['children', 'id', 'name', 'thread_counts', 'thread_list_url'] - assert list(response.keys()) == ['courseware_topics', 'non_courseware_topics'] - assert len(response['courseware_topics']) == 1 - courseware_keys = list(response['courseware_topics'][0].keys()) + keys = ["children", "id", "name", "thread_counts", "thread_list_url"] + assert list(response.keys()) == ["courseware_topics", "non_courseware_topics"] + assert len(response["courseware_topics"]) == 1 + courseware_keys = list(response["courseware_topics"][0].keys()) courseware_keys.sort() assert courseware_keys == keys - assert len(response['non_courseware_topics']) == 1 - non_courseware_keys = list(response['non_courseware_topics'][0].keys()) + assert len(response["non_courseware_topics"]) == 1 + non_courseware_keys = list(response["non_courseware_topics"][0].keys()) non_courseware_keys.sort() assert non_courseware_keys == keys @ddt.ddt -@mock.patch('lms.djangoapps.discussion.rest_api.api._get_course', mock.Mock()) +@mock.patch("lms.djangoapps.discussion.rest_api.api._get_course", mock.Mock()) @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) @override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, True) -class CourseTopicsViewV3Test(DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase): +class CourseTopicsViewV3Test( + DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase +): """ Tests for CourseTopicsViewV3 """ @@ -984,55 +1079,68 @@ def setUp(self) -> None: end=datetime(2028, 1, 1), enrollment_start=datetime(2020, 1, 1), enrollment_end=datetime(2028, 1, 1), - discussion_topics={"Course Wide Topic": { - "id": 'course-wide-topic', - "usage_key": None, - }} + discussion_topics={ + "Course Wide Topic": { + "id": "course-wide-topic", + "usage_key": None, + } + }, ) self.chapter = BlockFactory.create( parent_location=self.course.location, - category='chapter', + category="chapter", display_name="Week 1", start=datetime(2015, 3, 1, tzinfo=UTC), ) self.sequential = BlockFactory.create( parent_location=self.chapter.location, - category='sequential', + category="sequential", display_name="Lesson 1", start=datetime(2015, 3, 1, tzinfo=UTC), ) self.verticals = [ BlockFactory.create( parent_location=self.sequential.location, - category='vertical', - display_name='vertical', + category="vertical", + display_name="vertical", start=datetime(2015, 4, 1, tzinfo=UTC), ) ] course_key = self.course.id - self.config = DiscussionsConfiguration.objects.create(context_key=course_key, provider_type=Provider.OPEN_EDX) + self.config = DiscussionsConfiguration.objects.create( + context_key=course_key, provider_type=Provider.OPEN_EDX + ) topic_links = [] update_discussions_settings_from_course_task(str(course_key)) - topic_id_query = DiscussionTopicLink.objects.filter(context_key=course_key).values_list( - 'external_id', flat=True, + topic_id_query = DiscussionTopicLink.objects.filter( + context_key=course_key + ).values_list( + "external_id", + flat=True, ) - topic_ids = list(topic_id_query.order_by('ordering')) + topic_ids = list(topic_id_query.order_by("ordering")) DiscussionTopicLink.objects.bulk_create(topic_links) self.topic_stats = { - **{topic_id: dict(discussion=random.randint(0, 10), question=random.randint(0, 10)) - for topic_id in set(topic_ids)}, + **{ + topic_id: dict( + discussion=random.randint(0, 10), question=random.randint(0, 10) + ) + for topic_id in set(topic_ids) + }, topic_ids[0]: dict(discussion=0, question=0), } patcher = mock.patch( - 'lms.djangoapps.discussion.rest_api.api.get_course_commentable_counts', + "lms.djangoapps.discussion.rest_api.api.get_course_commentable_counts", mock.Mock(return_value=self.topic_stats), ) patcher.start() self.addCleanup(patcher.stop) - self.url = reverse("course_topics_v3", kwargs={"course_id": str(self.course.id)}) + self.url = reverse( + "course_topics_v3", kwargs={"course_id": str(self.course.id)} + ) patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False + "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", + return_value=False, ) patcher.start() self.addCleanup(patcher.stop) @@ -1041,12 +1149,23 @@ def test_basic(self): response = self.client.get(self.url) data = json.loads(response.content.decode()) expected_non_courseware_keys = [ - 'id', 'usage_key', 'name', 'thread_counts', 'enabled_in_context', - 'courseware' + "id", + "usage_key", + "name", + "thread_counts", + "enabled_in_context", + "courseware", ] expected_courseware_keys = [ - 'id', 'block_id', 'lms_web_url', 'legacy_web_url', 'student_view_url', - 'type', 'display_name', 'children', 'courseware' + "id", + "block_id", + "lms_web_url", + "legacy_web_url", + "student_view_url", + "type", + "display_name", + "children", + "courseware", ] assert response.status_code == 200 assert len(data) == 2 @@ -1054,11 +1173,11 @@ def test_basic(self): assert non_courseware_topic_keys == expected_non_courseware_keys courseware_topic_keys = list(data[1].keys()) assert courseware_topic_keys == expected_courseware_keys - expected_courseware_keys.remove('courseware') - sequential_keys = list(data[1]['children'][0].keys()) - assert sequential_keys == (expected_courseware_keys + ['thread_counts']) - expected_non_courseware_keys.remove('courseware') - vertical_keys = list(data[1]['children'][0]['children'][0].keys()) + expected_courseware_keys.remove("courseware") + sequential_keys = list(data[1]["children"][0].keys()) + assert sequential_keys == (expected_courseware_keys + ["thread_counts"]) + expected_non_courseware_keys.remove("courseware") + vertical_keys = list(data[1]["children"][0]["children"][0].keys()) assert vertical_keys == expected_non_courseware_keys @@ -1099,14 +1218,21 @@ def setUp(self): {"key": "close_reason", "value": None}, { "key": "comment_list_url", - "value": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread" + "value": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", }, { "key": "editable_fields", "value": [ - 'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body', - 'read', 'title', 'topic_id', 'type' - ] + "abuse_flagged", + "anonymous", + "copy_link", + "following", + "raw_body", + "read", + "title", + "topic_id", + "type", + ], }, {"key": "endorsed_comment_list_url", "value": None}, {"key": "following", "value": False}, @@ -1117,32 +1243,39 @@ def setUp(self): {"key": "non_endorsed_comment_list_url", "value": None}, {"key": "preview_body", "value": "Test body"}, {"key": "raw_body", "value": "Test body"}, - {"key": "rendered_body", "value": "

Test body

"}, {"key": "response_count", "value": 0}, {"key": "topic_id", "value": "test_topic"}, {"key": "type", "value": "discussion"}, - {"key": "users", "value": { - self.user.username: { - "profile": { - "image": { - "has_image": False, - "image_url_full": "http://testserver/static/default_500.png", - "image_url_large": "http://testserver/static/default_120.png", - "image_url_medium": "http://testserver/static/default_50.png", - "image_url_small": "http://testserver/static/default_30.png", + { + "key": "users", + "value": { + self.user.username: { + "profile": { + "image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + } } } - } - }}, + }, + }, {"key": "vote_count", "value": 4}, {"key": "voted", "value": False}, - + {"key": "is_deleted", "value": None}, + {"key": "deleted_at", "value": None}, + {"key": "deleted_by", "value": None}, + {"key": "deleted_by_label", "value": None}, ] - self.url = reverse("discussion_learner_threads", kwargs={'course_id': str(self.course.id)}) + self.url = reverse( + "discussion_learner_threads", kwargs={"course_id": str(self.course.id)} + ) patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False + "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", + return_value=False, ) patcher.start() self.addCleanup(patcher.stop) @@ -1153,12 +1286,12 @@ def update_thread(self, thread): Value of these keys has been defined in setUp function """ for element in self.add_keys: - thread[element['key']] = element['value'] + thread[element["key"]] = element["value"] for pair in self.replace_keys: - thread[pair['to']] = thread.pop(pair['from']) + thread[pair["to"]] = thread.pop(pair["from"]) for key in self.remove_keys: thread.pop(key) - thread['comment_count'] += 1 + thread["comment_count"] += 1 return thread def test_basic(self): @@ -1170,22 +1303,26 @@ def test_basic(self): """ self.register_get_user_response(self.user) expected_cs_comments_response = { - "collection": [make_minimal_cs_thread({ - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(self.user.id), - "username": self.user.username, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "title": "Test Title", - "body": "Test body", - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - "closed_by_label": None, - "edit_by_label": None, - })], + "collection": [ + make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + "closed_by_label": None, + "edit_by_label": None, + } + ) + ], "page": 1, "num_pages": 1, } @@ -1193,14 +1330,14 @@ def test_basic(self): self.url += f"?username={self.user.username}" response = self.client.get(self.url) assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - expected_api_response = expected_cs_comments_response['collection'] + response_data = json.loads(response.content.decode("utf-8")) + expected_api_response = expected_cs_comments_response["collection"] for thread in expected_api_response: self.update_thread(thread) - assert response_data['results'] == expected_api_response - assert response_data['pagination'] == { + assert response_data["results"] == expected_api_response + assert response_data["pagination"] == { "next": None, "previous": None, "count": 1, @@ -1230,20 +1367,24 @@ def test_thread_type_by(self, thread_type): thread_type (str): Value of thread_type can be 'None', 'discussion' and 'question' """ - threads = [make_minimal_cs_thread({ - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(self.user.id), - "username": self.user.username, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "title": "Test Title", - "body": "Test body", - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - })] + threads = [ + make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + } + ) + ] expected_cs_comments_response = { "collection": threads, "page": 1, @@ -1257,23 +1398,26 @@ def test_thread_type_by(self, thread_type): "course_id": str(self.course.id), "username": self.user.username, "thread_type": thread_type, - } + }, ) assert response.status_code == 200 - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "page": ["1"], - "per_page": ["10"], - "thread_type": [thread_type], - "sort_key": ['activity'], - "count_flagged": ["False"] - }) + self.assert_last_query_params( + { + "user_id": [str(self.user.id)], + "course_id": [str(self.course.id)], + "page": ["1"], + "per_page": ["10"], + "thread_type": [thread_type], + "sort_key": ["activity"], + "count_flagged": ["False"], + "show_deleted": ["False"], + } + ) @ddt.data( ("last_activity_at", "activity"), ("comment_count", "comments"), - ("vote_count", "votes") + ("vote_count", "votes"), ) @ddt.unpack def test_order_by(self, http_query, cc_query): @@ -1284,20 +1428,24 @@ def test_order_by(self, http_query, cc_query): http_query (str): Query string sent in the http request cc_query (str): Query string used for the comments client service """ - threads = [make_minimal_cs_thread({ - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(self.user.id), - "username": self.user.username, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "title": "Test Title", - "body": "Test body", - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - })] + threads = [ + make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + } + ) + ] expected_cs_comments_response = { "collection": threads, "page": 1, @@ -1311,17 +1459,20 @@ def test_order_by(self, http_query, cc_query): "course_id": str(self.course.id), "username": self.user.username, "order_by": http_query, - } + }, ) assert response.status_code == 200 - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "page": ["1"], - "per_page": ["10"], - "sort_key": [cc_query], - "count_flagged": ["False"] - }) + self.assert_last_query_params( + { + "user_id": [str(self.user.id)], + "course_id": [str(self.course.id)], + "page": ["1"], + "per_page": ["10"], + "sort_key": [cc_query], + "count_flagged": ["False"], + "show_deleted": ["False"], + } + ) @ddt.data("flagged", "unanswered", "unread", "unresponded") def test_status_by(self, post_status): @@ -1332,20 +1483,24 @@ def test_status_by(self, post_status): post_status (str): Value of post_status can be 'flagged', 'unanswered' and 'unread' """ - threads = [make_minimal_cs_thread({ - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(self.user.id), - "username": self.user.username, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "title": "Test Title", - "body": "Test body", - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - })] + threads = [ + make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + } + ) + ] expected_cs_comments_response = { "collection": threads, "page": 1, @@ -1359,29 +1514,37 @@ def test_status_by(self, post_status): "course_id": str(self.course.id), "username": self.user.username, "status": post_status, - } + }, ) if post_status == "flagged": assert response.status_code == 403 else: assert response.status_code == 200 - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "page": ["1"], - "per_page": ["10"], - post_status: ['True'], - "sort_key": ['activity'], - "count_flagged": ["False"] - }) + self.assert_last_query_params( + { + "user_id": [str(self.user.id)], + "course_id": [str(self.course.id)], + "page": ["1"], + "per_page": ["10"], + post_status: ["True"], + "sort_key": ["activity"], + "count_flagged": ["False"], + "show_deleted": ["False"], + } + ) @ddt.ddt -class CourseDiscussionSettingsAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTestCase): +class CourseDiscussionSettingsAPIViewTest( + APITestCase, UrlResetMixin, ModuleStoreTestCase +): """ Test the course discussion settings handler API endpoint. """ - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) def setUp(self): super().setUp() self.course = CourseFactory.create( @@ -1389,24 +1552,26 @@ def setUp(self): course="y", run="z", start=datetime.now(UTC), - discussion_topics={"Test Topic": {"id": "test_topic"}} + discussion_topics={"Test Topic": {"id": "test_topic"}}, + ) + self.path = reverse( + "discussion_course_settings", kwargs={"course_id": str(self.course.id)} ) - self.path = reverse('discussion_course_settings', kwargs={'course_id': str(self.course.id)}) self.password = self.TEST_PASSWORD - self.user = UserFactory(username='staff', password=self.password, is_staff=True) + self.user = UserFactory(username="staff", password=self.password, is_staff=True) patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False + "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", + return_value=False, ) patcher.start() self.addCleanup(patcher.stop) def _get_oauth_headers(self, user): """Return the OAuth headers for testing OAuth authentication""" - access_token = AccessTokenFactory.create(user=user, application=ApplicationFactory()).token - headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + access_token - } + access_token = AccessTokenFactory.create( + user=user, application=ApplicationFactory() + ).token + headers = {"HTTP_AUTHORIZATION": "Bearer " + access_token} return headers def _login_as_staff(self): @@ -1414,24 +1579,30 @@ def _login_as_staff(self): self.client.login(username=self.user.username, password=self.password) def _login_as_discussion_staff(self): - user = UserFactory(username='abc', password='abc') - role = Role.objects.create(name='Administrator', course_id=self.course.id) + user = UserFactory(username="abc", password="abc") + role = Role.objects.create(name="Administrator", course_id=self.course.id) role.users.set([user]) - self.client.login(username=user.username, password='abc') + self.client.login(username=user.username, password="abc") def _create_divided_discussions(self): """Create some divided discussions for testing.""" - divided_inline_discussions = ['Topic A', ] - divided_course_wide_discussions = ['Topic B', ] - divided_discussions = divided_inline_discussions + divided_course_wide_discussions + divided_inline_discussions = [ + "Topic A", + ] + divided_course_wide_discussions = [ + "Topic B", + ] + divided_discussions = ( + divided_inline_discussions + divided_course_wide_discussions + ) BlockFactory.create( parent=self.course, - category='discussion', - discussion_id=topic_name_to_id(self.course, 'Topic A'), - discussion_category='Chapter', - discussion_target='Discussion', - start=datetime.now() + category="discussion", + discussion_id=topic_name_to_id(self.course, "Topic A"), + discussion_category="Chapter", + discussion_target="Discussion", + start=datetime.now(), ) discussion_topics = { "Topic B": {"id": "Topic B"}, @@ -1440,31 +1611,36 @@ def _create_divided_discussions(self): config_course_discussions( self.course, discussion_topics=discussion_topics, - divided_discussions=divided_discussions + divided_discussions=divided_discussions, ) return divided_inline_discussions, divided_course_wide_discussions def _get_expected_response(self): """Return the default expected response before any changes to the discussion settings.""" return { - 'always_divide_inline_discussions': False, - 'divided_inline_discussions': [], - 'divided_course_wide_discussions': [], - 'id': 1, - 'division_scheme': 'cohort', - 'available_division_schemes': ['cohort'], - 'reported_content_email_notifications': False, + "always_divide_inline_discussions": False, + "divided_inline_discussions": [], + "divided_course_wide_discussions": [], + "id": 1, + "division_scheme": "cohort", + "available_division_schemes": ["cohort"], + "reported_content_email_notifications": False, } def patch_request(self, data, headers=None): headers = headers if headers else {} - return self.client.patch(self.path, json.dumps(data), content_type='application/merge-patch+json', **headers) + return self.client.patch( + self.path, + json.dumps(data), + content_type="application/merge-patch+json", + **headers, + ) def _assert_current_settings(self, expected_response): """Validate the current discussion settings against the expected response.""" response = self.client.get(self.path) assert response.status_code == 200 - content = json.loads(response.content.decode('utf-8')) + content = json.loads(response.content.decode("utf-8")) assert content == expected_response def _assert_patched_settings(self, data, expected_response): @@ -1473,7 +1649,7 @@ def _assert_patched_settings(self, data, expected_response): assert response.status_code == 204 self._assert_current_settings(expected_response) - @ddt.data('get', 'patch') + @ddt.data("get", "patch") def test_authentication_required(self, method): """Test and verify that authentication is required for this endpoint.""" self.client.logout() @@ -1481,8 +1657,8 @@ def test_authentication_required(self, method): assert response.status_code == 401 @ddt.data( - {'is_staff': False, 'get_status': 403, 'put_status': 403}, - {'is_staff': True, 'get_status': 200, 'put_status': 204}, + {"is_staff": False, "get_status": 403, "put_status": 403}, + {"is_staff": True, "get_status": 200, "put_status": 204}, ) @ddt.unpack def test_oauth(self, is_staff, get_status, put_status): @@ -1495,7 +1671,7 @@ def test_oauth(self, is_staff, get_status, put_status): assert response.status_code == get_status response = self.patch_request( - {'always_divide_inline_discussions': True}, headers + {"always_divide_inline_discussions": True}, headers ) assert response.status_code == put_status @@ -1503,66 +1679,68 @@ def test_non_existent_course_id(self): """Test the response when this endpoint is passed a non-existent course id.""" self._login_as_staff() response = self.client.get( - reverse('discussion_course_settings', kwargs={ - 'course_id': 'course-v1:a+b+c' - }) + reverse( + "discussion_course_settings", kwargs={"course_id": "course-v1:a+b+c"} + ) ) assert response.status_code == 404 def test_patch_request_by_discussion_staff(self): """Test the response when patch request is sent by a user with discussions staff role.""" self._login_as_discussion_staff() - response = self.patch_request( - {'always_divide_inline_discussions': True} - ) + response = self.patch_request({"always_divide_inline_discussions": True}) assert response.status_code == 403 def test_get_request_by_discussion_staff(self): """Test the response when get request is sent by a user with discussions staff role.""" self._login_as_discussion_staff() - divided_inline_discussions, divided_course_wide_discussions = self._create_divided_discussions() + divided_inline_discussions, divided_course_wide_discussions = ( + self._create_divided_discussions() + ) response = self.client.get(self.path) assert response.status_code == 200 expected_response = self._get_expected_response() - expected_response['divided_course_wide_discussions'] = [ - topic_name_to_id(self.course, name) for name in divided_course_wide_discussions + expected_response["divided_course_wide_discussions"] = [ + topic_name_to_id(self.course, name) + for name in divided_course_wide_discussions ] - expected_response['divided_inline_discussions'] = [ + expected_response["divided_inline_discussions"] = [ topic_name_to_id(self.course, name) for name in divided_inline_discussions ] - content = json.loads(response.content.decode('utf-8')) + content = json.loads(response.content.decode("utf-8")) assert content == expected_response def test_get_request_by_non_staff_user(self): """Test the response when get request is sent by a regular user with no staff role.""" - user = UserFactory(username='abc', password='abc') - self.client.login(username=user.username, password='abc') + user = UserFactory(username="abc", password="abc") + self.client.login(username=user.username, password="abc") response = self.client.get(self.path) assert response.status_code == 403 def test_patch_request_by_non_staff_user(self): """Test the response when patch request is sent by a regular user with no staff role.""" - user = UserFactory(username='abc', password='abc') - self.client.login(username=user.username, password='abc') - response = self.patch_request( - {'always_divide_inline_discussions': True} - ) + user = UserFactory(username="abc", password="abc") + self.client.login(username=user.username, password="abc") + response = self.patch_request({"always_divide_inline_discussions": True}) assert response.status_code == 403 def test_get_settings(self): """Test the current discussion settings against the expected response.""" - divided_inline_discussions, divided_course_wide_discussions = self._create_divided_discussions() + divided_inline_discussions, divided_course_wide_discussions = ( + self._create_divided_discussions() + ) self._login_as_staff() response = self.client.get(self.path) assert response.status_code == 200 expected_response = self._get_expected_response() - expected_response['divided_course_wide_discussions'] = [ - topic_name_to_id(self.course, name) for name in divided_course_wide_discussions + expected_response["divided_course_wide_discussions"] = [ + topic_name_to_id(self.course, name) + for name in divided_course_wide_discussions ] - expected_response['divided_inline_discussions'] = [ + expected_response["divided_inline_discussions"] = [ topic_name_to_id(self.course, name) for name in divided_inline_discussions ] - content = json.loads(response.content.decode('utf-8')) + content = json.loads(response.content.decode("utf-8")) assert content == expected_response def test_available_schemes(self): @@ -1570,18 +1748,23 @@ def test_available_schemes(self): config_course_cohorts(self.course, is_cohorted=False) self._login_as_staff() expected_response = self._get_expected_response() - expected_response['available_division_schemes'] = [] + expected_response["available_division_schemes"] = [] self._assert_current_settings(expected_response) CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT) - CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED) + CourseModeFactory.create( + course_id=self.course.id, mode_slug=CourseMode.VERIFIED + ) - expected_response['available_division_schemes'] = [CourseDiscussionSettings.ENROLLMENT_TRACK] + expected_response["available_division_schemes"] = [ + CourseDiscussionSettings.ENROLLMENT_TRACK + ] self._assert_current_settings(expected_response) config_course_cohorts(self.course, is_cohorted=True) - expected_response['available_division_schemes'] = [ - CourseDiscussionSettings.COHORT, CourseDiscussionSettings.ENROLLMENT_TRACK + expected_response["available_division_schemes"] = [ + CourseDiscussionSettings.COHORT, + CourseDiscussionSettings.ENROLLMENT_TRACK, ] self._assert_current_settings(expected_response) @@ -1595,11 +1778,11 @@ def test_empty_body_patch_request(self): assert response.status_code == 400 @ddt.data( - {'abc': 123}, - {'divided_course_wide_discussions': 3}, - {'divided_inline_discussions': 'a'}, - {'always_divide_inline_discussions': ['a']}, - {'division_scheme': True} + {"abc": 123}, + {"divided_course_wide_discussions": 3}, + {"divided_inline_discussions": "a"}, + {"always_divide_inline_discussions": ["a"]}, + {"division_scheme": True}, ) def test_invalid_body_parameters(self, body): """Test the response status code on sending a PATCH request with parameters having incorrect types.""" @@ -1613,31 +1796,34 @@ def test_update_always_divide_inline_discussion_settings(self): self._login_as_staff() expected_response = self._get_expected_response() self._assert_current_settings(expected_response) - expected_response['always_divide_inline_discussions'] = True + expected_response["always_divide_inline_discussions"] = True - self._assert_patched_settings({'always_divide_inline_discussions': True}, expected_response) + self._assert_patched_settings( + {"always_divide_inline_discussions": True}, expected_response + ) def test_update_course_wide_discussion_settings(self): """Test whether the 'divided_course_wide_discussions' setting is updated.""" - discussion_topics = { - 'Topic B': {'id': 'Topic B'} - } + discussion_topics = {"Topic B": {"id": "Topic B"}} config_course_cohorts(self.course, is_cohorted=True) config_course_discussions(self.course, discussion_topics=discussion_topics) expected_response = self._get_expected_response() self._login_as_staff() self._assert_current_settings(expected_response) - expected_response['divided_course_wide_discussions'] = [ + expected_response["divided_course_wide_discussions"] = [ topic_name_to_id(self.course, "Topic B") ] self._assert_patched_settings( - {'divided_course_wide_discussions': [topic_name_to_id(self.course, "Topic B")]}, - expected_response + { + "divided_course_wide_discussions": [ + topic_name_to_id(self.course, "Topic B") + ] + }, + expected_response, ) - expected_response['divided_course_wide_discussions'] = [] + expected_response["divided_course_wide_discussions"] = [] self._assert_patched_settings( - {'divided_course_wide_discussions': []}, - expected_response + {"divided_course_wide_discussions": []}, expected_response ) def test_update_inline_discussion_settings(self): @@ -1650,17 +1836,23 @@ def test_update_inline_discussion_settings(self): now = datetime.now() BlockFactory.create( parent_location=self.course.location, - category='discussion', - discussion_id='Topic_A', - discussion_category='Chapter', - discussion_target='Discussion', - start=now + category="discussion", + discussion_id="Topic_A", + discussion_category="Chapter", + discussion_target="Discussion", + start=now, + ) + expected_response["divided_inline_discussions"] = [ + "Topic_A", + ] + self._assert_patched_settings( + {"divided_inline_discussions": ["Topic_A"]}, expected_response ) - expected_response['divided_inline_discussions'] = ['Topic_A', ] - self._assert_patched_settings({'divided_inline_discussions': ['Topic_A']}, expected_response) - expected_response['divided_inline_discussions'] = [] - self._assert_patched_settings({'divided_inline_discussions': []}, expected_response) + expected_response["divided_inline_discussions"] = [] + self._assert_patched_settings( + {"divided_inline_discussions": []}, expected_response + ) def test_update_division_scheme(self): """Test whether the 'division_scheme' setting is updated.""" @@ -1668,15 +1860,17 @@ def test_update_division_scheme(self): self._login_as_staff() expected_response = self._get_expected_response() self._assert_current_settings(expected_response) - expected_response['division_scheme'] = 'none' - self._assert_patched_settings({'division_scheme': 'none'}, expected_response) + expected_response["division_scheme"] = "none" + self._assert_patched_settings({"division_scheme": "none"}, expected_response) def test_update_reported_content_email_notifications(self): """Test whether the 'reported_content_email_notifications' setting is updated.""" config_course_cohorts(self.course, is_cohorted=True) - config_course_discussions(self.course, reported_content_email_notifications=True) + config_course_discussions( + self.course, reported_content_email_notifications=True + ) expected_response = self._get_expected_response() - expected_response['reported_content_email_notifications'] = True + expected_response["reported_content_email_notifications"] = True self._login_as_staff() self._assert_current_settings(expected_response) @@ -1686,12 +1880,15 @@ class CourseDiscussionRolesAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTe """ Test the course discussion roles management endpoint. """ - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) def setUp(self): super().setUp() patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False + "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", + return_value=False, ) patcher.start() self.addCleanup(patcher.stop) @@ -1702,26 +1899,27 @@ def setUp(self): start=datetime.now(UTC), ) self.password = self.TEST_PASSWORD - self.user = UserFactory(username='staff', password=self.password, is_staff=True) - course_key = CourseKey.from_string('course-v1:x+y+z') + self.user = UserFactory(username="staff", password=self.password, is_staff=True) + course_key = CourseKey.from_string("course-v1:x+y+z") seed_permissions_roles(course_key) - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) def path(self, course_id=None, role=None): """Return the URL path to the endpoint based on the provided arguments.""" course_id = str(self.course.id) if course_id is None else course_id - role = 'Moderator' if role is None else role + role = "Moderator" if role is None else role return reverse( - 'discussion_course_roles', - kwargs={'course_id': course_id, 'rolename': role} + "discussion_course_roles", kwargs={"course_id": course_id, "rolename": role} ) def _get_oauth_headers(self, user): """Return the OAuth headers for testing OAuth authentication.""" - access_token = AccessTokenFactory.create(user=user, application=ApplicationFactory()).token - headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + access_token - } + access_token = AccessTokenFactory.create( + user=user, application=ApplicationFactory() + ).token + headers = {"HTTP_AUTHORIZATION": "Bearer " + access_token} return headers def _login_as_staff(self): @@ -1746,9 +1944,11 @@ def _add_users_to_role(self, users, rolename): def post(self, role, user_id, action): """Make a POST request to the endpoint using the provided parameters.""" self._login_as_staff() - return self.client.post(self.path(role=role), {'user_id': user_id, 'action': action}) + return self.client.post( + self.path(role=role), {"user_id": user_id, "action": action} + ) - @ddt.data('get', 'post') + @ddt.data("get", "post") def test_authentication_required(self, method): """Test and verify that authentication is required for this endpoint.""" self.client.logout() @@ -1761,29 +1961,31 @@ def test_oauth(self): self.client.logout() response = self.client.get(self.path(), **oauth_headers) assert response.status_code == 200 - body = {'user_id': 'staff', 'action': 'allow'} - response = self.client.post(self.path(), body, format='json', **oauth_headers) + body = {"user_id": "staff", "action": "allow"} + response = self.client.post(self.path(), body, format="json", **oauth_headers) assert response.status_code == 200 @ddt.data( - {'username': 'u1', 'is_staff': False, 'expected_status': 403}, - {'username': 'u2', 'is_staff': True, 'expected_status': 200}, + {"username": "u1", "is_staff": False, "expected_status": 403}, + {"username": "u2", "is_staff": True, "expected_status": 200}, ) @ddt.unpack def test_staff_permission_required(self, username, is_staff, expected_status): """Test and verify that only users with staff permission can access this endpoint.""" - UserFactory(username=username, password='edx', is_staff=is_staff) - self.client.login(username=username, password='edx') + UserFactory(username=username, password="edx", is_staff=is_staff) + self.client.login(username=username, password="edx") response = self.client.get(self.path()) assert response.status_code == expected_status - response = self.client.post(self.path(), {'user_id': username, 'action': 'allow'}, format='json') + response = self.client.post( + self.path(), {"user_id": username, "action": "allow"}, format="json" + ) assert response.status_code == expected_status def test_non_existent_course_id(self): """Test the response when the endpoint URL contains a non-existent course id.""" self._login_as_staff() - path = self.path(course_id='course-v1:a+b+c') + path = self.path(course_id="course-v1:a+b+c") response = self.client.get(path) assert response.status_code == 404 @@ -1794,7 +1996,7 @@ def test_non_existent_course_id(self): def test_non_existent_course_role(self): """Test the response when the endpoint URL contains a non-existent role.""" self._login_as_staff() - path = self.path(role='A') + path = self.path(role="A") response = self.client.get(path) assert response.status_code == 400 @@ -1803,10 +2005,10 @@ def test_non_existent_course_role(self): assert response.status_code == 400 @ddt.data( - {'role': 'Moderator', 'count': 0}, - {'role': 'Moderator', 'count': 1}, - {'role': 'Group Moderator', 'count': 2}, - {'role': 'Community TA', 'count': 3}, + {"role": "Moderator", "count": 0}, + {"role": "Moderator", "count": 1}, + {"role": "Group Moderator", "count": 2}, + {"role": "Community TA", "count": 3}, ) @ddt.unpack def test_get_role_members(self, role, count): @@ -1820,14 +2022,14 @@ def test_get_role_members(self, role, count): assert response.status_code == 200 - content = json.loads(response.content.decode('utf-8')) - assert content['course_id'] == 'course-v1:x+y+z' - assert len(content['results']) == count - expected_fields = ('username', 'email', 'first_name', 'last_name', 'group_name') - for item in content['results']: + content = json.loads(response.content.decode("utf-8")) + assert content["course_id"] == "course-v1:x+y+z" + assert len(content["results"]) == count + expected_fields = ("username", "email", "first_name", "last_name", "group_name") + for item in content["results"]: for expected_field in expected_fields: assert expected_field in item - assert content['division_scheme'] == 'cohort' + assert content["division_scheme"] == "cohort" def test_post_missing_body(self): """Test the response with a POST request without a body.""" @@ -1836,9 +2038,9 @@ def test_post_missing_body(self): assert response.status_code == 400 @ddt.data( - {'a': 1}, - {'user_id': 'xyz', 'action': 'allow'}, - {'user_id': 'staff', 'action': 123}, + {"a": 1}, + {"user_id": "xyz", "action": "allow"}, + {"user_id": "staff", "action": 123}, ) def test_missing_or_invalid_parameters(self, body): """ @@ -1849,82 +2051,100 @@ def test_missing_or_invalid_parameters(self, body): response = self.client.post(self.path(), body) assert response.status_code == 400 - response = self.client.post(self.path(), body, format='json') + response = self.client.post(self.path(), body, format="json") assert response.status_code == 400 @ddt.data( - {'action': 'allow', 'user_in_role': False}, - {'action': 'allow', 'user_in_role': True}, - {'action': 'revoke', 'user_in_role': False}, - {'action': 'revoke', 'user_in_role': True} + {"action": "allow", "user_in_role": False}, + {"action": "allow", "user_in_role": True}, + {"action": "revoke", "user_in_role": False}, + {"action": "revoke", "user_in_role": True}, ) @ddt.unpack def test_post_update_user_role(self, action, user_in_role): """Test the response when updating the user's role""" users = self._create_and_enroll_users(count=1) user = users[0] - role = 'Moderator' + role = "Moderator" if user_in_role: self._add_users_to_role(users, role) response = self.post(role, user.username, action) assert response.status_code == 200 - content = json.loads(response.content.decode('utf-8')) - assertion = self.assertTrue if action == 'allow' else self.assertFalse - assertion(any(user.username in x['username'] for x in content['results'])) + content = json.loads(response.content.decode("utf-8")) + assertion = self.assertTrue if action == "allow" else self.assertFalse + assertion(any(user.username in x["username"] for x in content["results"])) @ddt.ddt @httpretty.activate @override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True) -class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceMockMixin, APITestCase, - SharedModuleStoreTestCase): +class CourseActivityStatsTest( + ForumsEnableMixin, + UrlResetMixin, + CommentsServiceMockMixin, + APITestCase, + SharedModuleStoreTestCase, +): """ Tests for the course stats endpoint """ - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) def setUp(self) -> None: super().setUp() patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False + "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", + return_value=False, ) patcher.start() self.addCleanup(patcher.stop) self.course = CourseFactory.create() self.course_key = str(self.course.id) seed_permissions_roles(self.course.id) - self.user = UserFactory(username='user') - self.moderator = UserFactory(username='moderator') + self.user = UserFactory(username="user") + self.moderator = UserFactory(username="moderator") moderator_role = Role.objects.get(name="Moderator", course_id=self.course.id) moderator_role.users.add(self.moderator) self.stats = [ { - "active_flags": random.randint(0, 3), - "inactive_flags": random.randint(0, 2), + "threads": random.randint(0, 10), "replies": random.randint(0, 30), "responses": random.randint(0, 100), - "threads": random.randint(0, 10), - "username": f"user-{idx}" + "deleted_threads": 0, + "deleted_replies": 0, + "deleted_responses": 0, + "active_flags": random.randint(0, 3), + "inactive_flags": random.randint(0, 2), + "username": f"user-{idx}", } for idx in range(10) ] for stat in self.stats: user = UserFactory.create( - username=stat['username'], + username=stat["username"], email=f"{stat['username']}@example.com", - password=self.TEST_PASSWORD + password=self.TEST_PASSWORD, ) - CourseEnrollment.enroll(user, self.course.id, mode='audit') + CourseEnrollment.enroll(user, self.course.id, mode="audit") - CourseEnrollment.enroll(self.moderator, self.course.id, mode='audit') - self.stats_without_flags = [{**stat, "active_flags": None, "inactive_flags": None} for stat in self.stats] + CourseEnrollment.enroll(self.moderator, self.course.id, mode="audit") + self.stats_without_flags = [ + {**stat, "active_flags": None, "inactive_flags": None} + for stat in self.stats + ] self.register_course_stats_response(self.course_key, self.stats, 1, 3) - self.url = reverse("discussion_course_activity_stats", kwargs={"course_key_string": self.course_key}) + self.url = reverse( + "discussion_course_activity_stats", + kwargs={"course_key_string": self.course_key}, + ) - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) def test_regular_user(self): """ Tests that for a regular user stats are returned without flag counts @@ -1934,7 +2154,9 @@ def test_regular_user(self): data = response.json() assert data["results"] == self.stats_without_flags - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) def test_moderator_user(self): """ Tests that for a moderator user stats are returned with flag counts @@ -1954,7 +2176,9 @@ def test_moderator_user(self): ("user", "recency", "recency"), ) @ddt.unpack - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) def test_sorting(self, username, ordering_requested, ordering_performed): """ Test valid sorting options and defaults @@ -1964,15 +2188,22 @@ def test_sorting(self, username, ordering_requested, ordering_performed): if ordering_requested: params = {"order_by": ordering_requested} self.client.get(self.url, params) - assert urlparse( - httpretty.last_request().path # lint-amnesty, pylint: disable=no-member - ).path == f"/api/v1/users/{self.course_key}/stats" + assert ( + urlparse( + httpretty.last_request().path # lint-amnesty, pylint: disable=no-member + ).path + == f"/api/v1/users/{self.course_key}/stats" + ) assert parse_qs( - urlparse(httpretty.last_request().path).query # lint-amnesty, pylint: disable=no-member + urlparse( + httpretty.last_request().path + ).query # lint-amnesty, pylint: disable=no-member ).get("sort_key", None) == [ordering_performed] @ddt.data("flagged", "xyz") - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) def test_sorting_error_regular_user(self, order_by): """ Test for invalid sorting options for regular users. @@ -1982,47 +2213,60 @@ def test_sorting_error_regular_user(self, order_by): assert "order_by" in response.json()["field_errors"] @ddt.data( - ('user', 'user-0,user-1,user-2,user-3,user-4,user-5,user-6,user-7,user-8,user-9'), - ('moderator', 'moderator'), + ( + "user", + "user-0,user-1,user-2,user-3,user-4,user-5,user-6,user-7,user-8,user-9", + ), + ("moderator", "moderator"), ) @ddt.unpack - @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True}) - def test_with_username_param(self, username_search_string, comma_separated_usernames): + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def test_with_username_param( + self, username_search_string, comma_separated_usernames + ): """ Test for endpoint with username param. """ - params = {'username': username_search_string} + params = {"username": username_search_string} self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD) self.client.get(self.url, params) - assert urlparse( - httpretty.last_request().path # lint-amnesty, pylint: disable=no-member - ).path == f'/api/v1/users/{self.course_key}/stats' + assert ( + urlparse( + httpretty.last_request().path # lint-amnesty, pylint: disable=no-member + ).path + == f"/api/v1/users/{self.course_key}/stats" + ) assert parse_qs( - urlparse(httpretty.last_request().path).query # lint-amnesty, pylint: disable=no-member - ).get('usernames', [None]) == [comma_separated_usernames] + urlparse( + httpretty.last_request().path + ).query # lint-amnesty, pylint: disable=no-member + ).get("usernames", [None]) == [comma_separated_usernames] - @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True}) + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) def test_with_username_param_with_no_matches(self): """ Test for endpoint with username param with no matches. """ - params = {'username': 'unknown'} + params = {"username": "unknown"} self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD) response = self.client.get(self.url, params) data = response.json() - self.assertFalse(data['results']) - assert data['pagination']['count'] == 0 + self.assertFalse(data["results"]) + assert data["pagination"]["count"] == 0 - @ddt.data( - 'user-0', - 'USER-1', - 'User-2', - 'UsEr-3' + @ddt.data("user-0", "USER-1", "User-2", "UsEr-3") + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} ) - @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True}) def test_with_username_param_case(self, username_search_string): """ Test user search function is case-insensitive. """ - response = get_usernames_from_search_string(self.course_key, username_search_string, 1, 1) + response = get_usernames_from_search_string( + self.course_key, username_search_string, 1, 1 + ) assert response == (username_search_string.lower(), 1, 1) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py index 431304a9a2b5..39e48dd41ff9 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py @@ -14,8 +14,6 @@ from unittest import mock import ddt -from forum.backends.mongodb.comments import Comment -from forum.backends.mongodb.threads import CommentThread import httpretty from django.urls import reverse from pytz import UTC @@ -23,30 +21,39 @@ from rest_framework.parsers import JSONParser from rest_framework.test import APIClient -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory from common.djangoapps.student.tests.factories import ( CourseEnrollmentFactory, UserFactory, ) from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin from common.test.utils import disable_signal -from lms.djangoapps.discussion.tests.utils import ( - make_minimal_cs_comment, - make_minimal_cs_thread, +from forum.backends.mongodb.comments import Comment +from forum.backends.mongodb.threads import CommentThread +from lms.djangoapps.discussion.django_comment_client.tests.utils import ( + ForumsEnableMixin, ) -from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin from lms.djangoapps.discussion.rest_api import api from lms.djangoapps.discussion.rest_api.tests.utils import ( ForumMockUtilsMixin, ProfileImageTestMixin, make_paginated_api_response, ) +from lms.djangoapps.discussion.tests.utils import ( + make_minimal_cs_comment, + make_minimal_cs_thread, +) from openedx.core.djangoapps.django_comment_common.models import ( - FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR, FORUM_ROLE_STUDENT, - assign_role + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_STUDENT, + assign_role, ) -from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage +from openedx.core.djangoapps.user_api.accounts.image_helpers import ( + get_profile_image_storage, +) +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlResetMixin): @@ -387,6 +394,10 @@ def expected_response_data(self, overrides=None): "image_url_small": "http://testserver/static/default_30.png", }, "learner_status": "new", + "is_deleted": None, + "deleted_at": None, + "deleted_by": None, + "deleted_by_label": None, } response_data.update(overrides or {}) return response_data @@ -512,15 +523,17 @@ def test_course_id_missing(self): self.assert_response_correct( response, 400, - {"field_errors": {"course_id": {"developer_message": "This field is required."}}} + { + "field_errors": { + "course_id": {"developer_message": "This field is required."} + } + }, ) def test_404(self): response = self.client.get(self.url, {"course_id": "non/existent/course"}) self.assert_response_correct( - response, - 404, - {"developer_message": "Course not found."} + response, 404, {"developer_message": "Course not found."} ) def test_basic(self): @@ -871,7 +884,9 @@ class BulkDeleteUserPostsTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): def setUp(self): super().setUp() - self.url = reverse("bulk_delete_user_posts", kwargs={"course_id": str(self.course.id)}) + self.url = reverse( + "bulk_delete_user_posts", kwargs={"course_id": str(self.course.id)} + ) self.user2 = UserFactory.create(password=self.password) CourseEnrollmentFactory.create(user=self.user2, course_id=self.course.id) @@ -887,13 +902,19 @@ def mock_comment_and_thread_count(self, comment_count=1, thread_count=1): thread_collection = mock.MagicMock() thread_collection.count_documents.return_value = thread_count patch_thread = mock.patch.object( - CommentThread, "_collection", new_callable=mock.PropertyMock, return_value=thread_collection + CommentThread, + "_collection", + new_callable=mock.PropertyMock, + return_value=thread_collection, ) comment_collection = mock.MagicMock() comment_collection.count_documents.return_value = comment_count patch_comment = mock.patch.object( - Comment, "_collection", new_callable=mock.PropertyMock, return_value=comment_collection + Comment, + "_collection", + new_callable=mock.PropertyMock, + return_value=comment_collection, ) thread_mock = patch_thread.start() @@ -908,7 +929,9 @@ def test_bulk_delete_denied_for_discussion_roles(self, role): """ Test bulk delete user posts denied with discussion roles. """ - thread_mock, comment_mock = self.mock_comment_and_thread_count(comment_count=1, thread_count=1) + thread_mock, comment_mock = self.mock_comment_and_thread_count( + comment_count=1, thread_count=1 + ) assign_role(self.course.id, self.user, role) response = self.client.post( f"{self.url}?username={self.user2.username}", @@ -932,7 +955,9 @@ def test_bulk_delete_allowed_for_discussion_roles(self, role): assert response.status_code == status.HTTP_202_ACCEPTED assert response.json() == {"comment_count": 1, "thread_count": 1} - @mock.patch('lms.djangoapps.discussion.rest_api.views.delete_course_post_for_user.apply_async') + @mock.patch( + "lms.djangoapps.discussion.rest_api.views.delete_course_post_for_user.apply_async" + ) @ddt.data(True, False) def test_task_only_runs_if_execute_param_is_true(self, execute, task_mock): """ diff --git a/lms/djangoapps/discussion/rest_api/tests/utils.py b/lms/djangoapps/discussion/rest_api/tests/utils.py index 8c1615690ad5..990e68a30af9 100644 --- a/lms/djangoapps/discussion/rest_api/tests/utils.py +++ b/lms/djangoapps/discussion/rest_api/tests/utils.py @@ -2,7 +2,6 @@ Discussion API test utilities """ - import hashlib import json import re @@ -14,11 +13,18 @@ from PIL import Image from pytz import UTC -from lms.djangoapps.discussion.django_comment_client.tests.mixins import MockForumApiMixin -from openedx.core.djangoapps.django_comment_common.comment_client.utils import CommentClientRequestError +from lms.djangoapps.discussion.django_comment_client.tests.mixins import ( + MockForumApiMixin, +) +from openedx.core.djangoapps.django_comment_common.comment_client.utils import ( + CommentClientRequestError, +) from openedx.core.djangoapps.profile_images.images import create_profile_images from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file -from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image +from openedx.core.djangoapps.user_api.accounts.image_helpers import ( + get_profile_image_names, + set_has_profile_image, +) def _get_thread_callback(thread_data): @@ -26,6 +32,7 @@ def _get_thread_callback(thread_data): Get a callback function that will return POST/PUT data overridden by response_overrides. """ + def callback(request, _uri, headers): """ Simulate the thread creation or update endpoint by returning the provided @@ -42,7 +49,7 @@ def callback(request, _uri, headers): response_data["edit_history"] = [ { "original_body": original_data["body"], - "author": thread_data.get('username'), + "author": thread_data.get("username"), "reason_code": val, }, ] @@ -68,11 +75,13 @@ def callback(*args, **kwargs): if key in ["anonymous", "anonymous_to_peers", "closed", "pinned"]: response_data[key] = val is True or val == "True" elif key == "edit_reason_code": - response_data["edit_history"] = [{ - "original_body": original_data["body"], - "author": thread_data.get("username"), - "reason_code": val, - }] + response_data["edit_history"] = [ + { + "original_body": original_data["body"], + "author": thread_data.get("username"), + "reason_code": val, + } + ] else: response_data[key] = val @@ -87,6 +96,7 @@ def _get_comment_callback(comment_data, thread_id, parent_id): plus necessary dummy data, overridden by the content of the POST/PUT request. """ + def callback(request, _uri, headers): """ Simulate the comment creation or update endpoint as described above. @@ -105,7 +115,7 @@ def callback(request, _uri, headers): response_data["edit_history"] = [ { "original_body": original_data["body"], - "author": comment_data.get('username'), + "author": comment_data.get("username"), "reason_code": val, }, ] @@ -135,11 +145,13 @@ def callback(*args, **kwargs): if key in ["anonymous", "anonymous_to_peers", "endorsed"]: response_data[key] = val is True or val == "True" elif key == "edit_reason_code": - response_data["edit_history"] = [{ - "original_body": original_data["body"], - "author": comment_data.get("username"), - "reason_code": val, - }] + response_data["edit_history"] = [ + { + "original_body": original_data["body"], + "author": comment_data.get("username"), + "reason_code": val, + } + ] else: response_data[key] = val @@ -152,9 +164,11 @@ def make_user_callbacks(user_map): """ Returns a callable that mimics user creation. """ + def callback(*args, **kwargs): - user_id = args[0] if args else kwargs.get('user_id') + user_id = args[0] if args else kwargs.get("user_id") return user_map[str(user_id)] + return callback @@ -163,54 +177,58 @@ class CommentsServiceMockMixin: def register_get_threads_response(self, threads, page, num_pages): """Register a mock response for GET on the CS thread list endpoint""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.GET, "http://localhost:4567/api/v1/threads", - body=json.dumps({ - "collection": threads, - "page": page, - "num_pages": num_pages, - "thread_count": len(threads), - }), - status=200 + body=json.dumps( + { + "collection": threads, + "page": page, + "num_pages": num_pages, + "thread_count": len(threads), + } + ), + status=200, ) def register_get_course_commentable_counts_response(self, course_id, thread_counts): """Register a mock response for GET on the CS thread list endpoint""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/commentables/{course_id}/counts", body=json.dumps(thread_counts), - status=200 + status=200, ) def register_get_threads_search_response(self, threads, rewrite, num_pages=1): """Register a mock response for GET on the CS thread search endpoint""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.GET, "http://localhost:4567/api/v1/search/threads", - body=json.dumps({ - "collection": threads, - "page": 1, - "num_pages": num_pages, - "corrected_text": rewrite, - "thread_count": len(threads), - }), - status=200 + body=json.dumps( + { + "collection": threads, + "page": 1, + "num_pages": num_pages, + "corrected_text": rewrite, + "thread_count": len(threads), + } + ), + status=200, ) def register_post_thread_response(self, thread_data): """Register a mock response for POST on the CS commentable endpoint""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.POST, re.compile(r"http://localhost:4567/api/v1/(\w+)/threads"), - body=_get_thread_callback(thread_data) + body=_get_thread_callback(thread_data), ) def register_put_thread_response(self, thread_data): @@ -218,49 +236,51 @@ def register_put_thread_response(self, thread_data): Register a mock response for PUT on the CS endpoint for the given thread_id. """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.PUT, "http://localhost:4567/api/v1/threads/{}".format(thread_data["id"]), - body=_get_thread_callback(thread_data) + body=_get_thread_callback(thread_data), ) def register_get_thread_error_response(self, thread_id, status_code): """Register a mock error response for GET on the CS thread endpoint.""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/threads/{thread_id}", body="", - status=status_code + status=status_code, ) def register_get_thread_response(self, thread): """ Register a mock response for GET on the CS thread instance endpoint. """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.GET, "http://localhost:4567/api/v1/threads/{id}".format(id=thread["id"]), body=json.dumps(thread), - status=200 + status=200, ) def register_get_comments_response(self, comments, page, num_pages): """Register a mock response for GET on the CS comments list endpoint""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.GET, "http://localhost:4567/api/v1/comments", - body=json.dumps({ - "collection": comments, - "page": page, - "num_pages": num_pages, - "comment_count": len(comments), - }), - status=200 + body=json.dumps( + { + "collection": comments, + "page": page, + "num_pages": num_pages, + "comment_count": len(comments), + } + ), + status=200, ) def register_post_comment_response(self, comment_data, thread_id, parent_id=None): @@ -274,11 +294,11 @@ def register_post_comment_response(self, comment_data, thread_id, parent_id=None else: url = f"http://localhost:4567/api/v1/threads/{thread_id}/comments" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.POST, url, - body=_get_comment_callback(comment_data, thread_id, parent_id) + body=_get_comment_callback(comment_data, thread_id, parent_id), ) def register_put_comment_response(self, comment_data): @@ -288,11 +308,11 @@ def register_put_comment_response(self, comment_data): """ thread_id = comment_data["thread_id"] parent_id = comment_data.get("parent_id") - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.PUT, "http://localhost:4567/api/v1/comments/{}".format(comment_data["id"]), - body=_get_comment_callback(comment_data, thread_id, parent_id) + body=_get_comment_callback(comment_data, thread_id, parent_id), ) def register_get_comment_error_response(self, comment_id, status_code): @@ -300,12 +320,12 @@ def register_get_comment_error_response(self, comment_id, status_code): Register a mock error response for GET on the CS comment instance endpoint. """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/comments/{comment_id}", body="", - status=status_code + status=status_code, ) def register_get_comment_response(self, response_overrides): @@ -313,75 +333,83 @@ def register_get_comment_response(self, response_overrides): Register a mock response for GET on the CS comment instance endpoint. """ comment = make_minimal_cs_comment(response_overrides) - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.GET, "http://localhost:4567/api/v1/comments/{id}".format(id=comment["id"]), body=json.dumps(comment), - status=200 + status=200, ) - def register_get_user_response(self, user, subscribed_thread_ids=None, upvoted_ids=None): + def register_get_user_response( + self, user, subscribed_thread_ids=None, upvoted_ids=None + ): """Register a mock response for GET on the CS user instance endpoint""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/users/{user.id}", - body=json.dumps({ - "id": str(user.id), - "subscribed_thread_ids": subscribed_thread_ids or [], - "upvoted_ids": upvoted_ids or [], - }), - status=200 + body=json.dumps( + { + "id": str(user.id), + "subscribed_thread_ids": subscribed_thread_ids or [], + "upvoted_ids": upvoted_ids or [], + } + ), + status=200, ) def register_get_user_retire_response(self, user, status=200, body=""): """Register a mock response for GET on the CS user retirement endpoint""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.POST, f"http://localhost:4567/api/v1/users/{user.id}/retire", body=body, - status=status + status=status, ) def register_get_username_replacement_response(self, user, status=200, body=""): - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.POST, f"http://localhost:4567/api/v1/users/{user.id}/replace_username", body=body, - status=status + status=status, ) def register_subscribed_threads_response(self, user, threads, page, num_pages): """Register a mock response for GET on the CS user instance endpoint""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/users/{user.id}/subscribed_threads", - body=json.dumps({ - "collection": threads, - "page": page, - "num_pages": num_pages, - "thread_count": len(threads), - }), - status=200 + body=json.dumps( + { + "collection": threads, + "page": page, + "num_pages": num_pages, + "thread_count": len(threads), + } + ), + status=200, ) def register_course_stats_response(self, course_key, stats, page, num_pages): """Register a mock response for GET on the CS user course stats instance endpoint""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/users/{course_key}/stats", - body=json.dumps({ - "user_stats": stats, - "page": page, - "num_pages": num_pages, - "count": len(stats), - }), - status=200 + body=json.dumps( + { + "user_stats": stats, + "page": page, + "num_pages": num_pages, + "count": len(stats), + } + ), + status=200, ) def register_subscription_response(self, user): @@ -389,13 +417,13 @@ def register_subscription_response(self, user): Register a mock response for POST and DELETE on the CS user subscription endpoint """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." for method in [httpretty.POST, httpretty.DELETE]: httpretty.register_uri( method, f"http://localhost:4567/api/v1/users/{user.id}/subscriptions", body=json.dumps({}), # body is unused - status=200 + status=200, ) def register_thread_votes_response(self, thread_id): @@ -403,13 +431,13 @@ def register_thread_votes_response(self, thread_id): Register a mock response for PUT and DELETE on the CS thread votes endpoint """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." for method in [httpretty.PUT, httpretty.DELETE]: httpretty.register_uri( method, f"http://localhost:4567/api/v1/threads/{thread_id}/votes", body=json.dumps({}), # body is unused - status=200 + status=200, ) def register_comment_votes_response(self, comment_id): @@ -417,41 +445,39 @@ def register_comment_votes_response(self, comment_id): Register a mock response for PUT and DELETE on the CS comment votes endpoint """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." for method in [httpretty.PUT, httpretty.DELETE]: httpretty.register_uri( method, f"http://localhost:4567/api/v1/comments/{comment_id}/votes", body=json.dumps({}), # body is unused - status=200 + status=200, ) def register_flag_response(self, content_type, content_id): """Register a mock response for PUT on the CS flag endpoints""" - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." for path in ["abuse_flag", "abuse_unflag"]: httpretty.register_uri( "PUT", "http://localhost:4567/api/v1/{content_type}s/{content_id}/{path}".format( - content_type=content_type, - content_id=content_id, - path=path + content_type=content_type, content_id=content_id, path=path ), body=json.dumps({}), # body is unused - status=200 + status=200, ) def register_read_response(self, user, content_type, content_id): """ Register a mock response for POST on the CS 'read' endpoint """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.POST, f"http://localhost:4567/api/v1/users/{user.id}/read", - params={'source_type': content_type, 'source_id': content_id}, + params={"source_type": content_type, "source_id": content_id}, body=json.dumps({}), # body is unused - status=200 + status=200, ) def register_thread_flag_response(self, thread_id): @@ -466,48 +492,48 @@ def register_delete_thread_response(self, thread_id): """ Register a mock response for DELETE on the CS thread instance endpoint """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.DELETE, f"http://localhost:4567/api/v1/threads/{thread_id}", body=json.dumps({}), # body is unused - status=200 + status=200, ) def register_delete_comment_response(self, comment_id): """ Register a mock response for DELETE on the CS comment instance endpoint """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.DELETE, f"http://localhost:4567/api/v1/comments/{comment_id}", body=json.dumps({}), # body is unused - status=200 + status=200, ) def register_user_active_threads(self, user_id, response): """ Register a mock response for GET on the CS comment active threads endpoint """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/users/{user_id}/active_threads", body=json.dumps(response), - status=200 + status=200, ) def register_get_subscriptions(self, thread_id, response): """ Register a mock response for GET on the CS comment active threads endpoint """ - assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/threads/{thread_id}/subscriptions", body=json.dumps(response), - status=200 + status=200, ) def assert_query_params_equal(self, httpretty_request, expected_params): @@ -531,7 +557,7 @@ def request_patch(self, request_data): return self.client.patch( self.url, json.dumps(request_data), - content_type="application/merge-patch+json" + content_type="application/merge-patch+json", ) def expected_thread_data(self, overrides=None): @@ -589,6 +615,10 @@ def expected_thread_data(self, overrides=None): "close_reason": None, "close_reason_code": None, "learner_status": "new", + "is_deleted": None, + "deleted_at": None, + "deleted_by": None, + "deleted_by_label": None, } response_data.update(overrides or {}) return response_data @@ -599,137 +629,153 @@ class ForumMockUtilsMixin(MockForumApiMixin): def register_get_threads_response(self, threads, page, num_pages): """Register a mock response for GET on the CS thread list endpoint""" - self.set_mock_return_value('get_user_threads', { - "collection": threads, - "page": page, - "num_pages": num_pages, - "thread_count": len(threads), - }) + self.set_mock_return_value( + "get_user_threads", + { + "collection": threads, + "page": page, + "num_pages": num_pages, + "thread_count": len(threads), + }, + ) def register_get_course_commentable_counts_response(self, course_id, thread_counts): """Register a mock response for GET on the CS thread list endpoint""" - self.set_mock_return_value('get_commentables_stats', thread_counts) + self.set_mock_return_value("get_commentables_stats", thread_counts) def register_get_threads_search_response(self, threads, rewrite, num_pages=1): """Register a mock response for GET on the CS thread search endpoint""" - self.set_mock_return_value('search_threads', { - "collection": threads, - "page": 1, - "num_pages": num_pages, - "corrected_text": rewrite, - "thread_count": len(threads), - }) + self.set_mock_return_value( + "search_threads", + { + "collection": threads, + "page": 1, + "num_pages": num_pages, + "corrected_text": rewrite, + "thread_count": len(threads), + }, + ) def register_post_thread_response(self, thread_data): - self.set_mock_side_effect('create_thread', make_thread_callback(thread_data)) + self.set_mock_side_effect("create_thread", make_thread_callback(thread_data)) def register_put_thread_response(self, thread_data): - self.set_mock_side_effect('update_thread', make_thread_callback(thread_data)) + self.set_mock_side_effect("update_thread", make_thread_callback(thread_data)) def register_get_thread_error_response(self, thread_id, status_code): self.set_mock_side_effect( - 'get_thread', - CommentClientRequestError(f"Thread does not exist with Id: {thread_id}") + "get_thread", + CommentClientRequestError(f"Thread does not exist with Id: {thread_id}"), ) def register_get_thread_response(self, thread): - self.set_mock_return_value('get_thread', thread) + self.set_mock_return_value("get_thread", thread) def register_get_comments_response(self, comments, page, num_pages): - self.set_mock_return_value('get_parent_comment', { - "collection": comments, - "page": page, - "num_pages": num_pages, - "comment_count": len(comments), - }) + self.set_mock_return_value( + "get_parent_comment", + { + "collection": comments, + "page": page, + "num_pages": num_pages, + "comment_count": len(comments), + }, + ) def register_post_comment_response(self, comment_data, thread_id, parent_id=None): self.set_mock_side_effect( - 'create_child_comment' if parent_id else 'create_parent_comment', - make_comment_callback(comment_data, thread_id, parent_id) + "create_child_comment" if parent_id else "create_parent_comment", + make_comment_callback(comment_data, thread_id, parent_id), ) def register_put_comment_response(self, comment_data): thread_id = comment_data["thread_id"] parent_id = comment_data.get("parent_id") self.set_mock_side_effect( - 'update_comment', - make_comment_callback(comment_data, thread_id, parent_id) + "update_comment", make_comment_callback(comment_data, thread_id, parent_id) ) def register_get_comment_error_response(self, comment_id, status_code): self.set_mock_side_effect( - 'get_parent_comment', - CommentClientRequestError(f"Comment does not exist with Id: {comment_id}") + "get_parent_comment", + CommentClientRequestError(f"Comment does not exist with Id: {comment_id}"), ) def register_get_comment_response(self, response_overrides): comment = make_minimal_cs_comment(response_overrides) - self.set_mock_return_value('get_parent_comment', comment) + self.set_mock_return_value("get_parent_comment", comment) - def register_get_user_response(self, user, subscribed_thread_ids=None, upvoted_ids=None): + def register_get_user_response( + self, user, subscribed_thread_ids=None, upvoted_ids=None + ): """Register a mock response for GET on the CS user endpoint""" self.users_map[str(user.id)] = { "id": str(user.id), "subscribed_thread_ids": subscribed_thread_ids or [], "upvoted_ids": upvoted_ids or [], } - self.set_mock_side_effect('get_user', make_user_callbacks(self.users_map)) + self.set_mock_side_effect("get_user", make_user_callbacks(self.users_map)) def register_get_user_retire_response(self, user, body=""): - self.set_mock_return_value('retire_user', body) + self.set_mock_return_value("retire_user", body) def register_get_username_replacement_response(self, user, status=200, body=""): - self.set_mock_return_value('update_username', body) + self.set_mock_return_value("update_username", body) def register_subscribed_threads_response(self, user, threads, page, num_pages): - self.set_mock_return_value('get_user_subscriptions', { - "collection": threads, - "page": page, - "num_pages": num_pages, - "thread_count": len(threads), - }) + self.set_mock_return_value( + "get_user_subscriptions", + { + "collection": threads, + "page": page, + "num_pages": num_pages, + "thread_count": len(threads), + }, + ) def register_course_stats_response(self, course_key, stats, page, num_pages): - self.set_mock_return_value('get_user_course_stats', { - "user_stats": stats, - "page": page, - "num_pages": num_pages, - "count": len(stats), - }) + self.set_mock_return_value( + "get_user_course_stats", + { + "user_stats": stats, + "page": page, + "num_pages": num_pages, + "count": len(stats), + }, + ) def register_subscription_response(self, user): - self.set_mock_return_value('create_subscription', {}) - self.set_mock_return_value('delete_subscription', {}) + self.set_mock_return_value("create_subscription", {}) + self.set_mock_return_value("delete_subscription", {}) def register_thread_votes_response(self, thread_id): - self.set_mock_return_value('update_thread_votes', {}) - self.set_mock_return_value('delete_thread_vote', {}) + self.set_mock_return_value("update_thread_votes", {}) + self.set_mock_return_value("delete_thread_vote", {}) def register_comment_votes_response(self, comment_id): - self.set_mock_return_value('update_comment_votes', {}) - self.set_mock_return_value('delete_comment_vote', {}) + self.set_mock_return_value("update_comment_votes", {}) + self.set_mock_return_value("delete_comment_vote", {}) def register_flag_response(self, content_type, content_id): - if content_type == 'thread': - self.set_mock_return_value('update_thread_flag', {}) - elif content_type == 'comment': - self.set_mock_return_value('update_comment_flag', {}) + if content_type == "thread": + self.set_mock_return_value("update_thread_flag", {}) + elif content_type == "comment": + self.set_mock_return_value("update_comment_flag", {}) def register_read_response(self, user, content_type, content_id): - self.set_mock_return_value('mark_thread_as_read', {}) + self.set_mock_return_value("mark_thread_as_read", {}) def register_delete_thread_response(self, thread_id): - self.set_mock_return_value('delete_thread', {}) + self.set_mock_return_value("delete_thread", {}) def register_delete_comment_response(self, comment_id): - self.set_mock_return_value('delete_comment', {}) + self.set_mock_return_value("delete_comment", {}) def register_user_active_threads(self, user_id, response): - self.set_mock_return_value('get_user_active_threads', response) + self.set_mock_return_value("get_user_active_threads", response) def register_get_subscriptions(self, thread_id, response): - self.set_mock_return_value('get_thread_subscriptions', response) + self.set_mock_return_value("get_thread_subscriptions", response) def register_thread_flag_response(self, thread_id): """Register a mock response for PUT on the CS thread flag endpoints""" @@ -760,7 +806,7 @@ def request_patch(self, request_data): return self.client.patch( self.url, json.dumps(request_data), - content_type="application/merge-patch+json" + content_type="application/merge-patch+json", ) def expected_thread_data(self, overrides=None): @@ -818,6 +864,10 @@ def expected_thread_data(self, overrides=None): "close_reason": None, "close_reason_code": None, "learner_status": "new", + "is_deleted": None, + "deleted_at": None, + "deleted_by": None, + "deleted_by_label": None, } response_data.update(overrides or {}) return response_data @@ -890,7 +940,9 @@ def make_minimal_cs_comment(overrides=None): return ret -def make_paginated_api_response(results=None, count=0, num_pages=0, next_link=None, previous_link=None): +def make_paginated_api_response( + results=None, count=0, num_pages=0, next_link=None, previous_link=None +): """ Generates the response dictionary of paginated APIs with passed data """ @@ -901,7 +953,7 @@ def make_paginated_api_response(results=None, count=0, num_pages=0, next_link=No "count": count, "num_pages": num_pages, }, - "results": results or [] + "results": results or [], } @@ -919,7 +971,9 @@ def create_profile_image(self, user, storage): with make_image_file() as image_file: create_profile_images(image_file, get_profile_image_names(user.username)) self.check_images(user, storage) - set_has_profile_image(user.username, True, self.TEST_PROFILE_IMAGE_UPLOADED_AT) + set_has_profile_image( + user.username, True, self.TEST_PROFILE_IMAGE_UPLOADED_AT + ) def check_images(self, user, storage, exist=True): """ @@ -933,7 +987,7 @@ def check_images(self, user, storage, exist=True): assert storage.exists(name) with closing(Image.open(storage.path(name))) as img: assert img.size == (size, size) - assert img.format == 'JPEG' + assert img.format == "JPEG" else: assert not storage.exists(name) @@ -941,18 +995,18 @@ def get_expected_user_profile(self, username): """ Returns the expected user profile data for a given username """ - url = 'http://example-storage.com/profile-images/{filename}_{{size}}.jpg?v={timestamp}'.format( - filename=hashlib.md5(b'secret' + username.encode('utf-8')).hexdigest(), - timestamp=self.TEST_PROFILE_IMAGE_UPLOADED_AT.strftime("%s") + url = "http://example-storage.com/profile-images/{filename}_{{size}}.jpg?v={timestamp}".format( + filename=hashlib.md5(b"secret" + username.encode("utf-8")).hexdigest(), + timestamp=self.TEST_PROFILE_IMAGE_UPLOADED_AT.strftime("%s"), ) return { - 'profile': { - 'image': { - 'has_image': True, - 'image_url_full': url.format(size=500), - 'image_url_large': url.format(size=120), - 'image_url_medium': url.format(size=50), - 'image_url_small': url.format(size=30), + "profile": { + "image": { + "has_image": True, + "image_url_full": url.format(size=500), + "image_url_large": url.format(size=120), + "image_url_medium": url.format(size=50), + "image_url_small": url.format(size=30), } } } @@ -962,14 +1016,14 @@ def parsed_body(request): """Returns a parsed dictionary version of a request body""" # This could just be HTTPrettyRequest.parsed_body, but that method double-decodes '%2B' -> '+' -> ' '. # You can just remove this method when this issue is fixed: https://github.com/gabrielfalcao/HTTPretty/issues/240 - return parse_qs(request.body.decode('utf8')) + return parse_qs(request.body.decode("utf8")) def querystring(request): """Returns a parsed dictionary version of a query string""" # This could just be HTTPrettyRequest.querystring, but that method double-decodes '%2B' -> '+' -> ' '. # You can just remove this method when this issue is fixed: https://github.com/gabrielfalcao/HTTPretty/issues/240 - return parse_qs(request.path.split('?', 1)[-1]) + return parse_qs(request.path.split("?", 1)[-1]) class ThreadMock(object): @@ -977,7 +1031,9 @@ class ThreadMock(object): A mock thread object """ - def __init__(self, thread_id, creator, title, parent_id=None, body='', commentable_id=None): + def __init__( + self, thread_id, creator, title, parent_id=None, body="", commentable_id=None + ): self.id = thread_id self.user_id = str(creator.id) self.username = creator.username diff --git a/lms/djangoapps/discussion/rest_api/urls.py b/lms/djangoapps/discussion/rest_api/urls.py index f102dc41f249..9753774f075c 100644 --- a/lms/djangoapps/discussion/rest_api/urls.py +++ b/lms/djangoapps/discussion/rest_api/urls.py @@ -9,6 +9,7 @@ from lms.djangoapps.discussion.rest_api.views import ( BulkDeleteUserPosts, + BulkRestoreUserPosts, CommentViewSet, CourseActivityStatsView, CourseDiscussionRolesAPIView, @@ -18,8 +19,10 @@ CourseTopicsViewV3, CourseView, CourseViewV2, + DeletedContentView, LearnerThreadView, ReplaceUsernamesView, + RestoreContent, RetireUserView, ThreadViewSet, UploadFileView, @@ -31,26 +34,22 @@ urlpatterns = [ re_path( - r"^v1/courses/{}/settings$".format( - settings.COURSE_ID_PATTERN - ), + r"^v1/courses/{}/settings$".format(settings.COURSE_ID_PATTERN), CourseDiscussionSettingsAPIView.as_view(), name="discussion_course_settings", ), re_path( - r"^v1/courses/{}/learner/$".format( - settings.COURSE_ID_PATTERN - ), + r"^v1/courses/{}/learner/$".format(settings.COURSE_ID_PATTERN), LearnerThreadView.as_view(), name="discussion_learner_threads", ), re_path( - fr"^v1/courses/{settings.COURSE_KEY_PATTERN}/activity_stats", + rf"^v1/courses/{settings.COURSE_KEY_PATTERN}/activity_stats", CourseActivityStatsView.as_view(), name="discussion_course_activity_stats", ), re_path( - fr"^v1/courses/{settings.COURSE_ID_PATTERN}/upload$", + rf"^v1/courses/{settings.COURSE_ID_PATTERN}/upload$", UploadFileView.as_view(), name="upload_file", ), @@ -62,36 +61,55 @@ name="discussion_course_roles", ), re_path( - fr"^v1/courses/{settings.COURSE_ID_PATTERN}", + rf"^v1/courses/{settings.COURSE_ID_PATTERN}", CourseView.as_view(), - name="discussion_course" + name="discussion_course", ), re_path( - fr"^v2/courses/{settings.COURSE_ID_PATTERN}", + rf"^v2/courses/{settings.COURSE_ID_PATTERN}", CourseViewV2.as_view(), - name="discussion_course_v2" + name="discussion_course_v2", ), - re_path(r'^v1/accounts/retire_forum/?$', RetireUserView.as_view(), name="retire_discussion_user"), - path('v1/accounts/replace_username', ReplaceUsernamesView.as_view(), name="replace_discussion_username"), re_path( - fr"^v1/course_topics/{settings.COURSE_ID_PATTERN}", + r"^v1/accounts/retire_forum/?$", + RetireUserView.as_view(), + name="retire_discussion_user", + ), + path( + "v1/accounts/replace_username", + ReplaceUsernamesView.as_view(), + name="replace_discussion_username", + ), + re_path( + rf"^v1/course_topics/{settings.COURSE_ID_PATTERN}", CourseTopicsView.as_view(), - name="course_topics" + name="course_topics", ), re_path( - fr"^v2/course_topics/{settings.COURSE_ID_PATTERN}", + rf"^v2/course_topics/{settings.COURSE_ID_PATTERN}", CourseTopicsViewV2.as_view(), - name="course_topics_v2" + name="course_topics_v2", ), re_path( - fr"^v3/course_topics/{settings.COURSE_ID_PATTERN}", + rf"^v3/course_topics/{settings.COURSE_ID_PATTERN}", CourseTopicsViewV3.as_view(), - name="course_topics_v3" + name="course_topics_v3", ), re_path( - fr"^v1/bulk_delete_user_posts/{settings.COURSE_ID_PATTERN}", + rf"^v1/bulk_delete_user_posts/{settings.COURSE_ID_PATTERN}", BulkDeleteUserPosts.as_view(), - name="bulk_delete_user_posts" + name="bulk_delete_user_posts", + ), + re_path( + rf"^v1/bulk_restore_user_posts/{settings.COURSE_ID_PATTERN}", + BulkRestoreUserPosts.as_view(), + name="bulk_restore_user_posts", + ), + path("v1/restore_content", RestoreContent.as_view(), name="restore_content"), + re_path( + rf"^v1/deleted_content/{settings.COURSE_ID_PATTERN}", + DeletedContentView.as_view(), + name="deleted_content", ), - path('v1/', include(ROUTER.urls)), + path("v1/", include(ROUTER.urls)), ] diff --git a/lms/djangoapps/discussion/rest_api/views.py b/lms/djangoapps/discussion/rest_api/views.py index ba9818124e08..3556f78562fe 100644 --- a/lms/djangoapps/discussion/rest_api/views.py +++ b/lms/djangoapps/discussion/rest_api/views.py @@ -1,17 +1,19 @@ """ Discussion API views """ + import logging import uuid import edx_api_doc_tools as apidocs - from django.contrib.auth import get_user_model from django.core.exceptions import BadRequest, ValidationError from django.shortcuts import get_object_or_404 from drf_yasg import openapi from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication -from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from edx_rest_framework_extensions.auth.session.authentication import ( + SessionAuthenticationAllowInactiveUser, +) from opaque_keys.edx.keys import CourseKey from rest_framework import permissions, status from rest_framework.authentication import SessionAuthentication @@ -21,31 +23,49 @@ from rest_framework.views import APIView from rest_framework.viewsets import ViewSet -from xmodule.modulestore.django import modulestore - from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.util.file import store_uploaded_file from lms.djangoapps.course_api.blocks.api import get_blocks from lms.djangoapps.course_goals.models import UserActivity +from lms.djangoapps.discussion.django_comment_client import settings as cc_settings +from lms.djangoapps.discussion.django_comment_client.utils import ( + get_group_id_for_comments_service, +) from lms.djangoapps.discussion.rate_limit import is_content_creation_rate_limited from lms.djangoapps.discussion.rest_api.permissions import IsAllowedToBulkDelete -from lms.djangoapps.discussion.rest_api.tasks import delete_course_post_for_user +from lms.djangoapps.discussion.rest_api.tasks import ( + delete_course_post_for_user, + restore_course_post_for_user, +) from lms.djangoapps.discussion.toggles import ONLY_VERIFIED_USERS_CAN_POST -from lms.djangoapps.discussion.django_comment_client import settings as cc_settings -from lms.djangoapps.discussion.django_comment_client.utils import get_group_id_for_comments_service from lms.djangoapps.instructor.access import update_forum_role -from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS -from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, Provider +from openedx.core.djangoapps.discussions.config.waffle import ( + ENABLE_NEW_STRUCTURE_DISCUSSIONS, +) +from openedx.core.djangoapps.discussions.models import ( + DiscussionsConfiguration, + Provider, +) from openedx.core.djangoapps.discussions.serializers import DiscussionSettingsSerializer from openedx.core.djangoapps.django_comment_common import comment_client -from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings, Role from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread -from openedx.core.djangoapps.user_api.accounts.permissions import CanReplaceUsername, CanRetireUser +from openedx.core.djangoapps.django_comment_common.models import ( + CourseDiscussionSettings, + Role, +) +from openedx.core.djangoapps.user_api.accounts.permissions import ( + CanReplaceUsername, + CanRetireUser, +) from openedx.core.djangoapps.user_api.models import UserRetirementStatus -from openedx.core.lib.api.authentication import BearerAuthentication, BearerAuthenticationAllowInactiveUser +from openedx.core.lib.api.authentication import ( + BearerAuthentication, + BearerAuthenticationAllowInactiveUser, +) from openedx.core.lib.api.parsers import MergePatchParser from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes +from xmodule.modulestore.django import modulestore from ..rest_api.api import ( create_comment, @@ -57,10 +77,10 @@ get_course_discussion_user_stats, get_course_topics, get_course_topics_v2, + get_learner_active_thread_list, get_response_comments, get_thread, get_thread_list, - get_learner_active_thread_list, get_user_comments, get_v2_course_topics_as_v1, update_comment, @@ -88,10 +108,10 @@ from .utils import ( create_blocks_params, create_topics_v3_structure, - is_captcha_enabled, - verify_recaptcha_token, get_course_id_from_thread_id, + is_captcha_enabled, is_only_student, + verify_recaptcha_token, ) log = logging.getLogger(__name__) @@ -107,14 +127,16 @@ class CourseView(DeveloperErrorViewMixin, APIView): @apidocs.schema( parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID") + apidocs.string_parameter( + "course_id", apidocs.ParameterLocation.PATH, description="Course ID" + ) ], responses={ 200: CourseMetadataSerailizer(read_only=True, required=False), 401: "The requester is not authenticated.", 403: "The requester cannot access the specified course.", 404: "The requested course does not exist.", - } + }, ) def get(self, request, course_id): """ @@ -126,7 +148,9 @@ def get(self, request, course_id): """ course_key = CourseKey.from_string(course_id) # TODO: which class is right? # Record user activity for tracking progress towards a user's course goals (for mobile app) - UserActivity.record_user_activity(request.user, course_key, request=request, only_if_mobile_app=True) + UserActivity.record_user_activity( + request.user, course_key, request=request, only_if_mobile_app=True + ) return Response(get_course(request, course_key)) @@ -138,14 +162,16 @@ class CourseViewV2(DeveloperErrorViewMixin, APIView): @apidocs.schema( parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID") + apidocs.string_parameter( + "course_id", apidocs.ParameterLocation.PATH, description="Course ID" + ) ], responses={ 200: CourseMetadataSerailizer(read_only=True, required=False), 401: "The requester is not authenticated.", 403: "The requester cannot access the specified course.", 404: "The requested course does not exist.", - } + }, ) def get(self, request, course_id): """ @@ -156,7 +182,9 @@ def get(self, request, course_id): """ course_key = CourseKey.from_string(course_id) # Record user activity for tracking progress towards a user's course goals (for mobile app) - UserActivity.record_user_activity(request.user, course_key, request=request, only_if_mobile_app=True) + UserActivity.record_user_activity( + request.user, course_key, request=request, only_if_mobile_app=True + ) return Response(get_course(request, course_key, False)) @@ -221,14 +249,14 @@ def get(self, request, course_key_string): form_query_string = CourseActivityStatsForm(request.query_params) if not form_query_string.is_valid(): raise ValidationError(form_query_string.errors) - order_by = form_query_string.cleaned_data.get('order_by', None) + order_by = form_query_string.cleaned_data.get("order_by", None) order_by = UserOrdering(order_by) if order_by else None - username_search_string = form_query_string.cleaned_data.get('username', None) + username_search_string = form_query_string.cleaned_data.get("username", None) data = get_course_discussion_user_stats( request, course_key_string, - form_query_string.cleaned_data['page'], - form_query_string.cleaned_data['page_size'], + form_query_string.cleaned_data["page"], + form_query_string.cleaned_data["page_size"], order_by, username_search_string, ) @@ -268,19 +296,17 @@ def get(self, request, course_id): Implements the GET method as described in the class docstring. """ course_key = CourseKey.from_string(course_id) - topic_ids = self.request.GET.get('topic_id') - topic_ids = set(topic_ids.strip(',').split(',')) if topic_ids else None + topic_ids = self.request.GET.get("topic_id") + topic_ids = set(topic_ids.strip(",").split(",")) if topic_ids else None with modulestore().bulk_operations(course_key): configuration = DiscussionsConfiguration.get(context_key=course_key) provider = configuration.provider_type # This will be removed when mobile app will support new topic structure - new_structure_enabled = ENABLE_NEW_STRUCTURE_DISCUSSIONS.is_enabled(course_key) + new_structure_enabled = ENABLE_NEW_STRUCTURE_DISCUSSIONS.is_enabled( + course_key + ) if provider == Provider.OPEN_EDX and new_structure_enabled: - response = get_v2_course_topics_as_v1( - request, - course_key, - topic_ids - ) + response = get_v2_course_topics_as_v1(request, course_key, topic_ids) else: response = get_course_topics( request, @@ -288,7 +314,9 @@ def get(self, request, course_id): topic_ids, ) # Record user activity for tracking progress towards a user's course goals (for mobile app) - UserActivity.record_user_activity(request.user, course_key, request=request, only_if_mobile_app=True) + UserActivity.record_user_activity( + request.user, course_key, request=request, only_if_mobile_app=True + ) return Response(response) @@ -304,17 +332,17 @@ class CourseTopicsViewV2(DeveloperErrorViewMixin, APIView): @apidocs.schema( parameters=[ apidocs.string_parameter( - 'course_id', + "course_id", apidocs.ParameterLocation.PATH, description="Course ID", ), apidocs.string_parameter( - 'topic_id', + "topic_id", apidocs.ParameterLocation.QUERY, description="Comma-separated list of topic ids to filter", ), openapi.Parameter( - 'order_by', + "order_by", apidocs.ParameterLocation.QUERY, required=False, type=openapi.TYPE_STRING, @@ -327,7 +355,7 @@ class CourseTopicsViewV2(DeveloperErrorViewMixin, APIView): 401: "The requester is not authenticated.", 403: "The requester cannot access the specified course.", 404: "The requested course does not exist.", - } + }, ) def get(self, request, course_id): """ @@ -348,7 +376,7 @@ def get(self, request, course_id): course_key, request.user, form_query_params.cleaned_data["topic_id"], - form_query_params.cleaned_data["order_by"] + form_query_params.cleaned_data["order_by"], ) return Response(response) @@ -416,17 +444,17 @@ def get(self, request, course_id): blocks_params = create_blocks_params(course_usage_key, request.user) blocks = get_blocks( request, - blocks_params['usage_key'], - blocks_params['user'], - blocks_params['depth'], - blocks_params['nav_depth'], - blocks_params['requested_fields'], - blocks_params['block_counts'], - blocks_params['student_view_data'], - blocks_params['return_type'], - blocks_params['block_types_filter'], + blocks_params["usage_key"], + blocks_params["user"], + blocks_params["depth"], + blocks_params["nav_depth"], + blocks_params["requested_fields"], + blocks_params["block_counts"], + blocks_params["student_view_data"], + blocks_params["return_type"], + blocks_params["block_types_filter"], hide_access_denials=False, - )['blocks'] + )["blocks"] topics = create_topics_v3_structure(blocks, topics) return Response(topics) @@ -627,8 +655,12 @@ class ThreadViewSet(DeveloperErrorViewMixin, ViewSet): No content is returned for a DELETE request """ + lookup_field = "thread_id" - parser_classes = (JSONParser, MergePatchParser,) + parser_classes = ( + JSONParser, + MergePatchParser, + ) def list(self, request): """ @@ -641,7 +673,10 @@ class docstring. # Record user activity for tracking progress towards a user's course goals (for mobile app) UserActivity.record_user_activity( - request.user, form.cleaned_data["course_id"], request=request, only_if_mobile_app=True + request.user, + form.cleaned_data["course_id"], + request=request, + only_if_mobile_app=True, ) return get_thread_list( @@ -660,14 +695,15 @@ class docstring. form.cleaned_data["order_direction"], form.cleaned_data["requested_fields"], form.cleaned_data["count_flagged"], + form.cleaned_data["show_deleted"], ) def retrieve(self, request, thread_id=None): """ Implements the GET method for thread ID """ - requested_fields = request.GET.get('requested_fields') - course_id = request.GET.get('course_id') + requested_fields = request.GET.get("requested_fields") + course_id = request.GET.get("course_id") return Response(get_thread(request, thread_id, requested_fields, course_id)) def create(self, request): @@ -681,21 +717,28 @@ class docstring. course_key = CourseKey.from_string(course_key_str) if is_content_creation_rate_limited(request, course_key=course_key): - return Response("Too many requests", status=status.HTTP_429_TOO_MANY_REQUESTS) + return Response( + "Too many requests", status=status.HTTP_429_TOO_MANY_REQUESTS + ) if is_captcha_enabled(course_key) and is_only_student(course_key, request.user): - captcha_token = request.data.get('captcha_token') + captcha_token = request.data.get("captcha_token") if not captcha_token: - raise ValidationError({'captcha_token': 'This field is required.'}) + raise ValidationError({"captcha_token": "This field is required."}) if not verify_recaptcha_token(captcha_token): - return Response({'error': 'CAPTCHA verification failed.'}, status=400) - - if ONLY_VERIFIED_USERS_CAN_POST.is_enabled(course_key) and not request.user.is_active: - raise ValidationError({"detail": "Only verified users can post in discussions."}) + return Response({"error": "CAPTCHA verification failed."}, status=400) + + if ( + ONLY_VERIFIED_USERS_CAN_POST.is_enabled(course_key) + and not request.user.is_active + ): + raise ValidationError( + {"detail": "Only verified users can post in discussions."} + ) data = request.data.copy() - data.pop('captcha_token', None) + data.pop("captcha_token", None) return Response(create_thread(request, data)) def partial_update(self, request, thread_id): @@ -762,24 +805,27 @@ def get(self, request, course_id=None): Implements the GET method as described in the class docstring. """ course_key = CourseKey.from_string(course_id) - page_num = request.GET.get('page', 1) - threads_per_page = request.GET.get('page_size', 10) - count_flagged = request.GET.get('count_flagged', False) - thread_type = request.GET.get('thread_type') - order_by = request.GET.get('order_by') + page_num = request.GET.get("page", 1) + threads_per_page = request.GET.get("page_size", 10) + count_flagged = request.GET.get("count_flagged", False) + thread_type = request.GET.get("thread_type") + order_by = request.GET.get("order_by") order_by_mapping = { "last_activity_at": "activity", "comment_count": "comments", - "vote_count": "votes" + "vote_count": "votes", } - order_by = order_by_mapping.get(order_by, 'activity') - post_status = request.GET.get('status', None) + order_by = order_by_mapping.get(order_by, "activity") + post_status = request.GET.get("status", None) + show_deleted = request.GET.get("show_deleted", "false").lower() == "true" discussion_id = None - username = request.GET.get('username', None) + username = request.GET.get("username", None) user = get_object_or_404(User, username=username) group_id = None try: - group_id = get_group_id_for_comments_service(request, course_key, discussion_id) + group_id = get_group_id_for_comments_service( + request, course_key, discussion_id + ) except ValueError: pass @@ -792,14 +838,17 @@ def get(self, request, course_id=None): "count_flagged": count_flagged, "thread_type": thread_type, "sort_key": order_by, + "show_deleted": show_deleted, } if post_status: - if post_status not in ['flagged', 'unanswered', 'unread', 'unresponded']: - raise ValidationError({ - "status": [ - f"Invalid value. '{post_status}' must be 'flagged', 'unanswered', 'unread' or 'unresponded" - ] - }) + if post_status not in ["flagged", "unanswered", "unread", "unresponded"]: + raise ValidationError( + { + "status": [ + f"Invalid value. '{post_status}' must be 'flagged', 'unanswered', 'unread' or 'unresponded" + ] + } + ) query_params[post_status] = True return get_learner_active_thread_list(request, course_key, query_params) @@ -968,8 +1017,12 @@ class CommentViewSet(DeveloperErrorViewMixin, ViewSet): No content is returned for a DELETE request """ + lookup_field = "comment_id" - parser_classes = (JSONParser, MergePatchParser,) + parser_classes = ( + JSONParser, + MergePatchParser, + ) def list(self, request): """ @@ -1010,7 +1063,8 @@ def list_by_thread(self, request): form.cleaned_data["page_size"], form.cleaned_data["flagged"], form.cleaned_data["requested_fields"], - form.cleaned_data["merge_question_type_responses"] + form.cleaned_data["merge_question_type_responses"], + form.cleaned_data["show_deleted"], ) def list_by_user(self, request): @@ -1057,21 +1111,28 @@ class docstring. course_key = CourseKey.from_string(course_key_str) if is_content_creation_rate_limited(request, course_key=course_key): - return Response("Too many requests", status=status.HTTP_429_TOO_MANY_REQUESTS) + return Response( + "Too many requests", status=status.HTTP_429_TOO_MANY_REQUESTS + ) if is_captcha_enabled(course_key) and is_only_student(course_key, request.user): - captcha_token = request.data.get('captcha_token') + captcha_token = request.data.get("captcha_token") if not captcha_token: - raise ValidationError({'captcha_token': 'This field is required.'}) + raise ValidationError({"captcha_token": "This field is required."}) if not verify_recaptcha_token(captcha_token): - return Response({'error': 'CAPTCHA verification failed.'}, status=400) - - if ONLY_VERIFIED_USERS_CAN_POST.is_enabled(course_key) and not request.user.is_active: - raise ValidationError({"detail": "Only verified users can post in discussions."}) + return Response({"error": "CAPTCHA verification failed."}, status=400) + + if ( + ONLY_VERIFIED_USERS_CAN_POST.is_enabled(course_key) + and not request.user.is_active + ): + raise ValidationError( + {"detail": "Only verified users can post in discussions."} + ) data = request.data.copy() - data.pop('captcha_token', None) + data.pop("captcha_token", None) return Response(create_comment(request, data)) def destroy(self, request, comment_id): @@ -1147,8 +1208,11 @@ def post(self, request, course_id): unique_file_name = f"{course_id}/{thread_key}/{uuid.uuid4()}" try: file_storage, stored_file_name = store_uploaded_file( - request, "uploaded_file", cc_settings.ALLOWED_UPLOAD_FILE_TYPES, - unique_file_name, max_file_size=cc_settings.MAX_UPLOAD_FILE_SIZE, + request, + "uploaded_file", + cc_settings.ALLOWED_UPLOAD_FILE_TYPES, + unique_file_name, + max_file_size=cc_settings.MAX_UPLOAD_FILE_SIZE, ) except ValueError as err: raise BadRequest("no `uploaded_file` was provided") from err @@ -1189,10 +1253,12 @@ def post(self, request): """ Implements the retirement endpoint. """ - username = request.data['username'] + username = request.data["username"] try: - retirement = UserRetirementStatus.get_retirement_for_retirement_action(username) + retirement = UserRetirementStatus.get_retirement_for_retirement_action( + username + ) cc_user = comment_client.User.from_django_user(retirement.user) # Send the retired username to the forums service, as the service cannot generate @@ -1247,7 +1313,9 @@ def post(self, request): for username_pair in username_mappings: current_username = list(username_pair.keys())[0] new_username = list(username_pair.values())[0] - successfully_replaced = self._replace_username(current_username, new_username) + successfully_replaced = self._replace_username( + current_username, new_username + ) if successfully_replaced: successful_replacements.append({current_username: new_username}) else: @@ -1257,8 +1325,8 @@ def post(self, request): status=status.HTTP_200_OK, data={ "successful_replacements": successful_replacements, - "failed_replacements": failed_replacements - } + "failed_replacements": failed_replacements, + }, ) def _replace_username(self, current_username, new_username): @@ -1304,7 +1372,7 @@ def _replace_username(self, current_username, new_username): return True def _has_valid_schema(self, post_data): - """ Verifies the data is a list of objects with a single key:value pair """ + """Verifies the data is a list of objects with a single key:value pair""" if not isinstance(post_data, list): return False for obj in post_data: @@ -1364,12 +1432,16 @@ class CourseDiscussionSettingsAPIView(DeveloperErrorViewMixin, APIView): * available_division_schemes: A list of available division schemes for the course. """ + authentication_classes = ( JwtAuthentication, BearerAuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser, ) - parser_classes = (JSONParser, MergePatchParser,) + parser_classes = ( + JSONParser, + MergePatchParser, + ) permission_classes = (permissions.IsAuthenticated, IsStaffOrAdmin) def _get_request_kwargs(self, course_id): @@ -1385,14 +1457,14 @@ def get(self, request, course_id): if not form.is_valid(): raise ValidationError(form.errors) - course_key = form.cleaned_data['course_key'] - course = form.cleaned_data['course'] + course_key = form.cleaned_data["course_key"] + course = form.cleaned_data["course"] discussion_settings = CourseDiscussionSettings.get(course_key) serializer = DiscussionSettingsSerializer( discussion_settings, context={ - 'course': course, - 'settings': discussion_settings, + "course": course, + "settings": discussion_settings, }, partial=True, ) @@ -1411,15 +1483,15 @@ def patch(self, request, course_id): if not form.is_valid(): raise ValidationError(form.errors) - course = form.cleaned_data['course'] - course_key = form.cleaned_data['course_key'] + course = form.cleaned_data["course"] + course_key = form.cleaned_data["course_key"] discussion_settings = CourseDiscussionSettings.get(course_key) serializer = DiscussionSettingsSerializer( discussion_settings, context={ - 'course': course, - 'settings': discussion_settings, + "course": course, + "settings": discussion_settings, }, data=request.data, partial=True, @@ -1488,6 +1560,7 @@ class CourseDiscussionRolesAPIView(DeveloperErrorViewMixin, APIView): * division_scheme: The division scheme used by the course. """ + authentication_classes = ( JwtAuthentication, BearerAuthenticationAllowInactiveUser, @@ -1508,11 +1581,13 @@ def get(self, request, course_id, rolename): if not form.is_valid(): raise ValidationError(form.errors) - course_id = form.cleaned_data['course_key'] - role = form.cleaned_data['role'] + course_id = form.cleaned_data["course_key"] + role = form.cleaned_data["role"] - data = {'course_id': course_id, 'users': role.users.all()} - context = {'course_discussion_settings': CourseDiscussionSettings.get(course_id)} + data = {"course_id": course_id, "users": role.users.all()} + context = { + "course_discussion_settings": CourseDiscussionSettings.get(course_id) + } serializer = DiscussionRolesListSerializer(data, context=context) return Response(serializer.data) @@ -1526,23 +1601,25 @@ def post(self, request, course_id, rolename): if not form.is_valid(): raise ValidationError(form.errors) - course_id = form.cleaned_data['course_key'] - rolename = form.cleaned_data['rolename'] + course_id = form.cleaned_data["course_key"] + rolename = form.cleaned_data["rolename"] serializer = DiscussionRolesSerializer(data=request.data) if not serializer.is_valid(): raise ValidationError(serializer.errors) - action = serializer.validated_data['action'] - user = serializer.validated_data['user'] + action = serializer.validated_data["action"] + user = serializer.validated_data["user"] try: update_forum_role(course_id, user, rolename, action) except Role.DoesNotExist as err: raise ValidationError(f"Role '{rolename}' does not exist") from err - role = form.cleaned_data['role'] - data = {'course_id': course_id, 'users': role.users.all()} - context = {'course_discussion_settings': CourseDiscussionSettings.get(course_id)} + role = form.cleaned_data["role"] + data = {"course_id": course_id, "users": role.users.all()} + context = { + "course_discussion_settings": CourseDiscussionSettings.get(course_id) + } serializer = DiscussionRolesListSerializer(data, context=context) return Response(serializer.data) @@ -1566,7 +1643,9 @@ class BulkDeleteUserPosts(DeveloperErrorViewMixin, APIView): """ authentication_classes = ( - JwtAuthentication, BearerAuthentication, SessionAuthentication, + JwtAuthentication, + BearerAuthentication, + SessionAuthentication, ) permission_classes = (permissions.IsAuthenticated, IsAllowedToBulkDelete) @@ -1587,23 +1666,26 @@ def post(self, request, course_id): course_ids = [course_id] if course_or_org == "org": org_id = CourseKey.from_string(course_id).org - enrollments = CourseEnrollment.objects.filter(user=request.user).values_list('course_id', flat=True) - course_ids.extend([ - str(c_id) - for c_id in enrollments - if c_id.org == org_id - ]) + enrollments = CourseEnrollment.objects.filter( + user=request.user + ).values_list("course_id", flat=True) + course_ids.extend([str(c_id) for c_id in enrollments if c_id.org == org_id]) course_ids = list(set(course_ids)) log.info(f"<> {username} enrolled in {enrollments}") - log.info(f"<> Posts for {username} in {course_ids} - for {course_or_org} {course_id}") + log.info( + f"<> Posts for {username} in {course_ids} - for {course_or_org} {course_id}" + ) comment_count = Comment.get_user_comment_count(user.id, course_ids) thread_count = Thread.get_user_threads_count(user.id, course_ids) - log.info(f"<> {username} in {course_ids} - Count thread {thread_count}, comment {comment_count}") + log.info( + f"<> {username} in {course_ids} - Count thread {thread_count}, comment {comment_count}" + ) if execute_task: event_data = { "triggered_by": request.user.username, + "triggered_by_user_id": str(request.user.id), "username": username, "course_or_org": course_or_org, "course_key": course_id, @@ -1613,5 +1695,256 @@ def post(self, request, course_id): ) return Response( {"comment_count": comment_count, "thread_count": thread_count}, - status=status.HTTP_202_ACCEPTED + status=status.HTTP_202_ACCEPTED, + ) + + +class RestoreContent(DeveloperErrorViewMixin, APIView): + """ + **Use Cases** + A privileged user that can restore individual soft-deleted threads, comments, or responses. + + **Example Requests**: + POST /api/discussion/v1/restore_content + Request Body: + { + "content_type": "thread", // "thread", "comment", or "response" + "content_id": "thread_id_or_comment_id", + "course_id": "course-v1:edX+DemoX+Demo_Course" + } + + **Example Response**: + {"success": true, "message": "Content restored successfully"} + """ + + authentication_classes = ( + JwtAuthentication, + BearerAuthentication, + SessionAuthentication, + ) + permission_classes = (permissions.IsAuthenticated, IsAllowedToBulkDelete) + + def post(self, request): + """ + Implements the restore individual content endpoint. + """ + content_type = request.data.get("content_type") + content_id = request.data.get("content_id") + course_id = request.data.get("course_id") + + if not all([content_type, content_id, course_id]): + raise BadRequest("content_type, content_id, and course_id are required.") + + if content_type not in ["thread", "comment", "response"]: + raise BadRequest("content_type must be 'thread', 'comment', or 'response'.") + + restored_by_user_id = str(request.user.id) + + try: + if content_type == "thread": + success = Thread.restore_thread( + content_id, course_id=course_id, restored_by=restored_by_user_id + ) + else: # comment or response (both are comments in the backend) + success = Comment.restore_comment( + content_id, course_id=course_id, restored_by=restored_by_user_id + ) + + if success: + return Response( + { + "success": True, + "message": f"{content_type.capitalize()} restored successfully", + }, + status=status.HTTP_200_OK, + ) + else: + return Response( + { + "success": False, + "message": f"{content_type.capitalize()} not found or already restored", + }, + status=status.HTTP_404_NOT_FOUND, + ) + except Exception as e: # pylint: disable=broad-exception-caught + log.error("Error restoring %s %s: %s", content_type, content_id, str(e)) + return Response( + { + "success": False, + "message": f"Error restoring {content_type}: {str(e)}", + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class BulkRestoreUserPosts(DeveloperErrorViewMixin, APIView): + """ + **Use Cases** + A privileged user that can restore all soft-deleted posts and comments made by a user. + It returns expected number of comments and threads that will be restored + + **Example Requests**: + POST /api/discussion/v1/bulk_restore_user_posts/{course_id} + Query Parameters: + username: The username of the user whose posts are to be restored + course_id: Course id for which posts are to be restored + execute: If True, runs restoration task + course_or_org: If 'course', restores posts in the course, if 'org', restores posts in all courses of the org + + **Example Response**: + {"comment_count": 5, "thread_count": 3} + """ + + authentication_classes = ( + JwtAuthentication, + BearerAuthentication, + SessionAuthentication, + ) + permission_classes = (permissions.IsAuthenticated, IsAllowedToBulkDelete) + + def post(self, request, course_id): + """ + Implements the restore user posts endpoint. + """ + username = request.GET.get("username", None) + execute_task = request.GET.get("execute", "false").lower() == "true" + if (not username) or (not course_id): + raise BadRequest("username and course_id are required.") + course_or_org = request.GET.get("course_or_org", "course") + if course_or_org not in ["course", "org"]: + raise BadRequest("course_or_org must be either 'course' or 'org'.") + + user = get_object_or_404(User, username=username) + course_ids = [course_id] + if course_or_org == "org": + org_id = CourseKey.from_string(course_id).org + enrollments = CourseEnrollment.objects.filter( + user=request.user + ).values_list("course_id", flat=True) + course_ids.extend([str(c_id) for c_id in enrollments if c_id.org == org_id]) + course_ids = list(set(course_ids)) + log.info("<> %s enrolled in %s", username, enrollments) + log.info( + "<> Posts for %s in %s - for %s %s", + username, + course_ids, + course_or_org, + course_id, + ) + + comment_count = Comment.get_user_deleted_comment_count(user.id, course_ids) + thread_count = Thread.get_user_deleted_threads_count(user.id, course_ids) + log.info( + "<> %s in %s - Count thread %s, comment %s", + username, + course_ids, + thread_count, + comment_count, + ) + + if execute_task: + event_data = { + "triggered_by": request.user.username, + "triggered_by_user_id": str(request.user.id), + "username": username, + "course_or_org": course_or_org, + "course_key": course_id, + } + restore_course_post_for_user.apply_async( + args=(user.id, username, course_ids, event_data), + ) + return Response( + {"comment_count": comment_count, "thread_count": thread_count}, + status=status.HTTP_202_ACCEPTED, ) + + +class DeletedContentView(DeveloperErrorViewMixin, APIView): + """ + **Use Cases** + Retrieve all deleted content (threads, comments, responses) for a course. + This endpoint allows privileged users to fetch deleted discussion content. + + **Example Requests**: + GET /api/discussion/v1/deleted_content/course-v1:edX+DemoX+Demo_Course + GET /api/discussion/v1/deleted_content/course-v1:edX+DemoX+Demo_Course?content_type=thread + GET /api/discussion/v1/deleted_content/course-v1:edX+DemoX+Demo_Course?page=1&per_page=20 + + **Example Response**: + { + "results": [ + { + "id": "thread_id", + "type": "thread", + "title": "Deleted Thread Title", + "body": "Thread content...", + "course_id": "course-v1:edX+DemoX+Demo_Course", + "author_id": "user_123", + "deleted_at": "2023-11-19T10:30:00Z", + "deleted_by": "moderator_456" + } + ], + "pagination": { + "page": 1, + "per_page": 20, + "total_count": 50, + "num_pages": 3 + } + } + """ + + authentication_classes = ( + JwtAuthentication, + BearerAuthentication, + SessionAuthentication, + ) + permission_classes = (permissions.IsAuthenticated, IsAllowedToBulkDelete) + + def get(self, request, course_id): + """ + Retrieve all deleted content for a course. + """ + try: + course_key = CourseKey.from_string(course_id) + except Exception as e: + raise BadRequest("Invalid course_id") from e + + # Get query parameters + content_type = request.GET.get( + "content_type", None + ) # 'thread', 'comment', or None for all + page = int(request.GET.get("page", 1)) + per_page = int(request.GET.get("per_page", 20)) + author_id = request.GET.get("author_id", None) + + # Validate parameters + if content_type and content_type not in ["thread", "comment"]: + raise BadRequest("content_type must be 'thread' or 'comment'") + + per_page = min(per_page, 100) # Limit to prevent excessive load + + try: + # Import here to avoid circular imports + from lms.djangoapps.discussion.rest_api.api import ( + get_deleted_content_for_course, + ) + + results = get_deleted_content_for_course( + request=request, + course_id=str(course_key), + content_type=content_type, + page=page, + per_page=per_page, + author_id=author_id, + ) + + return Response(results, status=status.HTTP_200_OK) + + except Exception as e: # pylint: disable=broad-exception-caught + logging.exception( + "Error retrieving deleted content for course %s: %s", course_id, e + ) + return Response( + {"error": "Failed to retrieve deleted content"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) 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 8905679a45db..dae2088594ae 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py @@ -4,13 +4,17 @@ from bs4 import BeautifulSoup -from openedx.core.djangoapps.django_comment_common.comment_client import models, settings +from forum import api as forum_api +from forum.backends.mongodb.comments import ( + Comment as ForumComment, +) # pylint: disable=import-error +from openedx.core.djangoapps.django_comment_common.comment_client import ( + models, + settings, +) from .thread import Thread from .utils import CommentClientRequestError, get_course_key -from forum import api as forum_api -from forum.backends.mongodb.comments import Comment as ForumComment - log = logging.getLogger(__name__) @@ -18,26 +22,56 @@ class Comment(models.Model): accessible_fields = [ - 'id', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', - 'endorsed', 'parent_id', 'thread_id', 'username', 'votes', 'user_id', - 'closed', 'created_at', 'updated_at', 'depth', 'at_position_list', - 'type', 'commentable_id', 'abuse_flaggers', 'endorsement', - 'child_count', 'edit_history', - 'is_spam', 'ai_moderation_reason', 'abuse_flagged', + "id", + "body", + "anonymous", + "anonymous_to_peers", + "course_id", + "endorsed", + "parent_id", + "thread_id", + "username", + "votes", + "user_id", + "closed", + "created_at", + "updated_at", + "depth", + "at_position_list", + "type", + "commentable_id", + "abuse_flaggers", + "endorsement", + "child_count", + "edit_history", + "is_spam", + "ai_moderation_reason", + "abuse_flagged", + "is_deleted", + "deleted_at", + "deleted_by", ] updatable_fields = [ - 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'closed', - 'user_id', 'endorsed', 'endorsement_user_id', 'edit_reason_code', - 'closing_user_id', 'editing_user_id', + "body", + "anonymous", + "anonymous_to_peers", + "course_id", + "closed", + "user_id", + "endorsed", + "endorsement_user_id", + "edit_reason_code", + "closing_user_id", + "editing_user_id", ] initializable_fields = updatable_fields - metrics_tag_fields = ['course_id', 'endorsed', 'closed'] + metrics_tag_fields = ["course_id", "endorsed", "closed"] base_url = f"{settings.PREFIX}/comments" - type = 'comment' + type = "comment" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -46,7 +80,7 @@ def __init__(self, *args, **kwargs): @property def thread(self): if not self._cached_thread: - self._cached_thread = Thread(id=self.thread_id, type='thread') + self._cached_thread = Thread(id=self.thread_id, type="thread") return self._cached_thread @property @@ -56,22 +90,22 @@ def context(self): @classmethod def url_for_comments(cls, params=None): - if params and params.get('parent_id'): - return _url_for_comment(params['parent_id']) + if params and params.get("parent_id"): + return _url_for_comment(params["parent_id"]) else: - return _url_for_thread_comments(params['thread_id']) + return _url_for_thread_comments(params["thread_id"]) @classmethod def url(cls, action, params=None): if params is None: params = {} - if action in ['post']: + if action in ["post"]: return cls.url_for_comments(params) else: return super().url(action, params) def flagAbuse(self, user, voteable, course_id=None): - if voteable.type != 'comment': + if voteable.type != "comment": raise CommentClientRequestError("Can only flag comments") course_key = get_course_key(self.attributes.get("course_id") or course_id) @@ -84,7 +118,7 @@ def flagAbuse(self, user, voteable, course_id=None): voteable._update_from_response(response) def unFlagAbuse(self, user, voteable, removeAll, course_id=None): - if voteable.type != 'comment': + if voteable.type != "comment": raise CommentClientRequestError("Can only unflag comments") course_key = get_course_key(self.attributes.get("course_id") or course_id) @@ -102,7 +136,7 @@ def body_text(self): """ Return the text content of the comment html body. """ - soup = BeautifulSoup(self.body, 'html.parser') + soup = BeautifulSoup(self.body, "html.parser") return soup.get_text() @classmethod @@ -114,12 +148,15 @@ def get_user_comment_count(cls, user_id, course_ids): query_params = { "course_id": {"$in": course_ids}, "author_id": str(user_id), - "_type": "Comment" + "is_deleted": {"$ne": True}, + "_type": "Comment", } - return ForumComment()._collection.count_documents(query_params) # pylint: disable=protected-access + return ForumComment()._collection.count_documents( + query_params + ) # pylint: disable=protected-access @classmethod - def delete_user_comments(cls, user_id, course_ids): + def delete_user_comments(cls, user_id, course_ids, deleted_by=None): """ Deletes comments and responses of user in the given course_ids. TODO: Add support for MySQL backend as well @@ -128,21 +165,66 @@ def delete_user_comments(cls, user_id, course_ids): query_params = { "course_id": {"$in": course_ids}, "author_id": str(user_id), + "is_deleted": {"$ne": True}, } comments_deleted = 0 comments = ForumComment().get_list(**query_params) - log.info(f"<> Fetched comments for user {user_id} in {time.time() - start_time} seconds") + log.info( + f"<> Fetched comments for user {user_id} in {time.time() - start_time} seconds" + ) for comment in comments: start_time = time.time() comment_id = comment.get("_id") course_id = comment.get("course_id") if comment_id: - forum_api.delete_comment(comment_id, course_id=course_id) + # Use forum_api.delete_comment which supports deleted_by parameter + forum_api.delete_comment( # pylint: disable=unexpected-keyword-arg + comment_id, course_id=course_id, deleted_by=deleted_by + ) comments_deleted += 1 - log.info(f"<> Deleted comment {comment_id} in {time.time() - start_time} seconds." - f" Comment Found: {comment_id is not None}") + log.info( + f"<> Deleted comment {comment_id} in {time.time() - start_time} seconds." + f" Comment Found: {comment_id is not None}" + ) return comments_deleted + @classmethod + def get_user_deleted_comment_count(cls, user_id, course_ids): + """ + Returns count of deleted comments for user in the given course_ids. + """ + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + "_type": "Comment", + "is_deleted": True, + } + return ForumComment()._collection.count_documents( + query_params + ) # pylint: disable=protected-access + + @classmethod + def restore_user_deleted_comments(cls, user_id, course_ids, restored_by=None): + """ + Restores (undeletes) comments of user in the given course_ids by setting is_deleted=False. + """ + return forum_api.restore_user_deleted_comments( + user_id=str(user_id), + course_ids=course_ids, + course_id=course_ids[0] if course_ids else None, + restored_by=restored_by, + ) + + @classmethod + def restore_comment(cls, comment_id, course_id=None, restored_by=None): + """ + Restores an individual soft-deleted comment by setting is_deleted=False + Public method for individual comment restoration + """ + return forum_api.restore_comment( + comment_id=comment_id, course_id=course_id, restored_by=restored_by + ) + def _url_for_thread_comments(thread_id): return f"{settings.PREFIX}/threads/{thread_id}/comments" diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/models.py b/openedx/core/djangoapps/django_comment_common/comment_client/models.py index 4544a463ed80..ddfcc37cc524 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/models.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/models.py @@ -4,24 +4,28 @@ import logging import typing as t -from .utils import CommentClientRequestError, extract, perform_request, get_course_key from forum import api as forum_api -from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled, is_forum_v2_disabled_globally +from openedx.core.djangoapps.discussions.config.waffle import ( + is_forum_v2_disabled_globally, + is_forum_v2_enabled, +) + +from .utils import CommentClientRequestError, extract, get_course_key, perform_request log = logging.getLogger(__name__) class Model: - accessible_fields = ['id'] - updatable_fields = ['id'] - initializable_fields = ['id'] + accessible_fields = ["id"] + updatable_fields = ["id"] + initializable_fields = ["id"] base_url = None default_retrieve_params = {} metric_tag_fields = [] - DEFAULT_ACTIONS_WITH_ID = ['get', 'put', 'delete'] - DEFAULT_ACTIONS_WITHOUT_ID = ['get_all', 'post'] + DEFAULT_ACTIONS_WITH_ID = ["get", "put", "delete"] + DEFAULT_ACTIONS_WITHOUT_ID = ["get_all", "post"] DEFAULT_ACTIONS = DEFAULT_ACTIONS_WITH_ID + DEFAULT_ACTIONS_WITHOUT_ID def __init__(self, *args, **kwargs): @@ -29,18 +33,21 @@ def __init__(self, *args, **kwargs): self.retrieved = False def __getattr__(self, name): - if name == 'id': - return self.attributes.get('id', None) + if name == "id": + return self.attributes.get("id", None) try: return self.attributes[name] - except KeyError: + except KeyError as e: if self.retrieved or self.id is None: - raise AttributeError(f"Field {name} does not exist") # lint-amnesty, pylint: disable=raise-missing-from + raise AttributeError(f"Field {name} does not exist") from e self.retrieve() return self.__getattr__(name) def __setattr__(self, name, value): - if name == 'attributes' or name not in self.accessible_fields + self.updatable_fields: + if ( + name == "attributes" + or name not in self.accessible_fields + self.updatable_fields + ): super().__setattr__(name, value) else: self.attributes[name] = value @@ -76,7 +83,9 @@ def _retrieve(self, *args, **kwargs): if not course_id: _, course_id = is_forum_v2_enabled_for_comment(self.id) if self.type == "comment": - response = forum_api.get_parent_comment(comment_id=self.attributes["id"], course_id=course_id) + response = forum_api.get_parent_comment( + comment_id=self.attributes["id"], course_id=course_id + ) else: raise CommentClientRequestError("Forum v2 API call is missing") self._update_from_response(response) @@ -91,11 +100,11 @@ def _metric_tags(self): record the class name of the model. """ tags = [ - f'{self.__class__.__name__}.{attr}:{self[attr]}' + f"{self.__class__.__name__}.{attr}:{self[attr]}" for attr in self.metric_tag_fields if attr in self.attributes ] - tags.append(f'model_class:{self.__class__.__name__}') + tags.append(f"model_class:{self.__class__.__name__}") return tags @classmethod @@ -114,11 +123,11 @@ def retrieve_all(cls, params=None): The parsed JSON response from the backend. """ return perform_request( - 'get', - cls.url(action='get_all'), + "get", + cls.url(action="get_all"), params, - metric_tags=[f'model_class:{cls.__name__}'], - metric_action='model.retrieve_all', + metric_tags=[f"model_class:{cls.__name__}"], + metric_action="model.retrieve_all", ) def _update_from_response(self, response_data): @@ -128,8 +137,7 @@ def _update_from_response(self, response_data): else: log.warning( "Unexpected field {field_name} in model {model_name}".format( - field_name=k, - model_name=self.__class__.__name__ + field_name=k, model_name=self.__class__.__name__ ) ) @@ -152,7 +160,7 @@ def save(self, params=None): Invokes Forum's POST/PUT service to create/update thread """ self.before_save(self) - if self.id: # if we have id already, treat this as an update + if self.id: # if we have id already, treat this as an update response = self.handle_update(params) else: # otherwise, treat this as an insert response = self.handle_create(params) @@ -160,13 +168,25 @@ def save(self, params=None): self._update_from_response(response) self.after_save(self) - def delete(self, course_id=None): + def delete(self, course_id=None, deleted_by=None): course_key = get_course_key(self.attributes.get("course_id") or course_id) response = None if self.type == "comment": - response = forum_api.delete_comment(comment_id=self.attributes["id"], course_id=str(course_key)) + response = ( + forum_api.delete_comment( # pylint: disable=unexpected-keyword-arg + comment_id=self.attributes["id"], + course_id=str(course_key), + deleted_by=deleted_by, + ) + ) elif self.type == "thread": - response = forum_api.delete_thread(thread_id=self.attributes["id"], course_id=str(course_key)) + response = ( + forum_api.delete_thread( # pylint: disable=unexpected-keyword-arg + thread_id=self.attributes["id"], + course_id=str(course_key), + deleted_by=deleted_by, + ) + ) if response is None: raise CommentClientRequestError("Forum v2 API call is missing") self.retrieved = True @@ -176,7 +196,7 @@ def delete(self, course_id=None): def url_with_id(cls, params=None): if params is None: params = {} - return cls.base_url + '/' + str(params['id']) + return cls.base_url + "/" + str(params["id"]) @classmethod def url_without_id(cls, params=None): @@ -187,17 +207,21 @@ def url(cls, action, params=None): if params is None: params = {} if cls.base_url is None: - raise CommentClientRequestError("Must provide base_url when using default url function") - if action not in cls.DEFAULT_ACTIONS: # lint-amnesty, pylint: disable=no-else-raise + raise CommentClientRequestError( + "Must provide base_url when using default url function" + ) + if action not in cls.DEFAULT_ACTIONS: raise ValueError( f"Invalid action {action}. The supported action must be in {str(cls.DEFAULT_ACTIONS)}" ) - elif action in cls.DEFAULT_ACTIONS_WITH_ID: + if action in cls.DEFAULT_ACTIONS_WITH_ID: try: return cls.url_with_id(params) - except KeyError: - raise CommentClientRequestError(f"Cannot perform action {action} without id") # lint-amnesty, pylint: disable=raise-missing-from - else: # action must be in DEFAULT_ACTIONS_WITHOUT_ID now + except KeyError as e: + raise CommentClientRequestError( + f"Cannot perform action {action} without id" + ) from e + else: # action must be in DEFAULT_ACTIONS_WITHOUT_ID now return cls.url_without_id() def handle_update(self, params=None): @@ -306,8 +330,8 @@ def handle_create(self, params=None): try: return handlers[self.type](course_key) - except KeyError as exc: - raise CommentClientRequestError(f"Unsupported type: {self.type}") from exc + except KeyError as e: + raise CommentClientRequestError(f"Unsupported type: {self.type}") from e def handle_create_comment(self, course_id): request_data = self.initializable_attributes() @@ -319,8 +343,8 @@ def handle_create_comment(self, course_id): "anonymous": request_data.get("anonymous", False), "anonymous_to_peers": request_data.get("anonymous_to_peers", False), } - if 'endorsed' in request_data: - params['endorsed'] = request_data['endorsed'] + if "endorsed" in request_data: + params["endorsed"] = request_data["endorsed"] if parent_id := self.attributes.get("parent_id"): params["parent_comment_id"] = parent_id response = forum_api.create_child_comment(**params) diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py index 34ccd7bf2ce6..754fe0065f00 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py @@ -5,50 +5,104 @@ import time import typing as t +from django.core.exceptions import ObjectDoesNotExist from eventtracking import tracker +from rest_framework.serializers import ValidationError -from django.core.exceptions import ObjectDoesNotExist from forum import api as forum_api -from forum.api.threads import prepare_thread_api_response -from forum.backend import get_backend -from forum.backends.mongodb.threads import CommentThread -from forum.utils import ForumV2RequestError -from rest_framework.serializers import ValidationError +from forum.api.threads import ( + prepare_thread_api_response, +) # pylint: disable=import-error +from forum.backend import get_backend # pylint: disable=import-error +from forum.backends.mongodb.threads import CommentThread # pylint: disable=import-error +from forum.utils import ForumV2RequestError # pylint: disable=import-error +from openedx.core.djangoapps.discussions.config.waffle import ( + is_forum_v2_disabled_globally, + is_forum_v2_enabled, +) -from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled, is_forum_v2_disabled_globally from . import models, settings, utils - log = logging.getLogger(__name__) class Thread(models.Model): # accessible_fields can be set and retrieved on the model accessible_fields = [ - 'id', 'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', - 'closed', 'tags', 'votes', 'commentable_id', 'username', 'user_id', - 'created_at', 'updated_at', 'comments_count', 'unread_comments_count', - 'at_position_list', 'children', 'type', 'highlighted_title', - 'highlighted_body', 'endorsed', 'read', 'group_id', 'group_name', 'pinned', - 'abuse_flaggers', 'resp_skip', 'resp_limit', 'resp_total', 'thread_type', - 'endorsed_responses', 'non_endorsed_responses', 'non_endorsed_resp_total', - 'context', 'last_activity_at', 'closed_by', 'close_reason_code', 'edit_history', - 'is_spam', 'ai_moderation_reason', 'abuse_flagged', + "id", + "title", + "body", + "anonymous", + "anonymous_to_peers", + "course_id", + "closed", + "tags", + "votes", + "commentable_id", + "username", + "user_id", + "created_at", + "updated_at", + "comments_count", + "unread_comments_count", + "at_position_list", + "children", + "type", + "highlighted_title", + "highlighted_body", + "endorsed", + "read", + "group_id", + "group_name", + "pinned", + "abuse_flaggers", + "resp_skip", + "resp_limit", + "resp_total", + "thread_type", + "endorsed_responses", + "non_endorsed_responses", + "non_endorsed_resp_total", + "context", + "last_activity_at", + "closed_by", + "close_reason_code", + "edit_history", + "is_spam", + "ai_moderation_reason", + "abuse_flagged", + "is_deleted", + "deleted_at", + "deleted_by", ] # updateable_fields are sent in PUT requests updatable_fields = [ - 'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'read', - 'closed', 'user_id', 'commentable_id', 'group_id', 'group_name', 'pinned', 'thread_type', - 'close_reason_code', 'edit_reason_code', 'closing_user_id', 'editing_user_id', + "title", + "body", + "anonymous", + "anonymous_to_peers", + "course_id", + "read", + "closed", + "user_id", + "commentable_id", + "group_id", + "group_name", + "pinned", + "thread_type", + "close_reason_code", + "edit_reason_code", + "closing_user_id", + "editing_user_id", ] # initializable_fields are sent in POST requests - initializable_fields = updatable_fields + ['thread_type', 'context'] + initializable_fields = updatable_fields + ["thread_type", "context"] base_url = f"{settings.PREFIX}/threads" - default_retrieve_params = {'recursive': False} - type = 'thread' + default_retrieve_params = {"recursive": False} + type = "thread" @classmethod def search(cls, query_params): @@ -58,82 +112,83 @@ def search(cls, query_params): # with_responses=False internally in the comment service, so no additional # optimization is required. params = { - 'page': 1, - 'per_page': 20, - 'course_id': query_params['course_id'], + "page": 1, + "per_page": 20, + "course_id": query_params["course_id"], } - params.update( - utils.strip_blank(utils.strip_none(query_params)) - ) + params.update(utils.strip_blank(utils.strip_none(query_params))) # Convert user_id and author_id to strings if present - for field in ['user_id', 'author_id']: + for field in ["user_id", "author_id"]: if value := params.get(field): params[field] = str(value) # Handle commentable_ids/commentable_id conversion - if commentable_ids := params.get('commentable_ids'): - params['commentable_ids'] = commentable_ids.split(',') - elif commentable_id := params.get('commentable_id'): - params['commentable_ids'] = [commentable_id] - params.pop('commentable_id', None) - + if commentable_ids := params.get("commentable_ids"): + params["commentable_ids"] = commentable_ids.split(",") + elif commentable_id := params.get("commentable_id"): + params["commentable_ids"] = [commentable_id] + params.pop("commentable_id", None) + if query_params.get("show_deleted", False): + params["is_deleted"] = True params = utils.clean_forum_params(params) - if query_params.get('text'): # Handle group_ids/group_id conversion - if group_ids := params.get('group_ids'): - params['group_ids'] = [int(group_id) for group_id in group_ids.split(',')] - elif group_id := params.get('group_id'): - params['group_ids'] = [int(group_id)] - params.pop('group_id', None) + if query_params.get("text"): # Handle group_ids/group_id conversion + if group_ids := params.get("group_ids"): + params["group_ids"] = [ + int(group_id) for group_id in group_ids.split(",") + ] + elif group_id := params.get("group_id"): + params["group_ids"] = [int(group_id)] + params.pop("group_id", None) response = forum_api.search_threads(**params) else: response = forum_api.get_user_threads(**params) - if query_params.get('text'): - search_query = query_params['text'] - course_id = query_params['course_id'] - group_id = query_params['group_id'] if 'group_id' in query_params else None - requested_page = params['page'] - total_results = response.get('total_results') - corrected_text = response.get('corrected_text') + if query_params.get("text"): + search_query = query_params["text"] + course_id = query_params["course_id"] + group_id = query_params["group_id"] if "group_id" in query_params else None + requested_page = params["page"] + total_results = response.get("total_results") + corrected_text = response.get("corrected_text") # Record search result metric to allow search quality analysis. # course_id is already included in the context for the event tracker tracker.emit( - 'edx.forum.searched', + "edx.forum.searched", { - 'query': search_query, - 'search_type': 'Content', - 'corrected_text': corrected_text, - 'group_id': group_id, - 'page': requested_page, - 'total_results': total_results, - } + "query": search_query, + "search_type": "Content", + "corrected_text": corrected_text, + "group_id": group_id, + "page": requested_page, + "total_results": total_results, + }, ) log.info( 'forum_text_search query="{search_query}" corrected_text="{corrected_text}" course_id={course_id} ' - 'group_id={group_id} page={requested_page} total_results={total_results}'.format( + "group_id={group_id} page={requested_page} total_results={total_results}".format( search_query=search_query, corrected_text=corrected_text, course_id=course_id, group_id=group_id, requested_page=requested_page, - total_results=total_results + total_results=total_results, ) ) return utils.CommentClientPaginatedResult( - collection=response.get('collection', []), - page=response.get('page', 1), - num_pages=response.get('num_pages', 1), - thread_count=response.get('thread_count', 0), - corrected_text=response.get('corrected_text', None) + collection=response.get("collection", []), + page=response.get("page", 1), + num_pages=response.get("num_pages", 1), + thread_count=response.get("thread_count", 0), + corrected_text=response.get("corrected_text", None), ) @classmethod def url_for_threads(cls, params=None): - if params and params.get('commentable_id'): + if params and params.get("commentable_id"): return "{prefix}/{commentable_id}/threads".format( prefix=settings.PREFIX, - commentable_id=params['commentable_id'], + commentable_id=params["commentable_id"], ) else: return f"{settings.PREFIX}/threads" @@ -146,9 +201,9 @@ def url_for_search_threads(cls): def url(cls, action, params=None): if params is None: params = {} - if action in ['get_all', 'post']: + if action in ["get_all", "post"]: return cls.url_for_threads(params) - elif action == 'search': + elif action == "search": return cls.url_for_search_threads() else: return super().url(action, params) @@ -158,21 +213,23 @@ def url(cls, action, params=None): # that subclasses don't need to override for this. def _retrieve(self, *args, **kwargs): request_params = { - 'recursive': kwargs.get('recursive'), - 'with_responses': kwargs.get('with_responses', False), - 'user_id': kwargs.get('user_id'), - 'mark_as_read': kwargs.get('mark_as_read', True), - 'resp_skip': kwargs.get('response_skip'), - 'resp_limit': kwargs.get('response_limit'), - 'reverse_order': kwargs.get('reverse_order', False), - 'merge_question_type_responses': kwargs.get('merge_question_type_responses', False) + "recursive": kwargs.get("recursive"), + "with_responses": kwargs.get("with_responses", False), + "user_id": kwargs.get("user_id"), + "mark_as_read": kwargs.get("mark_as_read", True), + "resp_skip": kwargs.get("response_skip"), + "resp_limit": kwargs.get("response_limit"), + "reverse_order": kwargs.get("reverse_order", False), + "merge_question_type_responses": kwargs.get( + "merge_question_type_responses", False + ), } request_params = utils.clean_forum_params(request_params) course_id = kwargs.get("course_id") if not course_id: _, course_id = is_forum_v2_enabled_for_thread(self.id) - if user_id := request_params.get('user_id'): - request_params['user_id'] = str(user_id) + if user_id := request_params.get("user_id"): + request_params["user_id"] = str(user_id) response = forum_api.get_thread( thread_id=self.id, params=request_params, @@ -181,7 +238,7 @@ def _retrieve(self, *args, **kwargs): self._update_from_response(response) def flagAbuse(self, user, voteable, course_id=None): - if voteable.type != 'thread': + if voteable.type != "thread": raise utils.CommentClientRequestError("Can only flag threads") course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) @@ -189,12 +246,12 @@ def flagAbuse(self, user, voteable, course_id=None): thread_id=voteable.id, action="flag", user_id=str(user.id), - course_id=str(course_key) + course_id=str(course_key), ) voteable._update_from_response(response) def unFlagAbuse(self, user, voteable, removeAll, course_id=None): - if voteable.type != 'thread': + if voteable.type != "thread": raise utils.CommentClientRequestError("Can only unflag threads") course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) @@ -203,7 +260,7 @@ def unFlagAbuse(self, user, voteable, removeAll, course_id=None): action="unflag", user_id=user.id, update_all=bool(removeAll), - course_id=str(course_key) + course_id=str(course_key), ) voteable._update_from_response(response) @@ -211,18 +268,14 @@ def unFlagAbuse(self, user, voteable, removeAll, course_id=None): def pin(self, user, thread_id, course_id=None): course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) response = forum_api.pin_thread( - user_id=user.id, - thread_id=thread_id, - course_id=str(course_key) + user_id=user.id, thread_id=thread_id, course_id=str(course_key) ) self._update_from_response(response) def un_pin(self, user, thread_id, course_id=None): course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) response = forum_api.unpin_thread( - user_id=user.id, - thread_id=thread_id, - course_id=str(course_key) + user_id=user.id, thread_id=thread_id, course_id=str(course_key) ) self._update_from_response(response) @@ -235,12 +288,15 @@ def get_user_threads_count(cls, user_id, course_ids): query_params = { "course_id": {"$in": course_ids}, "author_id": str(user_id), - "_type": "CommentThread" + "is_deleted": {"$ne": True}, + "_type": "CommentThread", } - return CommentThread()._collection.count_documents(query_params) # pylint: disable=protected-access + return CommentThread()._collection.count_documents( + query_params + ) # pylint: disable=protected-access @classmethod - def _delete_thread(cls, thread_id, course_id=None): + def _delete_thread(cls, thread_id, course_id=None, deleted_by=None): """ Deletes a thread """ @@ -257,34 +313,53 @@ def _delete_thread(cls, thread_id, course_id=None): ) from exc start_time = time.perf_counter() - backend.delete_comments_of_a_thread(thread_id) - log.info(f"{prefix} Delete comments of thread {time.perf_counter() - start_time} sec") + # backend.delete_comments_of_a_thread(thread_id) + count_of_response_deleted, count_of_replies_deleted = ( + backend.soft_delete_comments_of_a_thread(thread_id, deleted_by) + ) + log.info( + f"{prefix} Delete comments of thread {time.perf_counter() - start_time} sec" + ) try: start_time = time.perf_counter() serialized_data = prepare_thread_api_response(thread, backend) - log.info(f"{prefix} Prepare response {time.perf_counter() - start_time} sec") + log.info( + f"{prefix} Prepare response {time.perf_counter() - start_time} sec" + ) except ValidationError as error: log.error(f"Validation error in get_thread: {error}") - raise ForumV2RequestError("Failed to prepare thread API response") from error + raise ForumV2RequestError( + "Failed to prepare thread API response" + ) from error start_time = time.perf_counter() backend.delete_subscriptions_of_a_thread(thread_id) - log.info(f"{prefix} Delete subscriptions {time.perf_counter() - start_time} sec") + log.info( + f"{prefix} Delete subscriptions {time.perf_counter() - start_time} sec" + ) start_time = time.perf_counter() - result = backend.delete_thread(thread_id) + # result = backend.delete_thread(thread_id) + result = backend.soft_delete_thread(thread_id, deleted_by) log.info(f"{prefix} Delete thread {time.perf_counter() - start_time} sec") if result and not (thread["anonymous"] or thread["anonymous_to_peers"]): start_time = time.perf_counter() backend.update_stats_for_course( - thread["author_id"], thread["course_id"], threads=-1 + thread["author_id"], + thread["course_id"], + threads=-1, + responses=-count_of_response_deleted, + replies=-count_of_replies_deleted, + deleted_threads=1, + deleted_responses=count_of_response_deleted, + deleted_replies=count_of_replies_deleted, ) log.info(f"{prefix} Update stats {time.perf_counter() - start_time} sec") return serialized_data @classmethod - def delete_user_threads(cls, user_id, course_ids): + def delete_user_threads(cls, user_id, course_ids, deleted_by=None): """ Deletes threads of user in the given course_ids. TODO: Add support for MySQL backend as well @@ -293,21 +368,65 @@ def delete_user_threads(cls, user_id, course_ids): query_params = { "course_id": {"$in": course_ids}, "author_id": str(user_id), + "is_deleted": {"$ne": True}, } threads_deleted = 0 threads = CommentThread().get_list(**query_params) - log.info(f"<> Fetched threads for user {user_id} in {time.time() - start_time} seconds") + log.info( + f"<> Fetched threads for user {user_id} in {time.time() - start_time} seconds" + ) for thread in threads: start_time = time.time() thread_id = thread.get("_id") course_id = thread.get("course_id") if thread_id: - cls._delete_thread(thread_id, course_id=course_id) + cls._delete_thread( + thread_id, course_id=course_id, deleted_by=deleted_by + ) threads_deleted += 1 - log.info(f"<> Deleted thread {thread_id} in {time.time() - start_time} seconds." - f" Thread Found: {thread_id is not None}") + log.info( + f"<> Deleted thread {thread_id} in {time.time() - start_time} seconds." + f" Thread Found: {thread_id is not None}" + ) return threads_deleted + @classmethod + def get_user_deleted_threads_count(cls, user_id, course_ids): + """ + Returns count of deleted threads for user in the given course_ids. + """ + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + "_type": "CommentThread", + "is_deleted": True, + } + return CommentThread()._collection.count_documents( + query_params + ) # pylint: disable=protected-access + + @classmethod + def restore_user_deleted_threads(cls, user_id, course_ids, restored_by=None): + """ + Restores (undeletes) threads of user in the given course_ids by setting is_deleted=False. + """ + return forum_api.restore_user_deleted_threads( + user_id=str(user_id), + course_ids=course_ids, + course_id=course_ids[0] if course_ids else None, + restored_by=restored_by, + ) + + @classmethod + def restore_thread(cls, thread_id, course_id=None, restored_by=None): + """ + Restores an individual soft-deleted thread by setting is_deleted=False + Public method for individual thread restoration + """ + return forum_api.restore_thread( + thread_id=thread_id, course_id=course_id, restored_by=restored_by + ) + def _url_for_flag_abuse_thread(thread_id): return f"{settings.PREFIX}/threads/{thread_id}/abuse_flag" From 5af58a5b899ee77423ccbc979dd628c77d06ac57 Mon Sep 17 00:00:00 2001 From: Nathan Sprenkle Date: Tue, 6 Jan 2026 13:21:40 -0500 Subject: [PATCH 46/54] feat: shift progress calculation to backend (openedx#37399) (#69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit migrates the data calculation logic for the GradeSummary table, which was previously in the frontend-app-learning. This commit also introduces a new visibility option for assignment scores: “Never show individual assessment results, but show overall assessment results after the due date.” With this option, learners cannot see question-level correctness or scores at any time. However, once the due date has passed, they can view their overall score in the total grades section on the Progress page. These two changes are coupled with each other because it compromises the integrity of this data to do the score hiding logic on the front end. The corresponding frontend PR is: openedx/frontend-app-learning#1797 Co-authored-by: Muhammad Anas <88967643+Anas12091101@users.noreply.github.com> --- .../js/show-correctness-editor.underscore | 7 + .../course_home_api/progress/api.py | 212 ++++++++++++++++++ .../course_home_api/progress/serializers.py | 17 ++ .../progress/tests/test_api.py | 109 ++++++++- .../progress/tests/test_views.py | 10 +- .../course_home_api/progress/views.py | 30 ++- lms/djangoapps/courseware/tests/test_views.py | 27 ++- lms/djangoapps/grades/subsection_grade.py | 9 +- lms/templates/courseware/progress.html | 9 +- xmodule/graders.py | 3 +- xmodule/tests/test_graders.py | 8 + 11 files changed, 429 insertions(+), 12 deletions(-) diff --git a/cms/templates/js/show-correctness-editor.underscore b/cms/templates/js/show-correctness-editor.underscore index 3db6c3c27a5c..1b0dd896747a 100644 --- a/cms/templates/js/show-correctness-editor.underscore +++ b/cms/templates/js/show-correctness-editor.underscore @@ -35,6 +35,13 @@ <% } %> <%- gettext('If the subsection does not have a due date, learners always see their scores when they submit answers to assessments.') %>

+ +

+ <%- gettext('Learners do not see question-level correctness or scores before or after the due date. However, once the due date passes, they can see their overall score for the subsection on the Progress page.') %> +

diff --git a/lms/djangoapps/course_home_api/progress/api.py b/lms/djangoapps/course_home_api/progress/api.py index b2a8634c59f4..f89ecd3d2596 100644 --- a/lms/djangoapps/course_home_api/progress/api.py +++ b/lms/djangoapps/course_home_api/progress/api.py @@ -2,14 +2,226 @@ Python APIs exposed for the progress tracking functionality of the course home API. """ +from __future__ import annotations + from django.contrib.auth import get_user_model from opaque_keys.edx.keys import CourseKey +from openedx.core.lib.grade_utils import round_away_from_zero +from xmodule.graders import ShowCorrectness +from datetime import datetime, timezone from lms.djangoapps.courseware.courses import get_course_blocks_completion_summary +from dataclasses import dataclass, field User = get_user_model() +@dataclass +class _AssignmentBucket: + """Holds scores and visibility info for one assignment type. + + Attributes: + assignment_type: Full assignment type name from the grading policy (for example, "Homework"). + num_total: The total number of assignments expected to contribute to the grade before any + drop-lowest rules are applied. + last_grade_publish_date: The most recent date when grades for all assignments of assignment_type + are released and included in the final grade. + scores: Per-subsection fractional scores (each value is ``earned / possible`` and falls in + the range 0–1). While awaiting published content we pad the list with zero placeholders + so that its length always matches ``num_total`` until real scores replace them. + visibilities: Mirrors ``scores`` index-for-index and records whether each subsection's + correctness feedback is visible to the learner (``True``), hidden (``False``), or not + yet populated (``None`` when the entry is a placeholder). + included: Tracks whether each subsection currently counts toward the learner's grade as + determined by ``SubsectionGrade.show_grades``. Values follow the same convention as + ``visibilities`` (``True`` / ``False`` / ``None`` placeholders). + assignments_created: Count of real subsections inserted into the bucket so far. Once this + reaches ``num_total``, all placeholder entries have been replaced with actual data. + """ + assignment_type: str + num_total: int + last_grade_publish_date: datetime + scores: list[float] = field(default_factory=list) + visibilities: list[bool | None] = field(default_factory=list) + included: list[bool | None] = field(default_factory=list) + assignments_created: int = 0 + + @classmethod + def with_placeholders(cls, assignment_type: str, num_total: int, now: datetime): + """Create a bucket prefilled with placeholder (empty) entries.""" + return cls( + assignment_type=assignment_type, + num_total=num_total, + last_grade_publish_date=now, + scores=[0] * num_total, + visibilities=[None] * num_total, + included=[None] * num_total, + ) + + def add_subsection(self, score: float, is_visible: bool, is_included: bool): + """Add a subsection’s score and visibility, replacing a placeholder if space remains.""" + if self.assignments_created < self.num_total: + if self.scores: + self.scores.pop(0) + if self.visibilities: + self.visibilities.pop(0) + if self.included: + self.included.pop(0) + self.scores.append(score) + self.visibilities.append(is_visible) + self.included.append(is_included) + self.assignments_created += 1 + + def drop_lowest(self, num_droppable: int): + """Remove the lowest scoring subsections, up to the provided num_droppable.""" + while num_droppable > 0 and self.scores: + idx = self.scores.index(min(self.scores)) + self.scores.pop(idx) + self.visibilities.pop(idx) + self.included.pop(idx) + num_droppable -= 1 + + def hidden_state(self) -> str: + """Return whether kept scores are all, some, or none hidden.""" + if not self.visibilities: + return 'none' + all_hidden = all(v is False for v in self.visibilities) + some_hidden = any(v is False for v in self.visibilities) + if all_hidden: + return 'all' + if some_hidden: + return 'some' + return 'none' + + def averages(self) -> tuple[float, float]: + """Compute visible and included averages over kept scores. + + Visible average uses only grades with visibility flag True in numerator; denominator is total + number of kept scores (mirrors legacy behavior). Included average uses only scores that are + marked included (show_grades True) in numerator with same denominator. + + Returns: + (earned_visible, earned_all) tuple of floats (0-1 each). + """ + if not self.scores: + return 0.0, 0.0 + visible_scores = [s for i, s in enumerate(self.scores) if self.visibilities[i]] + included_scores = [s for i, s in enumerate(self.scores) if self.included[i]] + earned_visible = (sum(visible_scores) / len(self.scores)) if self.scores else 0.0 + earned_all = (sum(included_scores) / len(self.scores)) if self.scores else 0.0 + return earned_visible, earned_all + + +class _AssignmentTypeGradeAggregator: + """Collects and aggregates subsection grades by assignment type.""" + + def __init__(self, course_grade, grading_policy: dict, has_staff_access: bool): + """Initialize with course grades, grading policy, and staff access flag.""" + self.course_grade = course_grade + self.grading_policy = grading_policy + self.has_staff_access = has_staff_access + self.now = datetime.now(timezone.utc) + self.policy_map = self._build_policy_map() + self.buckets: dict[str, _AssignmentBucket] = {} + + def _build_policy_map(self) -> dict: + """Convert grading policy into a lookup of assignment type → policy info.""" + policy_map = {} + for policy in self.grading_policy.get('GRADER', []): + policy_map[policy.get('type')] = { + 'weight': policy.get('weight', 0.0), + 'short_label': policy.get('short_label', ''), + 'num_droppable': policy.get('drop_count', 0), + 'num_total': policy.get('min_count', 0), + } + return policy_map + + def _bucket_for(self, assignment_type: str) -> _AssignmentBucket: + """Get or create a score bucket for the given assignment type.""" + bucket = self.buckets.get(assignment_type) + if bucket is None: + num_total = self.policy_map.get(assignment_type, {}).get('num_total', 0) or 0 + bucket = _AssignmentBucket.with_placeholders(assignment_type, num_total, self.now) + self.buckets[assignment_type] = bucket + return bucket + + def collect(self): + """Gather subsection grades into their respective assignment buckets.""" + for chapter in self.course_grade.chapter_grades.values(): + for subsection_grade in chapter.get('sections', []): + if not getattr(subsection_grade, 'graded', False): + continue + assignment_type = getattr(subsection_grade, 'format', '') or '' + if not assignment_type: + continue + graded_total = getattr(subsection_grade, 'graded_total', None) + earned = getattr(graded_total, 'earned', 0.0) if graded_total else 0.0 + possible = getattr(graded_total, 'possible', 0.0) if graded_total else 0.0 + earned = 0.0 if earned is None else earned + possible = 0.0 if possible is None else possible + score = (earned / possible) if possible else 0.0 + is_visible = ShowCorrectness.correctness_available( + subsection_grade.show_correctness, subsection_grade.due, self.has_staff_access + ) + is_included = subsection_grade.show_grades(self.has_staff_access) + bucket = self._bucket_for(assignment_type) + bucket.add_subsection(score, is_visible, is_included) + visibilities_with_due_dates = [ShowCorrectness.PAST_DUE, ShowCorrectness.NEVER_BUT_INCLUDE_GRADE] + if subsection_grade.show_correctness in visibilities_with_due_dates: + if subsection_grade.due and subsection_grade.due > bucket.last_grade_publish_date: + bucket.last_grade_publish_date = subsection_grade.due + + def build_results(self) -> dict: + """Apply drops, compute averages, and return aggregated results and total grade.""" + final_grades = 0.0 + rows = [] + for assignment_type, bucket in self.buckets.items(): + policy = self.policy_map.get(assignment_type, {}) + bucket.drop_lowest(policy.get('num_droppable', 0)) + earned_visible, earned_all = bucket.averages() + weight = policy.get('weight', 0.0) + short_label = policy.get('short_label', '') + row = { + 'type': assignment_type, + 'weight': weight, + 'average_grade': round_away_from_zero(earned_visible, 4), + 'weighted_grade': round_away_from_zero(earned_visible * weight, 4), + 'short_label': short_label, + 'num_droppable': policy.get('num_droppable', 0), + 'last_grade_publish_date': bucket.last_grade_publish_date, + 'has_hidden_contribution': bucket.hidden_state(), + } + final_grades += earned_all * weight + rows.append(row) + rows.sort(key=lambda r: r['weight']) + return {'results': rows, 'final_grades': round_away_from_zero(final_grades, 4)} + + def run(self) -> dict: + """Execute full pipeline (collect + aggregate) returning final payload.""" + self.collect() + return self.build_results() + + +def aggregate_assignment_type_grade_summary( + course_grade, + grading_policy: dict, + has_staff_access: bool = False, +) -> dict: + """ + Aggregate subsection grades by assignment type and return summary data. + Args: + course_grade: CourseGrade object containing chapter and subsection grades. + grading_policy: Dictionary representing the course's grading policy. + has_staff_access: Boolean indicating if the user has staff access to view all grades. + Returns: + Dictionary with keys: + results: list of per-assignment-type summary dicts + final_grades: overall weighted contribution (float, 4 decimal rounding) + """ + aggregator = _AssignmentTypeGradeAggregator(course_grade, grading_policy, has_staff_access) + return aggregator.run() + + def calculate_progress_for_learner_in_course(course_key: CourseKey, user: User) -> dict: """ Calculate a given learner's progress in the specified course run. diff --git a/lms/djangoapps/course_home_api/progress/serializers.py b/lms/djangoapps/course_home_api/progress/serializers.py index 6bdc204434af..c48660a41c6a 100644 --- a/lms/djangoapps/course_home_api/progress/serializers.py +++ b/lms/djangoapps/course_home_api/progress/serializers.py @@ -26,6 +26,7 @@ class SubsectionScoresSerializer(ReadOnlySerializer): assignment_type = serializers.CharField(source='format') block_key = serializers.SerializerMethodField() display_name = serializers.CharField() + due = serializers.DateTimeField(allow_null=True) has_graded_assignment = serializers.BooleanField(source='graded') override = serializers.SerializerMethodField() learner_has_access = serializers.SerializerMethodField() @@ -127,6 +128,20 @@ class VerificationDataSerializer(ReadOnlySerializer): status_date = serializers.DateTimeField() +class AssignmentTypeScoresSerializer(ReadOnlySerializer): + """ + Serializer for aggregated scores per assignment type. + """ + type = serializers.CharField() + weight = serializers.FloatField() + average_grade = serializers.FloatField() + weighted_grade = serializers.FloatField() + last_grade_publish_date = serializers.DateTimeField() + has_hidden_contribution = serializers.CharField() + short_label = serializers.CharField() + num_droppable = serializers.IntegerField() + + class ProgressTabSerializer(VerifiedModeSerializer): """ Serializer for progress tab @@ -146,3 +161,5 @@ class ProgressTabSerializer(VerifiedModeSerializer): user_has_passing_grade = serializers.BooleanField() verification_data = VerificationDataSerializer() disable_progress_graph = serializers.BooleanField() + assignment_type_grade_summary = AssignmentTypeScoresSerializer(many=True) + final_grades = serializers.FloatField() diff --git a/lms/djangoapps/course_home_api/progress/tests/test_api.py b/lms/djangoapps/course_home_api/progress/tests/test_api.py index 30d8d9059eaa..51e7dd68286e 100644 --- a/lms/djangoapps/course_home_api/progress/tests/test_api.py +++ b/lms/djangoapps/course_home_api/progress/tests/test_api.py @@ -6,7 +6,80 @@ from django.test import TestCase -from lms.djangoapps.course_home_api.progress.api import calculate_progress_for_learner_in_course +from lms.djangoapps.course_home_api.progress.api import ( + calculate_progress_for_learner_in_course, + aggregate_assignment_type_grade_summary, +) +from xmodule.graders import ShowCorrectness +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + + +def _make_subsection(fmt, earned, possible, show_corr, *, due_delta_days=None): + """Build a lightweight subsection object for testing aggregation scenarios.""" + graded_total = SimpleNamespace(earned=earned, possible=possible) + due = None + if due_delta_days is not None: + due = datetime.now(timezone.utc) + timedelta(days=due_delta_days) + return SimpleNamespace( + graded=True, + format=fmt, + graded_total=graded_total, + show_correctness=show_corr, + due=due, + show_grades=lambda staff: True, + ) + + +_AGGREGATION_SCENARIOS = [ + ( + 'all_visible_always', + {'type': 'Homework', 'weight': 1.0, 'drop_count': 0, 'min_count': 2, 'short_label': 'HW'}, + [ + _make_subsection('Homework', 1, 1, ShowCorrectness.ALWAYS), + _make_subsection('Homework', 0.5, 1, ShowCorrectness.ALWAYS), + ], + {'avg': 0.75, 'weighted': 0.75, 'hidden': 'none', 'final': 0.75}, + ), + ( + 'some_hidden_never_but_include', + {'type': 'Exam', 'weight': 1.0, 'drop_count': 0, 'min_count': 2, 'short_label': 'EX'}, + [ + _make_subsection('Exam', 1, 1, ShowCorrectness.ALWAYS), + _make_subsection('Exam', 0.5, 1, ShowCorrectness.NEVER_BUT_INCLUDE_GRADE), + ], + {'avg': 0.5, 'weighted': 0.5, 'hidden': 'some', 'final': 0.75}, + ), + ( + 'all_hidden_never_but_include', + {'type': 'Quiz', 'weight': 1.0, 'drop_count': 0, 'min_count': 2, 'short_label': 'QZ'}, + [ + _make_subsection('Quiz', 0.4, 1, ShowCorrectness.NEVER_BUT_INCLUDE_GRADE), + _make_subsection('Quiz', 0.6, 1, ShowCorrectness.NEVER_BUT_INCLUDE_GRADE), + ], + {'avg': 0.0, 'weighted': 0.0, 'hidden': 'all', 'final': 0.5}, + ), + ( + 'past_due_mixed_visibility', + {'type': 'Lab', 'weight': 1.0, 'drop_count': 0, 'min_count': 2, 'short_label': 'LB'}, + [ + _make_subsection('Lab', 0.8, 1, ShowCorrectness.PAST_DUE, due_delta_days=-1), + _make_subsection('Lab', 0.2, 1, ShowCorrectness.PAST_DUE, due_delta_days=+3), + ], + {'avg': 0.4, 'weighted': 0.4, 'hidden': 'some', 'final': 0.5}, + ), + ( + 'drop_lowest_keeps_high_scores', + {'type': 'Project', 'weight': 1.0, 'drop_count': 2, 'min_count': 4, 'short_label': 'PR'}, + [ + _make_subsection('Project', 1, 1, ShowCorrectness.ALWAYS), + _make_subsection('Project', 1, 1, ShowCorrectness.ALWAYS), + _make_subsection('Project', 0, 1, ShowCorrectness.ALWAYS), + _make_subsection('Project', 0, 1, ShowCorrectness.ALWAYS), + ], + {'avg': 1.0, 'weighted': 1.0, 'hidden': 'none', 'final': 1.0}, + ), +] class ProgressApiTests(TestCase): @@ -73,3 +146,37 @@ def test_calculate_progress_for_learner_in_course_summary_empty(self, mock_get_s results = calculate_progress_for_learner_in_course("some_course", "some_user") assert not results + + def test_aggregate_assignment_type_grade_summary_scenarios(self): + """ + A test to verify functionality of aggregate_assignment_type_grade_summary. + 1. Test visibility modes (always, never but include grade, past due) + 2. Test drop-lowest behavior + 3. Test weighting behavior + 4. Test final grade calculation + 5. Test average grade calculation + 6. Test weighted grade calculation + 7. Test has_hidden_contribution calculation + """ + + for case_name, policy, subsections, expected in _AGGREGATION_SCENARIOS: + with self.subTest(case_name=case_name): + course_grade = SimpleNamespace(chapter_grades={'chapter': {'sections': subsections}}) + grading_policy = {'GRADER': [policy]} + + result = aggregate_assignment_type_grade_summary( + course_grade, + grading_policy, + has_staff_access=False, + ) + + assert 'results' in result and 'final_grades' in result + assert result['final_grades'] == expected['final'] + assert len(result['results']) == 1 + + row = result['results'][0] + assert row['type'] == policy['type'], case_name + assert row['average_grade'] == expected['avg'] + assert row['weighted_grade'] == expected['weighted'] + assert row['has_hidden_contribution'] == expected['hidden'] + assert row['num_droppable'] == policy['drop_count'] diff --git a/lms/djangoapps/course_home_api/progress/tests/test_views.py b/lms/djangoapps/course_home_api/progress/tests/test_views.py index d13ebec29c21..8012e11675f1 100644 --- a/lms/djangoapps/course_home_api/progress/tests/test_views.py +++ b/lms/djangoapps/course_home_api/progress/tests/test_views.py @@ -282,8 +282,8 @@ def test_url_hidden_if_subsection_hide_after_due(self): assert hide_after_due_subsection['url'] is None @ddt.data( - (True, 0.7), # midterm and final are visible to staff - (False, 0.3), # just the midterm is visible to learners + (True, 0.72), # lab, midterm and final are visible to staff + (False, 0.32), # Only lab and midterm is visible to learners ) @ddt.unpack def test_course_grade_considers_subsection_grade_visibility(self, is_staff, expected_percent): @@ -301,14 +301,18 @@ def test_course_grade_considers_subsection_grade_visibility(self, is_staff, expe never = self.add_subsection_with_problem(format='Homework', show_correctness='never') always = self.add_subsection_with_problem(format='Midterm Exam', show_correctness='always') past_due = self.add_subsection_with_problem(format='Final Exam', show_correctness='past_due', due=tomorrow) + never_but_show_grade = self.add_subsection_with_problem( + format='Lab', show_correctness='never_but_include_grade' + ) answer_problem(self.course, get_mock_request(self.user), never) answer_problem(self.course, get_mock_request(self.user), always) answer_problem(self.course, get_mock_request(self.user), past_due) + answer_problem(self.course, get_mock_request(self.user), never_but_show_grade) # First, confirm the grade in the database - it should never change based on user state. # This is midterm and final and a single problem added together. - assert CourseGradeFactory().read(self.user, self.course).percent == 0.72 + assert CourseGradeFactory().read(self.user, self.course).percent == 0.73 response = self.client.get(self.url) assert response.status_code == 200 diff --git a/lms/djangoapps/course_home_api/progress/views.py b/lms/djangoapps/course_home_api/progress/views.py index 3783c19061dc..54e71df48cc5 100644 --- a/lms/djangoapps/course_home_api/progress/views.py +++ b/lms/djangoapps/course_home_api/progress/views.py @@ -13,8 +13,11 @@ from rest_framework.response import Response from xmodule.modulestore.django import modulestore +from xmodule.graders import ShowCorrectness from common.djangoapps.student.models import CourseEnrollment from lms.djangoapps.course_home_api.progress.serializers import ProgressTabSerializer +from lms.djangoapps.course_home_api.progress.api import aggregate_assignment_type_grade_summary + from lms.djangoapps.course_home_api.toggles import course_home_mfe_progress_tab_is_active from lms.djangoapps.courseware.access import has_access, has_ccx_coach_role from lms.djangoapps.course_blocks.api import get_course_blocks @@ -99,6 +102,7 @@ class ProgressTabView(RetrieveAPIView): assignment_type: (str) the format, if any, of the Subsection (Homework, Exam, etc) block_key: (str) the key of the given subsection block display_name: (str) a str of what the name of the Subsection is for displaying on the site + due: (str or None) the due date of the subsection in ISO 8601 format, or None if no due date is set has_graded_assignment: (bool) whether or not the Subsection is a graded assignment learner_has_access: (bool) whether the learner has access to the subsection (could be FBE gated) num_points_earned: (int) the amount of points the user has earned for the given subsection @@ -175,6 +179,18 @@ def _get_student_user(self, request, course_key, student_id, is_staff): except User.DoesNotExist as exc: raise Http404 from exc + def _visible_section_scores(self, course_grade): + """Return only those chapter/section scores that are visible to the learner.""" + visible_chapters = [] + for chapter in course_grade.chapter_grades.values(): + filtered_sections = [ + subsection + for subsection in chapter["sections"] + if getattr(subsection, "show_correctness", None) != ShowCorrectness.NEVER_BUT_INCLUDE_GRADE + ] + visible_chapters.append({**chapter, "sections": filtered_sections}) + return visible_chapters + def get(self, request, *args, **kwargs): course_key_string = kwargs.get('course_key_string') course_key = CourseKey.from_string(course_key_string) @@ -245,6 +261,16 @@ def get(self, request, *args, **kwargs): access_expiration = get_access_expiration_data(request.user, course_overview) + # Aggregations delegated to helper functions for reuse and testability + assignment_type_grade_summary = aggregate_assignment_type_grade_summary( + course_grade, + grading_policy, + has_staff_access=is_staff, + ) + + # Filter out section scores to only have those that are visible to the user + section_scores = self._visible_section_scores(course_grade) + data = { 'access_expiration': access_expiration, 'certificate_data': get_cert_data(student, course, enrollment_mode, course_grade), @@ -255,12 +281,14 @@ def get(self, request, *args, **kwargs): 'enrollment_mode': enrollment_mode, 'grading_policy': grading_policy, 'has_scheduled_content': has_scheduled_content, - 'section_scores': list(course_grade.chapter_grades.values()), + 'section_scores': section_scores, 'studio_url': get_studio_url(course, 'settings/grading'), 'username': username, 'user_has_passing_grade': user_has_passing_grade, 'verification_data': verification_data, 'disable_progress_graph': disable_progress_graph, + 'assignment_type_grade_summary': assignment_type_grade_summary["results"], + 'final_grades': assignment_type_grade_summary["final_grades"], } context = self.get_serializer_context() context['staff_access'] = is_staff diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 4e3d1be9bddc..2c3ece3133a5 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -1781,6 +1781,14 @@ def assert_progress_page_show_grades(self, response, show_correctness, due_date, (ShowCorrectness.PAST_DUE, TODAY, True), (ShowCorrectness.PAST_DUE, TOMORROW, False), (ShowCorrectness.PAST_DUE, TOMORROW, True), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, None, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, None, True), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, YESTERDAY, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, YESTERDAY, True), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TODAY, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TODAY, True), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TOMORROW, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TOMORROW, True), ) @ddt.unpack def test_progress_page_no_problem_scores(self, show_correctness, due_date_name, graded): @@ -1821,6 +1829,14 @@ def test_progress_page_no_problem_scores(self, show_correctness, due_date_name, (ShowCorrectness.PAST_DUE, TODAY, True, True), (ShowCorrectness.PAST_DUE, TOMORROW, False, False), (ShowCorrectness.PAST_DUE, TOMORROW, True, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, None, False, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, None, True, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, YESTERDAY, False, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, YESTERDAY, True, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TODAY, False, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TODAY, True, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TOMORROW, False, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TOMORROW, True, False), ) @ddt.unpack def test_progress_page_hide_scores_from_learner(self, show_correctness, due_date_name, graded, show_grades): @@ -1873,11 +1889,20 @@ def test_progress_page_hide_scores_from_learner(self, show_correctness, due_date (ShowCorrectness.PAST_DUE, TODAY, True, True), (ShowCorrectness.PAST_DUE, TOMORROW, False, True), (ShowCorrectness.PAST_DUE, TOMORROW, True, True), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, None, False, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, None, True, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, YESTERDAY, False, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, YESTERDAY, True, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TODAY, False, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TODAY, True, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TOMORROW, False, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TOMORROW, True, False), ) @ddt.unpack def test_progress_page_hide_scores_from_staff(self, show_correctness, due_date_name, graded, show_grades): """ - Test that problem scores are hidden from staff viewing a learner's progress page only if show_correctness=never. + Test that problem scores are hidden from staff viewing a learner's progress page only if show_correctness is + never or never_but_include_grade. """ due_date = self.DATES[due_date_name] self.setup_course(show_correctness=show_correctness, due_date=due_date, graded=graded) diff --git a/lms/djangoapps/grades/subsection_grade.py b/lms/djangoapps/grades/subsection_grade.py index 4ce0a1f3a463..b0c98497b823 100644 --- a/lms/djangoapps/grades/subsection_grade.py +++ b/lms/djangoapps/grades/subsection_grade.py @@ -5,8 +5,8 @@ from abc import ABCMeta from collections import OrderedDict +from datetime import datetime, timezone from logging import getLogger - from lazy import lazy from lms.djangoapps.grades.models import BlockRecord, PersistentSubsectionGrade @@ -59,6 +59,13 @@ def show_grades(self, has_staff_access): """ Returns whether subsection scores are currently available to users with or without staff access. """ + if self.show_correctness == ShowCorrectness.NEVER_BUT_INCLUDE_GRADE: + # show_grades fn is used to determine if the grade should be included in final calculation. + # For NEVER_BUT_INCLUDE_GRADE, show_grades returns True if the due date has passed, + # but correctness_available always returns False as we do not want to show correctness + # of problems to the users. + return (self.due is None or + self.due < datetime.now(timezone.utc)) return ShowCorrectness.correctness_available(self.show_correctness, self.due, has_staff_access) @property diff --git a/lms/templates/courseware/progress.html b/lms/templates/courseware/progress.html index 711fad895427..3ee4044fcbbf 100644 --- a/lms/templates/courseware/progress.html +++ b/lms/templates/courseware/progress.html @@ -16,6 +16,7 @@ from lms.djangoapps.grades.api import constants as grades_constants from openedx.core.djangolib.markup import HTML, Text from openedx.features.enterprise_support.utils import get_enterprise_learner_generic_name +from xmodule.graders import ShowCorrectness %> <% @@ -180,7 +181,7 @@

${ chapter['display_name']}

%if hide_url:

${section.display_name} - %if (total > 0 or earned > 0) and section.show_grades(staff_access): + %if (total > 0 or earned > 0) and ShowCorrectness.correctness_available(section.show_correctness, section.due, staff_access): ${_("{earned} of {total} possible points").format(earned='{:.3n}'.format(float(earned)), total='{:.3n}'.format(float(total)))} @@ -189,14 +190,14 @@

%else: ${ section.display_name} - %if (total > 0 or earned > 0) and section.show_grades(staff_access): + %if (total > 0 or earned > 0) and ShowCorrectness.correctness_available(section.show_correctness, section.due, staff_access): ${_("{earned} of {total} possible points").format(earned='{:.3n}'.format(float(earned)), total='{:.3n}'.format(float(total)))} %endif %endif - %if (total > 0 or earned > 0) and section.show_grades(staff_access): + %if (total > 0 or earned > 0) and ShowCorrectness.correctness_available(section.show_correctness, section.due, staff_access): ${"({0:.3n}/{1:.3n}) {2}".format( float(earned), float(total), percentageString )} %endif

@@ -219,7 +220,7 @@

%endif

%if len(section.problem_scores.values()) > 0: - %if section.show_grades(staff_access): + %if ShowCorrectness.correctness_available(section.show_correctness, section.due, staff_access):
${ _("Problem Scores: ") if section.graded else _("Practice Scores: ")}
%for score in section.problem_scores.values(): diff --git a/xmodule/graders.py b/xmodule/graders.py index 001113882438..34f7e61654b4 100644 --- a/xmodule/graders.py +++ b/xmodule/graders.py @@ -485,13 +485,14 @@ class ShowCorrectness: ALWAYS = "always" PAST_DUE = "past_due" NEVER = "never" + NEVER_BUT_INCLUDE_GRADE = "never_but_include_grade" @classmethod def correctness_available(cls, show_correctness='', due_date=None, has_staff_access=False): """ Returns whether correctness is available now, for the given attributes. """ - if show_correctness == cls.NEVER: + if show_correctness in (cls.NEVER, cls.NEVER_BUT_INCLUDE_GRADE): return False elif has_staff_access: # This is after the 'never' check because course staff can see correctness diff --git a/xmodule/tests/test_graders.py b/xmodule/tests/test_graders.py index 5e2444a05533..b80004c913a3 100644 --- a/xmodule/tests/test_graders.py +++ b/xmodule/tests/test_graders.py @@ -493,3 +493,11 @@ def test_show_correctness_past_due(self, due_date_str, has_staff_access, expecte due_date = getattr(self, due_date_str) assert ShowCorrectness.correctness_available(ShowCorrectness.PAST_DUE, due_date, has_staff_access) ==\ expected_result + + @ddt.data(True, False) + def test_show_correctness_never_but_include_grade(self, has_staff_access): + """ + Test that show_correctness="never_but_include_grade" hides correctness from learners and course staff. + """ + assert not ShowCorrectness.correctness_available(show_correctness=ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, + has_staff_access=has_staff_access) From e434680d958e443e983e16c5a8c1a1ce4082cadc Mon Sep 17 00:00:00 2001 From: Chintan Joshi Date: Wed, 7 Jan 2026 21:37:47 +0530 Subject: [PATCH 47/54] Revert "feat: soft delete (#37)" (#78) This reverts commit 1e8714f6c0b27f3bfc50ab33a317bc860846d978. --- lms/djangoapps/discussion/rest_api/api.py | 1272 ++++------------ lms/djangoapps/discussion/rest_api/forms.py | 73 +- .../discussion/rest_api/serializers.py | 287 +--- lms/djangoapps/discussion/rest_api/tasks.py | 110 +- .../discussion/rest_api/tests/test_api_v2.py | 105 +- .../discussion/rest_api/tests/test_forms.py | 99 +- .../rest_api/tests/test_serializers.py | 592 ++++---- .../discussion/rest_api/tests/test_views.py | 1284 +++++++---------- .../rest_api/tests/test_views_v2.py | 65 +- .../discussion/rest_api/tests/utils.py | 440 +++--- lms/djangoapps/discussion/rest_api/urls.py | 64 +- lms/djangoapps/discussion/rest_api/views.py | 583 ++------ .../comment_client/comment.py | 142 +- .../comment_client/models.py | 96 +- .../comment_client/thread.py | 325 ++--- 15 files changed, 1800 insertions(+), 3737 deletions(-) diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index fcc13efc40b8..b87852c16cfa 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -1,17 +1,17 @@ """ Discussion API internal interface """ - from __future__ import annotations import itertools -import logging import re from collections import defaultdict from datetime import datetime + from enum import Enum from typing import Dict, Iterable, List, Literal, Optional, Set, Tuple from urllib.parse import urlencode, urlunparse +from pytz import UTC from django.conf import settings from django.contrib.auth import get_user_model @@ -19,26 +19,24 @@ from django.db.models import Q from django.http import Http404 from django.urls import reverse -from django.utils.html import strip_tags from edx_django_utils.monitoring import function_trace from opaque_keys import InvalidKeyError from opaque_keys.edx.locator import CourseKey -from pytz import UTC from rest_framework import status from rest_framework.exceptions import PermissionDenied from rest_framework.request import Request from rest_framework.response import Response -from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole -from forum import api as forum_api +from common.djangoapps.student.roles import ( + CourseInstructorRole, + CourseStaffRole, +) + from lms.djangoapps.course_api.blocks.api import get_blocks from lms.djangoapps.courseware.courses import get_course_with_access from lms.djangoapps.courseware.exceptions import CourseAccessRedirect from lms.djangoapps.discussion.rate_limit import is_content_creation_rate_limited -from lms.djangoapps.discussion.toggles import ( - ENABLE_DISCUSSIONS_MFE, - ONLY_VERIFIED_USERS_CAN_POST, -) +from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE, ONLY_VERIFIED_USERS_CAN_POST from lms.djangoapps.discussion.views import is_privileged_user from openedx.core.djangoapps.discussions.models import ( DiscussionsConfiguration, @@ -50,12 +48,12 @@ from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment from openedx.core.djangoapps.django_comment_common.comment_client.course import ( get_course_commentable_counts, - get_course_user_stats, + get_course_user_stats ) from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread from openedx.core.djangoapps.django_comment_common.comment_client.utils import ( CommentClient500Error, - CommentClientRequestError, + CommentClientRequestError ) from openedx.core.djangoapps.django_comment_common.models import ( FORUM_ROLE_ADMINISTRATOR, @@ -63,13 +61,13 @@ FORUM_ROLE_GROUP_MODERATOR, FORUM_ROLE_MODERATOR, CourseDiscussionSettings, - Role, + Role ) from openedx.core.djangoapps.django_comment_common.signals import ( comment_created, comment_deleted, - comment_edited, comment_endorsed, + comment_edited, comment_flagged, comment_voted, thread_created, @@ -77,15 +75,11 @@ thread_edited, thread_flagged, thread_followed, - thread_unfollowed, thread_voted, + thread_unfollowed ) from openedx.core.djangoapps.user_api.accounts.api import get_account_settings -from openedx.core.lib.exceptions import ( - CourseNotFoundError, - DiscussionNotFoundError, - PageNotFoundError, -) +from openedx.core.lib.exceptions import CourseNotFoundError, DiscussionNotFoundError, PageNotFoundError from xmodule.course_block import CourseBlock from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore @@ -94,27 +88,21 @@ from ..django_comment_client.base.views import ( track_comment_created_event, track_comment_deleted_event, - track_discussion_reported_event, - track_discussion_unreported_event, - track_forum_search_event, track_thread_created_event, track_thread_deleted_event, - track_thread_followed_event, track_thread_viewed_event, track_voted_event, + track_discussion_reported_event, + track_discussion_unreported_event, + track_forum_search_event, track_thread_followed_event ) from ..django_comment_client.utils import ( get_group_id_for_user, get_user_role_names, has_discussion_privileges, - is_commentable_divided, -) -from .exceptions import ( - CommentNotFoundError, - DiscussionBlackOutException, - DiscussionDisabledError, - ThreadNotFoundError, + is_commentable_divided ) +from .exceptions import CommentNotFoundError, DiscussionBlackOutException, DiscussionDisabledError, ThreadNotFoundError from .forms import CommentActionsForm, ThreadActionsForm, UserOrdering from .pagination import DiscussionAPIPagination from .permissions import ( @@ -122,7 +110,7 @@ can_take_action_on_spam, get_editable_fields, get_initializable_comment_fields, - get_initializable_thread_fields, + get_initializable_thread_fields ) from .serializers import ( CommentSerializer, @@ -131,23 +119,20 @@ ThreadSerializer, TopicOrdering, UserStatsSerializer, - get_context, + get_context ) from .utils import ( AttributeDict, add_stats_for_users_with_no_discussion_content, - can_user_notify_all_learners, create_blocks_params, discussion_open_for_user, - get_captcha_site_key_by_platform, get_usernames_for_course, get_usernames_from_search_string, - is_captcha_enabled, - is_posting_allowed, set_attribute, + is_posting_allowed, + can_user_notify_all_learners, is_captcha_enabled, get_captcha_site_key_by_platform ) -log = logging.getLogger(__name__) User = get_user_model() ThreadType = Literal["discussion", "question"] @@ -181,14 +166,11 @@ class DiscussionEntity(Enum): """ Enum for different types of discussion related entities """ - - thread = "thread" - comment = "comment" + thread = 'thread' + comment = 'comment' -def _get_course( - course_key: CourseKey, user: User, check_tab: bool = True -) -> CourseBlock: +def _get_course(course_key: CourseKey, user: User, check_tab: bool = True) -> CourseBlock: """ Get the course block, raising CourseNotFoundError if the course is not found or the user cannot access forums for the course, and DiscussionDisabledError if the @@ -206,16 +188,14 @@ def _get_course( CourseBlock: course object """ try: - course = get_course_with_access( - user, "load", course_key, check_if_enrolled=True - ) + course = get_course_with_access(user, 'load', course_key, check_if_enrolled=True) except (Http404, CourseAccessRedirect) as err: # Convert 404s into CourseNotFoundErrors. # Raise course not found if the user cannot access the course raise CourseNotFoundError("Course not found.") from err if check_tab: - discussion_tab = CourseTabList.get_tab_by_type(course.tabs, "discussion") + discussion_tab = CourseTabList.get_tab_by_type(course.tabs, 'discussion') if not (discussion_tab and discussion_tab.is_enabled(course, user)): raise DiscussionDisabledError("Discussion is disabled for the course.") @@ -236,34 +216,22 @@ def _get_thread_and_context(request, thread_id, retrieve_kwargs=None, course_id= retrieve_kwargs["with_responses"] = False if "mark_as_read" not in retrieve_kwargs: retrieve_kwargs["mark_as_read"] = False - cc_thread = Thread(id=thread_id).retrieve( - course_id=course_id, **retrieve_kwargs - ) + cc_thread = Thread(id=thread_id).retrieve(course_id=course_id, **retrieve_kwargs) course_key = CourseKey.from_string(cc_thread["course_id"]) course = _get_course(course_key, request.user) context = get_context(course, request, cc_thread) - if ( - retrieve_kwargs.get("flagged_comments") - and not context["has_moderation_privilege"] - ): + if retrieve_kwargs.get("flagged_comments") and not context["has_moderation_privilege"]: raise ValidationError("Only privileged users can request flagged comments") course_discussion_settings = CourseDiscussionSettings.get(course_key) if ( - not context["has_moderation_privilege"] - and cc_thread["group_id"] - and is_commentable_divided( - course.id, cc_thread["commentable_id"], course_discussion_settings - ) + not context["has_moderation_privilege"] and + cc_thread["group_id"] and + is_commentable_divided(course.id, cc_thread["commentable_id"], course_discussion_settings) ): - requester_group_id = get_group_id_for_user( - request.user, course_discussion_settings - ) - if ( - requester_group_id is not None - and cc_thread["group_id"] != requester_group_id - ): + requester_group_id = get_group_id_for_user(request.user, course_discussion_settings) + if requester_group_id is not None and cc_thread["group_id"] != requester_group_id: raise ThreadNotFoundError("Thread not found.") return cc_thread, context except CommentClientRequestError as err: @@ -296,8 +264,8 @@ def _is_user_author_or_privileged(cc_content, context): Boolean """ return ( - context["has_moderation_privilege"] - or context["cc_requester"]["id"] == cc_content["user_id"] + context["has_moderation_privilege"] or + context["cc_requester"]["id"] == cc_content["user_id"] ) @@ -307,13 +275,11 @@ def get_thread_list_url(request, course_key, topic_id_list=None, following=False """ path = reverse("thread-list") query_list = ( - [("course_id", str(course_key))] - + [("topic_id", topic_id) for topic_id in topic_id_list or []] - + ([("following", following)] if following else []) - ) - return request.build_absolute_uri( - urlunparse(("", "", path, "", urlencode(query_list), "")) + [("course_id", str(course_key))] + + [("topic_id", topic_id) for topic_id in topic_id_list or []] + + ([("following", following)] if following else []) ) + return request.build_absolute_uri(urlunparse(("", "", path, "", urlencode(query_list), ""))) def get_course(request, course_key, check_tab=True): @@ -358,19 +324,18 @@ def _format_datetime(dt): the substitution... though really, that would probably break mobile client parsing of the dates as well. :-P """ - return dt.isoformat().replace("+00:00", "Z") + return dt.isoformat().replace('+00:00', 'Z') course = _get_course(course_key, request.user, check_tab=check_tab) user_roles = get_user_role_names(request.user, course_key) course_config = DiscussionsConfiguration.get(course_key) EDIT_REASON_CODES = getattr(settings, "DISCUSSION_MODERATION_EDIT_REASON_CODES", {}) - CLOSE_REASON_CODES = getattr( - settings, "DISCUSSION_MODERATION_CLOSE_REASON_CODES", {} - ) + CLOSE_REASON_CODES = getattr(settings, "DISCUSSION_MODERATION_CLOSE_REASON_CODES", {}) is_posting_enabled = is_posting_allowed( - course_config.posting_restrictions, course.get_discussion_blackout_datetimes() + course_config.posting_restrictions, + course.get_discussion_blackout_datetimes() ) - discussion_tab = CourseTabList.get_tab_by_type(course.tabs, "discussion") + discussion_tab = CourseTabList.get_tab_by_type(course.tabs, 'discussion') is_course_staff = CourseStaffRole(course_key).has_user(request.user) is_course_admin = CourseInstructorRole(course_key).has_user(request.user) return { @@ -384,9 +349,7 @@ def _format_datetime(dt): for blackout in course.get_discussion_blackout_datetimes() ], "thread_list_url": get_thread_list_url(request, course_key), - "following_thread_list_url": get_thread_list_url( - request, course_key, following=True - ), + "following_thread_list_url": get_thread_list_url(request, course_key, following=True), "topics_url": request.build_absolute_uri( reverse("course_topics", kwargs={"course_id": course_key}) ), @@ -394,23 +357,18 @@ def _format_datetime(dt): "allow_anonymous_to_peers": course.allow_anonymous_to_peers, "user_roles": user_roles, "has_bulk_delete_privileges": can_take_action_on_spam(request.user, course_key), - "has_moderation_privileges": bool( - user_roles - & { - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - } - ), + "has_moderation_privileges": bool(user_roles & { + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + }), "is_group_ta": bool(user_roles & {FORUM_ROLE_GROUP_MODERATOR}), "is_user_admin": request.user.is_staff, "is_course_staff": is_course_staff, "is_course_admin": is_course_admin, "provider": course_config.provider_type, "enable_in_context": course_config.enable_in_context, - "group_at_subsection": course_config.plugin_configuration.get( - "group_at_subsection", False - ), + "group_at_subsection": course_config.plugin_configuration.get("group_at_subsection", False), "edit_reasons": [ {"code": reason_code, "label": label} for (reason_code, label) in EDIT_REASON_CODES.items() @@ -419,23 +377,17 @@ def _format_datetime(dt): {"code": reason_code, "label": label} for (reason_code, label) in CLOSE_REASON_CODES.items() ], - "show_discussions": bool( - discussion_tab and discussion_tab.is_enabled(course, request.user) - ), - "is_notify_all_learners_enabled": can_user_notify_all_learners( + 'show_discussions': bool(discussion_tab and discussion_tab.is_enabled(course, request.user)), + 'is_notify_all_learners_enabled': can_user_notify_all_learners( user_roles, is_course_staff, is_course_admin ), - "captcha_settings": { - "enabled": is_captcha_enabled(course_key), - "site_key": get_captcha_site_key_by_platform("web"), + 'captcha_settings': { + 'enabled': is_captcha_enabled(course_key), + 'site_key': get_captcha_site_key_by_platform('web'), }, "is_email_verified": request.user.is_active, - "only_verified_users_can_post": ONLY_VERIFIED_USERS_CAN_POST.is_enabled( - course_key - ), - "content_creation_rate_limited": is_content_creation_rate_limited( - request, course_key, increment=False - ), + "only_verified_users_can_post": ONLY_VERIFIED_USERS_CAN_POST.is_enabled(course_key), + "content_creation_rate_limited": is_content_creation_rate_limited(request, course_key, increment=False), } @@ -488,7 +440,7 @@ def convert(text): return text def alphanum_key(key): - return [convert(c) for c in re.split("([0-9]+)", key)] + return [convert(c) for c in re.split('([0-9]+)', key)] return sorted(category_list, key=alphanum_key) @@ -530,7 +482,7 @@ def get_non_courseware_topics( course_key: CourseKey, course: CourseBlock, topic_ids: Optional[List[str]], - thread_counts: Dict[str, Dict[str, int]], + thread_counts: Dict[str, Dict[str, int]] ) -> Tuple[List[Dict], Set[str]]: """ Returns a list of topic trees that are not linked to courseware. @@ -554,17 +506,13 @@ def get_non_courseware_topics( existing_topic_ids = set() topics = list(course.discussion_topics.items()) for name, entry in topics: - if not topic_ids or entry["id"] in topic_ids: + if not topic_ids or entry['id'] in topic_ids: discussion_topic = DiscussionTopic( - entry["id"], - name, - get_thread_list_url(request, course_key, [entry["id"]]), + entry["id"], name, get_thread_list_url(request, course_key, [entry["id"]]), None, - thread_counts.get(entry["id"]), - ) - non_courseware_topics.append( - DiscussionTopicSerializer(discussion_topic).data + thread_counts.get(entry["id"]) ) + non_courseware_topics.append(DiscussionTopicSerializer(discussion_topic).data) if topic_ids and entry["id"] in topic_ids: existing_topic_ids.add(entry["id"]) @@ -572,9 +520,7 @@ def get_non_courseware_topics( return non_courseware_topics, existing_topic_ids -def get_course_topics( - request: Request, course_key: CourseKey, topic_ids: Optional[Set[str]] = None -): +def get_course_topics(request: Request, course_key: CourseKey, topic_ids: Optional[Set[str]] = None): """ Returns the course topic listing for the given course and user; filtered by 'topic_ids' list if given. @@ -598,25 +544,15 @@ def get_course_topics( courseware_topics, existing_courseware_topic_ids = get_courseware_topics( request, course_key, course, topic_ids, thread_counts ) - non_courseware_topics, existing_non_courseware_topic_ids = ( - get_non_courseware_topics( - request, - course_key, - course, - topic_ids, - thread_counts, - ) + non_courseware_topics, existing_non_courseware_topic_ids = get_non_courseware_topics( + request, course_key, course, topic_ids, thread_counts, ) if topic_ids: - not_found_topic_ids = topic_ids - ( - existing_courseware_topic_ids | existing_non_courseware_topic_ids - ) + not_found_topic_ids = topic_ids - (existing_courseware_topic_ids | existing_non_courseware_topic_ids) if not_found_topic_ids: raise DiscussionNotFoundError( - "Discussion not found for '{}'.".format( - ", ".join(str(id) for id in not_found_topic_ids) - ) + "Discussion not found for '{}'.".format(", ".join(str(id) for id in not_found_topic_ids)) ) return { @@ -631,19 +567,17 @@ def get_v2_non_courseware_topics_as_v1(request, course_key, topics): """ non_courseware_topics = [] for topic in topics: - if topic.get("usage_key", "") is None: - for key in ["usage_key", "enabled_in_context"]: + if topic.get('usage_key', '') is None: + for key in ['usage_key', 'enabled_in_context']: topic.pop(key) - topic.update( - { - "children": [], - "thread_list_url": get_thread_list_url( - request, - course_key, - topic.get("id"), - ), - } - ) + topic.update({ + 'children': [], + 'thread_list_url': get_thread_list_url( + request, + course_key, + topic.get('id'), + ) + }) non_courseware_topics.append(topic) return non_courseware_topics @@ -655,25 +589,23 @@ def get_v2_courseware_topics_as_v1(request, course_key, sequentials, topics): courseware_topics = [] for sequential in sequentials: children = [] - for child in sequential.get("children", []): + for child in sequential.get('children', []): for topic in topics: - if child == topic.get("usage_key"): - topic.update( - { - "children": [], - "thread_list_url": get_thread_list_url( - request, - course_key, - [topic.get("id")], - ), - } - ) - topic.pop("enabled_in_context") + if child == topic.get('usage_key'): + topic.update({ + 'children': [], + 'thread_list_url': get_thread_list_url( + request, + course_key, + [topic.get('id')], + ) + }) + topic.pop('enabled_in_context') children.append(AttributeDict(topic)) discussion_topic = DiscussionTopic( None, - sequential.get("display_name"), + sequential.get('display_name'), get_thread_list_url( request, course_key, @@ -686,7 +618,7 @@ def get_v2_courseware_topics_as_v1(request, course_key, sequentials, topics): courseware_topics = [ courseware_topic for courseware_topic in courseware_topics - if courseware_topic.get("children", []) + if courseware_topic.get('children', []) ] return courseware_topics @@ -703,21 +635,20 @@ def get_v2_course_topics_as_v1( blocks_params = create_blocks_params(course_usage_key, request.user) blocks = get_blocks( request, - blocks_params["usage_key"], - blocks_params["user"], - blocks_params["depth"], - blocks_params["nav_depth"], - blocks_params["requested_fields"], - blocks_params["block_counts"], - blocks_params["student_view_data"], - blocks_params["return_type"], - blocks_params["block_types_filter"], + blocks_params['usage_key'], + blocks_params['user'], + blocks_params['depth'], + blocks_params['nav_depth'], + blocks_params['requested_fields'], + blocks_params['block_counts'], + blocks_params['student_view_data'], + blocks_params['return_type'], + blocks_params['block_types_filter'], hide_access_denials=False, - )["blocks"] + )['blocks'] - sequentials = [ - value for _, value in blocks.items() if value.get("type") == "sequential" - ] + sequentials = [value for _, value in blocks.items() + if value.get('type') == "sequential"] topics = get_course_topics_v2(course_key, request.user, topic_ids) non_courseware_topics = get_v2_non_courseware_topics_as_v1( @@ -774,29 +705,24 @@ def get_course_topics_v2( # Check access to the course store = modulestore() _get_course(course_key, user=user, check_tab=False) - user_is_privileged = ( - user.is_staff - or user.roles.filter( - course_id=course_key, - name__in=[ - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_ADMINISTRATOR, - ], - ).exists() - ) + user_is_privileged = user.is_staff or user.roles.filter( + course_id=course_key, + name__in=[ + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_ADMINISTRATOR, + ] + ).exists() with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course_key): blocks = store.get_items( course_key, - qualifiers={"category": "vertical"}, - fields=["usage_key", "discussion_enabled", "display_name"], + qualifiers={'category': 'vertical'}, + fields=['usage_key', 'discussion_enabled', 'display_name'], ) accessible_vertical_keys = [] for block in blocks: - if block.discussion_enabled and ( - not block.visible_to_staff_only or user_is_privileged - ): + if block.discussion_enabled and (not block.visible_to_staff_only or user_is_privileged): accessible_vertical_keys.append(block.usage_key) accessible_vertical_keys.append(None) @@ -806,13 +732,9 @@ def get_course_topics_v2( ) if user_is_privileged: - topics_query = topics_query.filter( - Q(usage_key__in=accessible_vertical_keys) | Q(enabled_in_context=False) - ) + topics_query = topics_query.filter(Q(usage_key__in=accessible_vertical_keys) | Q(enabled_in_context=False)) else: - topics_query = topics_query.filter( - usage_key__in=accessible_vertical_keys, enabled_in_context=True - ) + topics_query = topics_query.filter(usage_key__in=accessible_vertical_keys, enabled_in_context=True) if topic_ids: topics_query = topics_query.filter(external_id__in=topic_ids) @@ -824,13 +746,11 @@ def get_course_topics_v2( reverse=True, ) elif order_by == TopicOrdering.NAME: - topics_query = topics_query.order_by("title") + topics_query = topics_query.order_by('title') else: - topics_query = topics_query.order_by("ordering") + topics_query = topics_query.order_by('ordering') - topics_data = DiscussionTopicSerializerV2( - topics_query, many=True, context={"thread_counts": thread_counts} - ).data + topics_data = DiscussionTopicSerializerV2(topics_query, many=True, context={"thread_counts": thread_counts}).data return [ topic_data for topic_data in topics_data @@ -857,7 +777,7 @@ def _get_user_profile_dict(request, usernames): else: username_list = [] user_profile_details = get_account_settings(request, username_list) - return {user["username"]: user for user in user_profile_details} + return {user['username']: user for user in user_profile_details} def _user_profile(user_profile): @@ -865,7 +785,11 @@ def _user_profile(user_profile): Returns the user profile object. For now, this just comprises the profile_image details. """ - return {"profile": {"image": user_profile["profile_image"]}} + return { + 'profile': { + 'image': user_profile['profile_image'] + } + } def _get_users(discussion_entity_type, discussion_entity, username_profile_dict): @@ -883,28 +807,22 @@ def _get_users(discussion_entity_type, discussion_entity, username_profile_dict) A dict of users with username as key and user profile details as value. """ users = {} - if discussion_entity["author"]: - user_profile = username_profile_dict.get(discussion_entity["author"]) + if discussion_entity['author']: + user_profile = username_profile_dict.get(discussion_entity['author']) if user_profile: - users[discussion_entity["author"]] = _user_profile(user_profile) + users[discussion_entity['author']] = _user_profile(user_profile) if ( discussion_entity_type == DiscussionEntity.comment - and discussion_entity["endorsed"] - and discussion_entity["endorsed_by"] + and discussion_entity['endorsed'] + and discussion_entity['endorsed_by'] ): - users[discussion_entity["endorsed_by"]] = _user_profile( - username_profile_dict[discussion_entity["endorsed_by"]] - ) + users[discussion_entity['endorsed_by']] = _user_profile(username_profile_dict[discussion_entity['endorsed_by']]) return users def _add_additional_response_fields( - request, - serialized_discussion_entities, - usernames, - discussion_entity_type, - include_profile_image, + request, serialized_discussion_entities, usernames, discussion_entity_type, include_profile_image ): """ Adds additional data to serialized discussion thread/comment. @@ -922,13 +840,9 @@ def _add_additional_response_fields( A list of serialized discussion thread/comment with additional data if requested. """ if include_profile_image: - username_profile_dict = _get_user_profile_dict( - request, usernames=",".join(usernames) - ) + username_profile_dict = _get_user_profile_dict(request, usernames=','.join(usernames)) for discussion_entity in serialized_discussion_entities: - discussion_entity["users"] = _get_users( - discussion_entity_type, discussion_entity, username_profile_dict - ) + discussion_entity['users'] = _get_users(discussion_entity_type, discussion_entity, username_profile_dict) return serialized_discussion_entities @@ -937,12 +851,10 @@ def _include_profile_image(requested_fields): """ Returns True if requested_fields list has 'profile_image' entity else False """ - return requested_fields and "profile_image" in requested_fields + return requested_fields and 'profile_image' in requested_fields -def _serialize_discussion_entities( - request, context, discussion_entities, requested_fields, discussion_entity_type -): +def _serialize_discussion_entities(request, context, discussion_entities, requested_fields, discussion_entity_type): """ It serializes Discussion Entity (Thread or Comment) and add additional data if requested. @@ -973,19 +885,14 @@ def _serialize_discussion_entities( results.append(serialized_entity) if include_profile_image: + if serialized_entity['author'] and serialized_entity['author'] not in usernames: + usernames.append(serialized_entity['author']) if ( - serialized_entity["author"] - and serialized_entity["author"] not in usernames + 'endorsed' in serialized_entity and serialized_entity['endorsed'] and + 'endorsed_by' in serialized_entity and + serialized_entity['endorsed_by'] and serialized_entity['endorsed_by'] not in usernames ): - usernames.append(serialized_entity["author"]) - if ( - "endorsed" in serialized_entity - and serialized_entity["endorsed"] - and "endorsed_by" in serialized_entity - and serialized_entity["endorsed_by"] - and serialized_entity["endorsed_by"] not in usernames - ): - usernames.append(serialized_entity["endorsed_by"]) + usernames.append(serialized_entity['endorsed_by']) results = _add_additional_response_fields( request, results, usernames, discussion_entity_type, include_profile_image @@ -1009,7 +916,6 @@ def get_thread_list( order_direction: Literal["desc"] = "desc", requested_fields: Optional[List[Literal["profile_image"]]] = None, count_flagged: bool = None, - show_deleted: bool = False, ): """ Return the list of all discussion threads pertaining to the given course @@ -1053,31 +959,20 @@ def get_thread_list( CourseNotFoundError: if the requesting user does not have access to the requested course PageNotFoundError: if page requested is beyond the last """ - exclusive_param_count = sum( - 1 for param in [topic_id_list, text_search, following] if param - ) + exclusive_param_count = sum(1 for param in [topic_id_list, text_search, following] if param) if exclusive_param_count > 1: # pragma: no cover - raise ValueError( - "More than one mutually exclusive param passed to get_thread_list" - ) + raise ValueError("More than one mutually exclusive param passed to get_thread_list") - cc_map = { - "last_activity_at": "activity", - "comment_count": "comments", - "vote_count": "votes", - } + cc_map = {"last_activity_at": "activity", "comment_count": "comments", "vote_count": "votes"} if order_by not in cc_map: - raise ValidationError( - { - "order_by": [ - f"Invalid value. '{order_by}' must be 'last_activity_at', 'comment_count', or 'vote_count'" - ] - } - ) + raise ValidationError({ + "order_by": + [f"Invalid value. '{order_by}' must be 'last_activity_at', 'comment_count', or 'vote_count'"] + }) if order_direction != "desc": - raise ValidationError( - {"order_direction": [f"Invalid value. '{order_direction}' must be 'desc'"]} - ) + raise ValidationError({ + "order_direction": [f"Invalid value. '{order_direction}' must be 'desc'"] + }) course = _get_course(course_key, request.user) context = get_context(course, request) @@ -1089,21 +984,13 @@ def get_thread_list( except User.DoesNotExist: # Raising an error for a missing user leaks the presence of a username, # so just return an empty response. - return DiscussionAPIPagination(request, 0, 1).get_paginated_response( - { - "results": [], - "text_search_rewrite": None, - } - ) + return DiscussionAPIPagination(request, 0, 1).get_paginated_response({ + "results": [], + "text_search_rewrite": None, + }) if count_flagged and not context["has_moderation_privilege"]: - raise PermissionDenied( - "`count_flagged` can only be set by users with moderator access or higher." - ) - if show_deleted and not context["has_moderation_privilege"]: - raise PermissionDenied( - "`show_deleted` can only be set by users with moderator access or higher." - ) + raise PermissionDenied("`count_flagged` can only be set by users with moderator access or higher.") group_id = None allowed_roles = [ @@ -1123,9 +1010,7 @@ def get_thread_list( not context["has_moderation_privilege"] or request.user.id in context["ta_user_ids"] ): - group_id = get_group_id_for_user( - request.user, CourseDiscussionSettings.get(course.id) - ) + group_id = get_group_id_for_user(request.user, CourseDiscussionSettings.get(course.id)) query_params = { "user_id": str(request.user.id), @@ -1138,24 +1023,21 @@ def get_thread_list( "flagged": flagged, "thread_type": thread_type, "count_flagged": count_flagged, - "show_deleted": show_deleted, } if view: if view in ["unread", "unanswered", "unresponded"]: query_params[view] = "true" else: - raise ValidationError( - {"view": [f"Invalid value. '{view}' must be 'unread' or 'unanswered'"]} - ) + raise ValidationError({ + "view": [f"Invalid value. '{view}' must be 'unread' or 'unanswered'"] + }) if following: paginated_results = context["cc_requester"].subscribed_threads(query_params) else: query_params["course_id"] = str(course.id) - query_params["commentable_ids"] = ( - ",".join(topic_id_list) if topic_id_list else None - ) + query_params["commentable_ids"] = ",".join(topic_id_list) if topic_id_list else None query_params["text"] = text_search paginated_results = Thread.search(query_params) # The comments service returns the last page of results if the requested @@ -1165,25 +1047,19 @@ def get_thread_list( raise PageNotFoundError("Page not found (No results on this page).") results = _serialize_discussion_entities( - request, - context, - paginated_results.collection, - requested_fields, - DiscussionEntity.thread, + request, context, paginated_results.collection, requested_fields, DiscussionEntity.thread ) paginator = DiscussionAPIPagination( request, paginated_results.page, paginated_results.num_pages, - paginated_results.thread_count, - ) - return paginator.get_paginated_response( - { - "results": results, - "text_search_rewrite": paginated_results.corrected_text, - } + paginated_results.thread_count ) + return paginator.get_paginated_response({ + "results": results, + "text_search_rewrite": paginated_results.corrected_text, + }) def get_learner_active_thread_list(request, course_key, query_params): @@ -1278,101 +1154,49 @@ def get_learner_active_thread_list(request, course_key, query_params): course = _get_course(course_key, request.user) context = get_context(course, request) - group_id = query_params.get("group_id", None) - user_id = query_params.get("user_id", None) - count_flagged = query_params.get("count_flagged", None) - show_deleted = query_params.get("show_deleted", False) - if isinstance(show_deleted, str): - show_deleted = show_deleted.lower() == "true" - + group_id = query_params.get('group_id', None) + user_id = query_params.get('user_id', None) + count_flagged = query_params.get('count_flagged', None) if user_id is None: - return Response( - {"detail": "Invalid user id"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({'detail': 'Invalid user id'}, status=status.HTTP_400_BAD_REQUEST) if count_flagged and not context["has_moderation_privilege"]: - raise PermissionDenied( - "count_flagged can only be set by users with moderation roles." - ) + raise PermissionDenied("count_flagged can only be set by users with moderation roles.") if "flagged" in query_params.keys() and not context["has_moderation_privilege"]: raise PermissionDenied("Flagged filter is only available for moderators") - if show_deleted and not context["has_moderation_privilege"]: - raise PermissionDenied( - "show_deleted can only be set by users with moderation roles." - ) if group_id is None: comment_client_user = comment_client.User(id=user_id, course_id=course_key) else: - comment_client_user = comment_client.User( - id=user_id, course_id=course_key, group_id=group_id - ) + comment_client_user = comment_client.User(id=user_id, course_id=course_key, group_id=group_id) try: threads, page, num_pages = comment_client_user.active_threads(query_params) threads = set_attribute(threads, "pinned", False) - - # This portion below is temporary until we migrate to forum v2 - filtered_threads = [] - for thread in threads: - try: - forum_thread = forum_api.get_thread( - thread.get("id"), course_id=str(course_key) - ) - is_deleted = forum_thread.get("is_deleted", False) - - if show_deleted and is_deleted: - thread["is_deleted"] = True - thread["deleted_at"] = forum_thread.get("deleted_at") - thread["deleted_by"] = forum_thread.get("deleted_by") - filtered_threads.append(thread) - elif not show_deleted and not is_deleted: - filtered_threads.append(thread) - except Exception as e: # pylint: disable=broad-exception-caught - log.warning( - "Failed to check thread %s deletion status: %s", thread.get("id"), e - ) - if not show_deleted: # Fail safe: include thread for regular users - filtered_threads.append(thread) - results = _serialize_discussion_entities( - request, - context, - filtered_threads, - {"profile_image"}, - DiscussionEntity.thread, + request, context, threads, {'profile_image'}, DiscussionEntity.thread ) paginator = DiscussionAPIPagination( - request, page, num_pages, len(filtered_threads) - ) - return paginator.get_paginated_response( - { - "results": results, - } + request, + page, + num_pages, + len(threads) ) + return paginator.get_paginated_response({ + "results": results, + }) except CommentClient500Error: return DiscussionAPIPagination( request, page_num=1, num_pages=0, - ).get_paginated_response( - { - "results": [], - } - ) + ).get_paginated_response({ + "results": [], + }) -def get_comment_list( - request, - thread_id, - endorsed, - page, - page_size, - flagged=False, - requested_fields=None, - merge_question_type_responses=False, - show_deleted=False, -): +def get_comment_list(request, thread_id, endorsed, page, page_size, flagged=False, requested_fields=None, + merge_question_type_responses=False): """ Return the list of comments in the given thread. @@ -1402,7 +1226,7 @@ def get_comment_list( discussion.rest_api.views.CommentViewSet for more detail. """ response_skip = page_size * (page - 1) - reverse_order = request.GET.get("reverse_order", False) + reverse_order = request.GET.get('reverse_order', False) from_mfe_sidebar = request.GET.get("enable_in_context_sidebar", False) cc_thread, context = _get_thread_and_context( request, @@ -1415,23 +1239,19 @@ def get_comment_list( "response_skip": response_skip, "response_limit": page_size, "reverse_order": reverse_order, - "merge_question_type_responses": merge_question_type_responses, - }, + "merge_question_type_responses": merge_question_type_responses + } ) # Responses to discussion threads cannot be separated by endorsed, but # responses to question threads must be separated by endorsed due to the # existing comments service interface if cc_thread["thread_type"] == "question" and not merge_question_type_responses: if endorsed is None: # lint-amnesty, pylint: disable=no-else-raise - raise ValidationError( - {"endorsed": ["This field is required for question threads."]} - ) + raise ValidationError({"endorsed": ["This field is required for question threads."]}) elif endorsed: # CS does not apply resp_skip and resp_limit to endorsed responses # of a question post - responses = cc_thread["endorsed_responses"][ - response_skip: (response_skip + page_size) - ] + responses = cc_thread["endorsed_responses"][response_skip:(response_skip + page_size)] resp_total = len(cc_thread["endorsed_responses"]) else: responses = cc_thread["non_endorsed_responses"] @@ -1440,11 +1260,7 @@ def get_comment_list( if not merge_question_type_responses: if endorsed is not None: raise ValidationError( - { - "endorsed": [ - "This field may not be specified for discussion threads." - ] - } + {"endorsed": ["This field may not be specified for discussion threads."]} ) responses = cc_thread["children"] resp_total = cc_thread["resp_total"] @@ -1456,21 +1272,9 @@ def get_comment_list( raise PageNotFoundError("Page not found (No results on this page).") num_pages = (resp_total + page_size - 1) // page_size if resp_total else 1 - if not show_deleted: - responses = [ - response for response in responses if not response.get("is_deleted", False) - ] - else: - if not context["has_moderation_privilege"]: - raise PermissionDenied( - "`show_deleted` can only be set by users with moderation roles." - ) - - results = _serialize_discussion_entities( - request, context, responses, requested_fields, DiscussionEntity.comment - ) + results = _serialize_discussion_entities(request, context, responses, requested_fields, DiscussionEntity.comment) - paginator = DiscussionAPIPagination(request, page, num_pages, len(responses)) + paginator = DiscussionAPIPagination(request, page, num_pages, resp_total) track_thread_viewed_event(request, context["course"], cc_thread, from_mfe_sidebar) return paginator.get_paginated_response(results) @@ -1488,9 +1292,7 @@ def _check_fields(allowed_fields, data, message): ValidationError if the given data contains a key that is not in allowed_fields """ - non_allowed_fields = { - field: [message] for field in data.keys() if field not in allowed_fields - } + non_allowed_fields = {field: [message] for field in data.keys() if field not in allowed_fields} if non_allowed_fields: raise ValidationError(non_allowed_fields) @@ -1512,7 +1314,7 @@ def _check_initializable_thread_fields(data, context): _check_fields( get_initializable_thread_fields(context), data, - "This field is not initializable.", + "This field is not initializable." ) @@ -1533,7 +1335,7 @@ def _check_initializable_comment_fields(data, context): _check_fields( get_initializable_comment_fields(context), data, - "This field is not initializable.", + "This field is not initializable." ) @@ -1543,40 +1345,28 @@ def _check_editable_fields(cc_content, data, context): editable by the requesting user """ _check_fields( - get_editable_fields(cc_content, context), data, "This field is not editable." + get_editable_fields(cc_content, context), + data, + "This field is not editable." ) -def _do_extra_actions( - api_content, cc_content, request_fields, actions_form, context, request -): +def _do_extra_actions(api_content, cc_content, request_fields, actions_form, context, request): """ Perform any necessary additional actions related to content creation or update that require a separate comments service request. """ for field, form_value in actions_form.cleaned_data.items(): - if ( - field in request_fields - and field in api_content - and form_value != api_content[field] - ): + if field in request_fields and field in api_content and form_value != api_content[field]: api_content[field] = form_value if field == "following": - _handle_following_field( - form_value, context["cc_requester"], cc_content, request - ) + _handle_following_field(form_value, context["cc_requester"], cc_content, request) elif field == "abuse_flagged": - _handle_abuse_flagged_field( - form_value, context["cc_requester"], cc_content, request - ) + _handle_abuse_flagged_field(form_value, context["cc_requester"], cc_content, request) elif field == "voted": - _handle_voted_field( - form_value, cc_content, api_content, request, context - ) + _handle_voted_field(form_value, cc_content, api_content, request, context) elif field == "read": - _handle_read_field( - api_content, form_value, context["cc_requester"], cc_content - ) + _handle_read_field(api_content, form_value, context["cc_requester"], cc_content) elif field == "pinned": _handle_pinned_field(form_value, cc_content, context["cc_requester"]) else: @@ -1586,7 +1376,7 @@ def _do_extra_actions( def _handle_following_field(form_value, user, cc_content, request): """follow/unfollow thread for the user""" course_key = CourseKey.from_string(cc_content.course_id) - course = get_course_with_access(request.user, "load", course_key) + course = get_course_with_access(request.user, 'load', course_key) if form_value: user.follow(cc_content) else: @@ -1599,19 +1389,15 @@ def _handle_following_field(form_value, user, cc_content, request): def _handle_abuse_flagged_field(form_value, user, cc_content, request): """mark or unmark thread/comment as abused""" course_key = CourseKey.from_string(cc_content.course_id) - course = get_course_with_access(request.user, "load", course_key) + course = get_course_with_access(request.user, 'load', course_key) if form_value: cc_content.flagAbuse(user, cc_content) track_discussion_reported_event(request, course, cc_content) if ENABLE_DISCUSSIONS_MFE.is_enabled(course_key): - if cc_content.type == "thread": - thread_flagged.send( - sender="flag_abuse_for_thread", user=user, post=cc_content - ) + if cc_content.type == 'thread': + thread_flagged.send(sender='flag_abuse_for_thread', user=user, post=cc_content) else: - comment_flagged.send( - sender="flag_abuse_for_comment", user=user, post=cc_content - ) + comment_flagged.send(sender='flag_abuse_for_comment', user=user, post=cc_content) else: remove_all = bool(is_privileged_user(course_key, User.objects.get(id=user.id))) cc_content.unFlagAbuse(user, cc_content, remove_all) @@ -1620,7 +1406,7 @@ def _handle_abuse_flagged_field(form_value, user, cc_content, request): def _handle_voted_field(form_value, cc_content, api_content, request, context): """vote or undo vote on thread/comment""" - signal = thread_voted if cc_content.type == "thread" else comment_voted + signal = thread_voted if cc_content.type == 'thread' else comment_voted signal.send(sender=None, user=context["request"].user, post=cc_content) if form_value: context["cc_requester"].vote(cc_content, "up") @@ -1629,11 +1415,7 @@ def _handle_voted_field(form_value, cc_content, api_content, request, context): context["cc_requester"].unvote(cc_content) api_content["vote_count"] -= 1 track_voted_event( - request, - context["course"], - cc_content, - vote_value="up", - undo_vote=not form_value, + request, context["course"], cc_content, vote_value="up", undo_vote=not form_value ) @@ -1641,7 +1423,7 @@ def _handle_read_field(api_content, form_value, user, cc_content): """ Marks thread as read for the user """ - if form_value and not cc_content["read"]: + if form_value and not cc_content['read']: user.read(cc_content) # When a thread is marked as read, all of its responses and comments # are also marked as read. @@ -1708,35 +1490,24 @@ def create_thread(request, thread_data): context = get_context(course, request) _check_initializable_thread_fields(thread_data, context) discussion_settings = CourseDiscussionSettings.get(course_key) - if "group_id" not in thread_data and is_commentable_divided( - course_key, thread_data.get("topic_id"), discussion_settings + if ( + "group_id" not in thread_data and + is_commentable_divided(course_key, thread_data.get("topic_id"), discussion_settings) ): thread_data = thread_data.copy() thread_data["group_id"] = get_group_id_for_user(user, discussion_settings) serializer = ThreadSerializer(data=thread_data, context=context) actions_form = ThreadActionsForm(thread_data) if not (serializer.is_valid() and actions_form.is_valid()): - raise ValidationError( - dict(list(serializer.errors.items()) + list(actions_form.errors.items())) - ) + raise ValidationError(dict(list(serializer.errors.items()) + list(actions_form.errors.items()))) serializer.save() cc_thread = serializer.instance - thread_created.send( - sender=None, user=user, post=cc_thread, notify_all_learners=notify_all_learners - ) + thread_created.send(sender=None, user=user, post=cc_thread, notify_all_learners=notify_all_learners) api_thread = serializer.data - _do_extra_actions( - api_thread, cc_thread, list(thread_data.keys()), actions_form, context, request - ) + _do_extra_actions(api_thread, cc_thread, list(thread_data.keys()), actions_form, context, request) - track_thread_created_event( - request, - course, - cc_thread, - actions_form.cleaned_data["following"], - from_mfe_sidebar, - notify_all_learners, - ) + track_thread_created_event(request, course, cc_thread, actions_form.cleaned_data["following"], + from_mfe_sidebar, notify_all_learners) return api_thread @@ -1775,30 +1546,15 @@ def create_comment(request, comment_data): serializer = CommentSerializer(data=comment_data, context=context) actions_form = CommentActionsForm(comment_data) if not (serializer.is_valid() and actions_form.is_valid()): - raise ValidationError( - dict(list(serializer.errors.items()) + list(actions_form.errors.items())) - ) + raise ValidationError(dict(list(serializer.errors.items()) + list(actions_form.errors.items()))) context["cc_requester"].follow(cc_thread) serializer.save() cc_comment = serializer.instance comment_created.send(sender=None, user=request.user, post=cc_comment) api_comment = serializer.data - _do_extra_actions( - api_comment, - cc_comment, - list(comment_data.keys()), - actions_form, - context, - request, - ) - track_comment_created_event( - request, - course, - cc_comment, - cc_thread["commentable_id"], - followed=False, - from_mfe_sidebar=from_mfe_sidebar, - ) + _do_extra_actions(api_comment, cc_comment, list(comment_data.keys()), actions_form, context, request) + track_comment_created_event(request, course, cc_comment, cc_thread["commentable_id"], followed=False, + from_mfe_sidebar=from_mfe_sidebar) return api_comment @@ -1820,32 +1576,24 @@ def update_thread(request, thread_id, update_data): The updated thread; see discussion.rest_api.views.ThreadViewSet for more detail. """ - cc_thread, context = _get_thread_and_context( - request, thread_id, retrieve_kwargs={"with_responses": True} - ) + cc_thread, context = _get_thread_and_context(request, thread_id, retrieve_kwargs={"with_responses": True}) _check_editable_fields(cc_thread, update_data, context) - serializer = ThreadSerializer( - cc_thread, data=update_data, partial=True, context=context - ) + serializer = ThreadSerializer(cc_thread, data=update_data, partial=True, context=context) actions_form = ThreadActionsForm(update_data) if not (serializer.is_valid() and actions_form.is_valid()): - raise ValidationError( - dict(list(serializer.errors.items()) + list(actions_form.errors.items())) - ) + raise ValidationError(dict(list(serializer.errors.items()) + list(actions_form.errors.items()))) # Only save thread object if some of the edited fields are in the thread data, not extra actions if set(update_data) - set(actions_form.fields): serializer.save() # signal to update Teams when a user edits a thread thread_edited.send(sender=None, user=request.user, post=cc_thread) api_thread = serializer.data - _do_extra_actions( - api_thread, cc_thread, list(update_data.keys()), actions_form, context, request - ) + _do_extra_actions(api_thread, cc_thread, list(update_data.keys()), actions_form, context, request) # always return read as True (and therefore unread_comment_count=0) as reasonably # accurate shortcut, rather than adding additional processing. - api_thread["read"] = True - api_thread["unread_comment_count"] = 0 + api_thread['read'] = True + api_thread['unread_comment_count'] = 0 return api_thread @@ -1880,27 +1628,16 @@ def update_comment(request, comment_id, update_data): """ cc_comment, context = _get_comment_and_context(request, comment_id) _check_editable_fields(cc_comment, update_data, context) - serializer = CommentSerializer( - cc_comment, data=update_data, partial=True, context=context - ) + serializer = CommentSerializer(cc_comment, data=update_data, partial=True, context=context) actions_form = CommentActionsForm(update_data) if not (serializer.is_valid() and actions_form.is_valid()): - raise ValidationError( - dict(list(serializer.errors.items()) + list(actions_form.errors.items())) - ) + raise ValidationError(dict(list(serializer.errors.items()) + list(actions_form.errors.items()))) # Only save comment object if some of the edited fields are in the comment data, not extra actions if set(update_data) - set(actions_form.fields): serializer.save() comment_edited.send(sender=None, user=request.user, post=cc_comment) api_comment = serializer.data - _do_extra_actions( - api_comment, - cc_comment, - list(update_data.keys()), - actions_form, - context, - request, - ) + _do_extra_actions(api_comment, cc_comment, list(update_data.keys()), actions_form, context, request) _handle_comment_signals(update_data, cc_comment, request.user) return api_comment @@ -1934,9 +1671,7 @@ def get_thread(request, thread_id, requested_fields=None, course_id=None): ) if course_id and course_id != cc_thread.course_id: raise ThreadNotFoundError("Thread not found.") - return _serialize_discussion_entities( - request, context, [cc_thread], requested_fields, DiscussionEntity.thread - )[0] + return _serialize_discussion_entities(request, context, [cc_thread], requested_fields, DiscussionEntity.thread)[0] def get_response_comments(request, comment_id, page, page_size, requested_fields=None): @@ -1964,10 +1699,7 @@ def get_response_comments(request, comment_id, page, page_size, requested_fields """ try: cc_comment = Comment(id=comment_id).retrieve() - reverse_order = request.GET.get("reverse_order", False) - show_deleted = request.GET.get("show_deleted", False) - show_deleted = show_deleted in ["true", "True", True] - + reverse_order = request.GET.get('reverse_order', False) cc_thread, context = _get_thread_and_context( request, cc_comment["thread_id"], @@ -1975,13 +1707,10 @@ def get_response_comments(request, comment_id, page, page_size, requested_fields "with_responses": True, "recursive": True, "reverse_order": reverse_order, - "show_deleted": show_deleted, - }, + } ) if cc_thread["thread_type"] == "question": - thread_responses = itertools.chain( - cc_thread["endorsed_responses"], cc_thread["non_endorsed_responses"] - ) + thread_responses = itertools.chain(cc_thread["endorsed_responses"], cc_thread["non_endorsed_responses"]) else: thread_responses = cc_thread["children"] response_comments = [] @@ -1991,35 +1720,16 @@ def get_response_comments(request, comment_id, page, page_size, requested_fields break response_skip = page_size * (page - 1) - paged_response_comments = response_comments[ - response_skip: (response_skip + page_size) - ] + paged_response_comments = response_comments[response_skip:(response_skip + page_size)] if not paged_response_comments and page != 1: raise PageNotFoundError("Page not found (No results on this page).") - if not show_deleted: - paged_response_comments = [ - response - for response in paged_response_comments - if not response.get("is_deleted", False) - ] - else: - if not context["has_moderation_privilege"]: - raise PermissionDenied( - "`show_deleted` can only be set by users with moderation roles." - ) results = _serialize_discussion_entities( - request, - context, - paged_response_comments, - requested_fields, - DiscussionEntity.comment, + request, context, paged_response_comments, requested_fields, DiscussionEntity.comment ) - comments_count = len(paged_response_comments) - num_pages = ( - (comments_count + page_size - 1) // page_size if comments_count else 1 - ) + comments_count = len(response_comments) + num_pages = (comments_count + page_size - 1) // page_size if comments_count else 1 paginator = DiscussionAPIPagination(request, page, num_pages, comments_count) return paginator.get_paginated_response(results) except CommentClientRequestError as err: @@ -2063,20 +1773,16 @@ def get_user_comments( context = get_context(course, request) if flagged and not context["has_moderation_privilege"]: - raise ValidationError( - "Only privileged users can filter comments by flagged status" - ) + raise ValidationError("Only privileged users can filter comments by flagged status") try: - response = Comment.retrieve_all( - { - "user_id": author.id, - "course_id": str(course_key), - "flagged": flagged, - "page": page, - "per_page": page_size, - } - ) + response = Comment.retrieve_all({ + 'user_id': author.id, + 'course_id': str(course_key), + 'flagged': flagged, + 'page': page, + 'per_page': page_size, + }) except CommentClientRequestError as err: raise CommentNotFoundError("Comment not found") from err @@ -2116,7 +1822,7 @@ def delete_thread(request, thread_id): """ cc_thread, context = _get_thread_and_context(request, thread_id) if can_delete(cc_thread, context): - cc_thread.delete(deleted_by=str(request.user.id)) + cc_thread.delete() thread_deleted.send(sender=None, user=request.user, post=cc_thread) track_thread_deleted_event(request, context["course"], cc_thread) else: @@ -2141,7 +1847,7 @@ def delete_comment(request, comment_id): """ cc_comment, context = _get_comment_and_context(request, comment_id) if can_delete(cc_comment, context): - cc_comment.delete(deleted_by=str(request.user.id)) + cc_comment.delete() comment_deleted.send(sender=None, user=request.user, post=cc_comment) track_comment_deleted_event(request, context["course"], cc_comment) else: @@ -2173,10 +1879,7 @@ def get_course_discussion_user_stats( """ course_key = CourseKey.from_string(course_key_str) - is_privileged = ( - has_discussion_privileges(user=request.user, course_id=course_key) - or request.user.is_staff - ) + is_privileged = has_discussion_privileges(user=request.user, course_id=course_key) or request.user.is_staff if is_privileged: order_by = order_by or UserOrdering.BY_FLAGS else: @@ -2185,35 +1888,30 @@ def get_course_discussion_user_stats( raise ValidationError({"order_by": "Invalid value"}) params = { - "sort_key": str(order_by), - "page": page, - "per_page": page_size, + 'sort_key': str(order_by), + 'page': page, + 'per_page': page_size, } comma_separated_usernames = matched_users_count = matched_users_pages = None if username_search_string: - comma_separated_usernames, matched_users_count, matched_users_pages = ( - get_usernames_from_search_string( - course_key, username_search_string, page, page_size - ) + comma_separated_usernames, matched_users_count, matched_users_pages = get_usernames_from_search_string( + course_key, username_search_string, page, page_size ) search_event_data = { - "query": username_search_string, - "search_type": "Learner", - "page": params.get("page"), - "sort_key": params.get("sort_key"), - "total_results": matched_users_count, + 'query': username_search_string, + 'search_type': 'Learner', + 'page': params.get('page'), + 'sort_key': params.get('sort_key'), + 'total_results': matched_users_count, } course = _get_course(course_key, request.user) track_forum_search_event(request, course, search_event_data) - if not comma_separated_usernames: - return DiscussionAPIPagination(request, 0, 1).get_paginated_response( - { - "results": [], - } - ) + return DiscussionAPIPagination(request, 0, 1).get_paginated_response({ + "results": [], + }) - params["usernames"] = comma_separated_usernames + params['usernames'] = comma_separated_usernames course_stats_response = get_course_user_stats(course_key, params) @@ -2233,429 +1931,71 @@ def get_course_discussion_user_stats( paginator = DiscussionAPIPagination( request, course_stats_response["page"], - ( - matched_users_pages - if username_search_string - else course_stats_response["num_pages"] - ), - ( - matched_users_count - if username_search_string - else course_stats_response["count"] - ), - ) - return paginator.get_paginated_response( - { - "results": serializer.data, - } + matched_users_pages if username_search_string else course_stats_response["num_pages"], + matched_users_count if username_search_string else course_stats_response["count"], ) + return paginator.get_paginated_response({ + "results": serializer.data, + }) def get_users_without_stats( - username_search_string, course_key, page_number, page_size, request, is_privileged + username_search_string, + course_key, + page_number, + page_size, + request, + is_privileged ): """ This return users with no user stats. This function will be deprecated when this ticket DOS-3414 is resolved """ if username_search_string: - comma_separated_usernames, matched_users_count, matched_users_pages = ( - get_usernames_from_search_string( - course_key, username_search_string, page_number, page_size - ) + comma_separated_usernames, matched_users_count, matched_users_pages = get_usernames_from_search_string( + course_key, username_search_string, page_number, page_size ) if not comma_separated_usernames: - return DiscussionAPIPagination(request, 0, 1).get_paginated_response( - { - "results": [], - } - ) + return DiscussionAPIPagination(request, 0, 1).get_paginated_response({ + "results": [], + }) else: - comma_separated_usernames, matched_users_count, matched_users_pages = ( - get_usernames_for_course(course_key, page_number, page_size) + comma_separated_usernames, matched_users_count, matched_users_pages = get_usernames_for_course( + course_key, page_number, page_size ) if comma_separated_usernames: - updated_course_stats = add_stats_for_users_with_null_values( - [], comma_separated_usernames - ) + updated_course_stats = add_stats_for_users_with_null_values([], comma_separated_usernames) - serializer = UserStatsSerializer( - updated_course_stats, context={"is_privileged": is_privileged}, many=True - ) + serializer = UserStatsSerializer(updated_course_stats, context={"is_privileged": is_privileged}, many=True) paginator = DiscussionAPIPagination( request, page_number, matched_users_pages, matched_users_count, ) - return paginator.get_paginated_response( - { - "results": serializer.data, - } - ) + return paginator.get_paginated_response({ + "results": serializer.data, + }) def add_stats_for_users_with_null_values(course_stats, users_in_course): """ Update users stats for users with no discussion stats available in course """ - users_returned_from_api = [user["username"] for user in course_stats] - user_list = users_in_course.split(",") + users_returned_from_api = [user['username'] for user in course_stats] + user_list = users_in_course.split(',') users_with_no_discussion_content = set(user_list) ^ set(users_returned_from_api) updated_course_stats = course_stats for user in users_with_no_discussion_content: - updated_course_stats.append( - { - "username": user, - "threads": None, - "replies": None, - "responses": None, - "active_flags": None, - "inactive_flags": None, - } - ) - updated_course_stats = sorted( - updated_course_stats, key=lambda d: len(d["username"]) - ) + updated_course_stats.append({ + 'username': user, + 'threads': None, + 'replies': None, + 'responses': None, + 'active_flags': None, + 'inactive_flags': None, + }) + updated_course_stats = sorted(updated_course_stats, key=lambda d: len(d['username'])) return updated_course_stats - - -def _get_user_label_function(course_staff_user_ids, moderator_user_ids, ta_user_ids): - """ - Create and return a function that determines user labels based on role. - - Args: - course_staff_user_ids: List of user IDs for course staff - moderator_user_ids: List of user IDs for moderators - ta_user_ids: List of user IDs for TAs - - Returns: - A function that takes a user_id and returns the appropriate label or None - """ - - def get_user_label(user_id): - """Get role label for a user ID.""" - try: - user_id_int = int(user_id) - if user_id_int in course_staff_user_ids: - return "Staff" - elif user_id_int in moderator_user_ids: - return "Moderator" - elif user_id_int in ta_user_ids: - return "Community TA" - except (ValueError, TypeError): - # If user_id has any issues, there's no label to return - pass - return None - - return get_user_label - - -def _process_deleted_thread(thread_data, get_user_label_fn, usernames_set): - """ - Process a single deleted thread into the standardized content item format. - - Args: - thread_data: Raw thread data from forum API - get_user_label_fn: Function to get user labels by user ID - usernames_set: Set to collect usernames for profile image fetch (modified in-place) - - Returns: - dict: Formatted content item for the thread - """ - author_username = thread_data.get("author_username", "") - deleted_by_id = thread_data.get("deleted_by") - deleted_by_username = None - - # Get deleted_by username - if deleted_by_id: - try: - deleted_user = User.objects.get(id=int(deleted_by_id)) - deleted_by_username = deleted_user.username - usernames_set.add(deleted_by_username) - except (User.DoesNotExist, ValueError): - # If user not found or invalid ID, skip setting deleted fields - pass - - if author_username: - usernames_set.add(author_username) - - # Strip HTML tags from preview - body_text = thread_data.get("body", "") - preview_text = strip_tags(body_text)[:100] if body_text else "" - - thread_id = thread_data.get("_id", thread_data.get("id")) - return { - "id": str(thread_id) + "-thread", - "type": "thread", - "title": thread_data.get("title", ""), - "body": body_text, - "preview_body": preview_text, - "course_id": thread_data.get("course_id", ""), - "author": author_username, - "author_id": thread_data.get("author_id", ""), - "author_label": get_user_label_fn(thread_data.get("author_id")), - "commentable_id": thread_data.get("commentable_id", ""), - "created_at": thread_data.get("created_at"), - "updated_at": thread_data.get("updated_at"), - "is_deleted": True, - "deleted_at": thread_data.get("deleted_at"), - "deleted_by": deleted_by_username, - "deleted_by_label": get_user_label_fn(deleted_by_id) if deleted_by_id else None, - "thread_type": thread_data.get("thread_type", "discussion"), - "anonymous": thread_data.get("anonymous", False), - "anonymous_to_peers": thread_data.get("anonymous_to_peers", False), - "vote_count": thread_data.get("vote_count", 0), - "comment_count": thread_data.get("comment_count", 0), - } - - -def _process_deleted_comment(comment_data, get_user_label_fn, usernames_set): - """ - Process a single deleted comment into the standardized content item format. - - Args: - comment_data: Raw comment data from forum API - get_user_label_fn: Function to get user labels by user ID - usernames_set: Set to collect usernames for profile image fetch (modified in-place) - - Returns: - dict: Formatted content item for the comment - """ - author_username = comment_data.get("author_username", "") - deleted_by_id = comment_data.get("deleted_by") - deleted_by_username = None - - # Get deleted_by username - if deleted_by_id: - try: - deleted_user = User.objects.get(id=int(deleted_by_id)) - deleted_by_username = deleted_user.username - usernames_set.add(deleted_by_username) - except (User.DoesNotExist, ValueError): - # If user not found or invalid ID, skip setting deleted fields - pass - - if author_username: - usernames_set.add(author_username) - - # Determine if this is a response (depth=0) or comment (depth>0) - depth = comment_data.get("depth", 0) - comment_type = "response" if depth == 0 else "comment" - - # Get parent thread title for context - thread_id = comment_data.get("comment_thread_id", "") - thread_title = "" - if thread_id: - try: - parent_thread = Thread(id=thread_id).retrieve() - thread_title = parent_thread.get("title", "") - except Exception: # pylint: disable=broad-exception-caught - pass - - # Strip HTML tags from preview - body_text = comment_data.get("body", "") - preview_text = strip_tags(body_text)[:100] if body_text else "" - - comment_id = comment_data.get("_id", comment_data.get("id")) - return { - "id": str(comment_id) + "-comment", - "type": comment_type, - "body": body_text, - "preview_body": preview_text, - "title": thread_title, # Use parent thread title for comments/responses - "course_id": comment_data.get("course_id", ""), - "author": author_username, - "author_id": comment_data.get("author_id", ""), - "author_label": get_user_label_fn(comment_data.get("author_id")), - "comment_thread_id": str(thread_id), - "thread_title": thread_title, - "parent_id": ( - str(comment_data.get("parent_id", "")) - if comment_data.get("parent_id") - else None - ), - "created_at": comment_data.get("created_at"), - "updated_at": comment_data.get("updated_at"), - "is_deleted": True, - "deleted_at": comment_data.get("deleted_at"), - "deleted_by": deleted_by_username, - "deleted_by_label": get_user_label_fn(deleted_by_id) if deleted_by_id else None, - "depth": depth, - "anonymous": comment_data.get("anonymous", False), - "anonymous_to_peers": comment_data.get("anonymous_to_peers", False), - "endorsed": comment_data.get("endorsed", False), - "vote_count": comment_data.get("vote_count", 0), - } - - -def _add_user_profiles_to_content(deleted_content, usernames_set, request): - """ - Fetch user profile images and add them to each content item. - - Args: - deleted_content: List of content items (modified in-place) - usernames_set: Set of usernames to fetch profile images for - request: Django request object for getting profile images - """ - # Add profile images for all users - username_profile_dict = _get_user_profile_dict( - request, usernames=",".join(usernames_set) - ) - - # Add users dict with profile images to each item - for item in deleted_content: - users_dict = {} - - # Add author profile - author_username = item.get("author") - if author_username and author_username in username_profile_dict: - users_dict[author_username] = _user_profile( - username_profile_dict[author_username] - ) - - # Add deleted_by profile - deleted_by_username = item.get("deleted_by") - if deleted_by_username and deleted_by_username in username_profile_dict: - users_dict[deleted_by_username] = _user_profile( - username_profile_dict[deleted_by_username] - ) - - item["users"] = users_dict - - -def get_deleted_content_for_course( - request, course_id, content_type=None, page=1, per_page=20, author_id=None -): - """ - Retrieve all deleted content (threads, comments) for a course. - - Args: - request: The django request object for getting user profile images - course_id (str): Course identifier - content_type (str, optional): Filter by 'thread' or 'comment'. If None, returns all types. - page (int): Page number for pagination (1-based) - per_page (int): Number of items per page - author_id (str, optional): Filter by author ID - - Returns: - dict: Paginated results with deleted content including author labels and profile images - """ - - import math - - from lms.djangoapps.discussion.rest_api.utils import ( - get_course_staff_users_list, - get_course_ta_users_list, - get_moderator_users_list, - ) - - try: - # Get course and user role information for labels - course_key = CourseKey.from_string(course_id) - course = _get_course(course_key, request.user) - - course_staff_user_ids = get_course_staff_users_list(course.id) - moderator_user_ids = get_moderator_users_list(course.id) - ta_user_ids = get_course_ta_users_list(course.id) - - # Get user label function - get_user_label = _get_user_label_function( - course_staff_user_ids, moderator_user_ids, ta_user_ids - ) - - # Build query parameters for forum API - query_params = { - "course_id": course_id, - "is_deleted": True, # Only get deleted content - "page": page, - "per_page": per_page, - } - - if author_id: - query_params["author_id"] = author_id - - deleted_content = [] - total_count = 0 - usernames_set = set() # Track all usernames for profile image fetch - - # Get deleted threads - if content_type is None or content_type == "thread": - try: - deleted_threads = forum_api.get_deleted_threads_for_course( - course_id=course_id, - page=page if content_type == "thread" else 1, - per_page=per_page if content_type == "thread" else 1000, - author_id=author_id, - ) - for thread_data in deleted_threads.get("threads", []): - content_item = _process_deleted_thread( - thread_data, get_user_label, usernames_set - ) - deleted_content.append(content_item) - - if content_type == "thread": - total_count = deleted_threads.get( - "total_count", len(deleted_content) - ) - except Exception as e: # pylint: disable=broad-exception-caught - log.warning( - "Failed to get deleted threads for course %s: %s", course_id, e - ) - - # Get deleted comments - if content_type is None or content_type == "comment": - try: - deleted_comments = forum_api.get_deleted_comments_for_course( - course_id=course_id, - page=page if content_type == "comment" else 1, - per_page=per_page if content_type == "comment" else 1000, - author_id=author_id, - ) - for comment_data in deleted_comments.get("comments", []): - content_item = _process_deleted_comment( - comment_data, get_user_label, usernames_set - ) - deleted_content.append(content_item) - - if content_type == "comment": - total_count = deleted_comments.get( - "total_count", len(deleted_content) - ) - except Exception as e: # pylint: disable=broad-exception-caught - log.warning( - "Failed to get deleted comments for course %s: %s", course_id, e - ) - - # If getting all content types, handle pagination differently - if content_type is None: - total_count = len(deleted_content) - # Sort by deletion date (most recent first) - deleted_content.sort(key=lambda x: x.get("deleted_at", ""), reverse=True) - - # Apply pagination to combined results - start_idx = (page - 1) * per_page - end_idx = start_idx + per_page - deleted_content = deleted_content[start_idx:end_idx] - - # Add profile images for all users - _add_user_profiles_to_content(deleted_content, usernames_set, request) - - # Calculate pagination info - num_pages = math.ceil(total_count / per_page) if total_count > 0 else 1 - - return { - "results": deleted_content, - "pagination": { - "next": None, # Can be computed if needed - "previous": None, # Can be computed if needed - "count": total_count, - "num_pages": num_pages, - }, - } - - except Exception as e: - log.exception("Error getting deleted content for course %s: %s", course_id, e) - raise diff --git a/lms/djangoapps/discussion/rest_api/forms.py b/lms/djangoapps/discussion/rest_api/forms.py index f37543723792..8cc7127645b2 100644 --- a/lms/djangoapps/discussion/rest_api/forms.py +++ b/lms/djangoapps/discussion/rest_api/forms.py @@ -1,7 +1,6 @@ """ Discussion API forms """ - import urllib.parse from django.core.exceptions import ValidationError @@ -23,15 +22,13 @@ class UserOrdering(TextChoices): - BY_ACTIVITY = "activity" - BY_FLAGS = "flagged" - BY_RECENT_ACTIVITY = "recency" - BY_DELETED = "deleted" + BY_ACTIVITY = 'activity' + BY_FLAGS = 'flagged' + BY_RECENT_ACTIVITY = 'recency' class _PaginationForm(Form): """A form that includes pagination fields""" - page = IntegerField(required=False, min_value=1) page_size = IntegerField(required=False, min_value=1) @@ -48,7 +45,6 @@ class ThreadListGetForm(_PaginationForm): """ A form to validate query parameters in the thread list retrieval endpoint """ - EXCLUSIVE_PARAMS = ["topic_id", "text_search", "following"] course_id = CharField() @@ -62,22 +58,17 @@ class ThreadListGetForm(_PaginationForm): ) count_flagged = ExtendedNullBooleanField(required=False) flagged = ExtendedNullBooleanField(required=False) - show_deleted = ExtendedNullBooleanField(required=False) view = ChoiceField( - choices=[ - (choice, choice) for choice in ["unread", "unanswered", "unresponded"] - ], + choices=[(choice, choice) for choice in ["unread", "unanswered", "unresponded"]], required=False, ) order_by = ChoiceField( - choices=[ - (choice, choice) - for choice in ["last_activity_at", "comment_count", "vote_count"] - ], - required=False, + choices=[(choice, choice) for choice in ["last_activity_at", "comment_count", "vote_count"]], + required=False ) order_direction = ChoiceField( - choices=[(choice, choice) for choice in ["desc"]], required=False + choices=[(choice, choice) for choice in ["desc"]], + required=False ) requested_fields = MultiValueField(required=False) @@ -94,16 +85,14 @@ def clean_course_id(self): value = self.cleaned_data["course_id"] try: return CourseLocator.from_string(value) - except InvalidKeyError as e: - raise ValidationError(f"'{value}' is not a valid course id") from e + except InvalidKeyError: + raise ValidationError(f"'{value}' is not a valid course id") # lint-amnesty, pylint: disable=raise-missing-from def clean_following(self): """Validate following""" value = self.cleaned_data["following"] if value is False: # lint-amnesty, pylint: disable=no-else-raise - raise ValidationError( - "The value of the 'following' parameter must be true." - ) + raise ValidationError("The value of the 'following' parameter must be true.") else: return value @@ -126,7 +115,6 @@ class ThreadActionsForm(Form): A form to handle fields in thread creation/update that require separate interactions with the comments service. """ - following = BooleanField(required=False) voted = BooleanField(required=False) abuse_flagged = BooleanField(required=False) @@ -138,20 +126,17 @@ class CommentListGetForm(_PaginationForm): """ A form to validate query parameters in the comment list retrieval endpoint """ - thread_id = CharField() flagged = BooleanField(required=False) endorsed = ExtendedNullBooleanField(required=False) requested_fields = MultiValueField(required=False) merge_question_type_responses = BooleanField(required=False) - show_deleted = ExtendedNullBooleanField(required=False) class UserCommentListGetForm(_PaginationForm): """ A form to validate query parameters in the comment list retrieval endpoint """ - course_id = CharField() flagged = BooleanField(required=False) requested_fields = MultiValueField(required=False) @@ -161,8 +146,8 @@ def clean_course_id(self): value = self.cleaned_data["course_id"] try: return CourseLocator.from_string(value) - except InvalidKeyError as e: - raise ValidationError(f"'{value}' is not a valid course id") from e + except InvalidKeyError: + raise ValidationError(f"'{value}' is not a valid course id") # lint-amnesty, pylint: disable=raise-missing-from class CommentActionsForm(Form): @@ -170,7 +155,6 @@ class CommentActionsForm(Form): A form to handle fields in comment creation/update that require separate interactions with the comments service. """ - voted = BooleanField(required=False) abuse_flagged = BooleanField(required=False) @@ -179,7 +163,6 @@ class CommentGetForm(_PaginationForm): """ A form to validate query parameters in the comment retrieval endpoint """ - requested_fields = MultiValueField(required=False) @@ -187,34 +170,28 @@ class CourseDiscussionSettingsForm(Form): """ A form to validate the fields in the course discussion settings requests. """ - course_id = CharField() def __init__(self, *args, **kwargs): - self.request_user = kwargs.pop("request_user") + self.request_user = kwargs.pop('request_user') super().__init__(*args, **kwargs) def clean_course_id(self): """Validate the 'course_id' value""" - course_id = self.cleaned_data["course_id"] + course_id = self.cleaned_data['course_id'] try: course_key = CourseKey.from_string(course_id) - self.cleaned_data["course"] = get_course_with_access( - self.request_user, "load", course_key - ) - self.cleaned_data["course_key"] = course_key + self.cleaned_data['course'] = get_course_with_access(self.request_user, 'load', course_key) + self.cleaned_data['course_key'] = course_key return course_id - except InvalidKeyError as e: - raise ValidationError( - f"'{str(course_id)}' is not a valid course key" - ) from e + except InvalidKeyError: + raise ValidationError(f"'{str(course_id)}' is not a valid course key") # lint-amnesty, pylint: disable=raise-missing-from class CourseDiscussionRolesForm(CourseDiscussionSettingsForm): """ A form to validate the fields in the course discussion roles requests. """ - ROLE_CHOICES = ( (FORUM_ROLE_MODERATOR, FORUM_ROLE_MODERATOR), (FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR), @@ -222,20 +199,20 @@ class CourseDiscussionRolesForm(CourseDiscussionSettingsForm): ) rolename = ChoiceField( choices=ROLE_CHOICES, - error_messages={"invalid_choice": "Role '%(value)s' does not exist"}, + error_messages={"invalid_choice": "Role '%(value)s' does not exist"} ) def clean_rolename(self): """Validate the 'rolename' value.""" - rolename = urllib.parse.unquote(self.cleaned_data.get("rolename")) - course_id = self.cleaned_data.get("course_key") + rolename = urllib.parse.unquote(self.cleaned_data.get('rolename')) + course_id = self.cleaned_data.get('course_key') if course_id and rolename: try: role = Role.objects.get(name=rolename, course_id=course_id) except Role.DoesNotExist as err: raise ValidationError(f"Role '{rolename}' does not exist") from err - self.cleaned_data["role"] = role + self.cleaned_data['role'] = role return rolename @@ -243,17 +220,15 @@ class TopicListGetForm(Form): """ Form for the topics API get query parameters. """ - topic_id = CharField(required=False) order_by = ChoiceField(choices=TopicOrdering.choices, required=False) def clean_topic_id(self): topic_ids = self.cleaned_data.get("topic_id", None) - return set(topic_ids.strip(",").split(",")) if topic_ids else None + return set(topic_ids.strip(',').split(',')) if topic_ids else None class CourseActivityStatsForm(_PaginationForm): """Form for validating course activity stats API query parameters""" - order_by = ChoiceField(choices=UserOrdering.choices, required=False) username = CharField(required=False) diff --git a/lms/djangoapps/discussion/rest_api/serializers.py b/lms/djangoapps/discussion/rest_api/serializers.py index 902a433dac3b..8a7ab16e0903 100644 --- a/lms/djangoapps/discussion/rest_api/serializers.py +++ b/lms/djangoapps/discussion/rest_api/serializers.py @@ -1,13 +1,13 @@ """ Discussion API serializers """ - import html import re + +from bs4 import BeautifulSoup from typing import Dict from urllib.parse import urlencode, urlunparse -from bs4 import BeautifulSoup from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist, ValidationError @@ -18,12 +18,8 @@ from common.djangoapps.student.models import get_user_by_username_or_email from common.djangoapps.student.roles import GlobalStaff -from lms.djangoapps.discussion.django_comment_client.base.views import ( - track_comment_edited_event, - track_forum_response_mark_event, - track_thread_edited_event, - track_thread_lock_unlock_event, -) +from lms.djangoapps.discussion.django_comment_client.base.views import track_thread_lock_unlock_event, \ + track_thread_edited_event, track_comment_edited_event, track_forum_response_mark_event from lms.djangoapps.discussion.django_comment_client.utils import ( course_discussion_division_enabled, get_group_id_for_user, @@ -39,23 +35,17 @@ from lms.djangoapps.discussion.rest_api.render import render_body from lms.djangoapps.discussion.rest_api.utils import ( get_course_staff_users_list, - get_course_ta_users_list, get_moderator_users_list, + get_course_ta_users_list, get_user_learner_status, ) from openedx.core.djangoapps.discussions.models import DiscussionTopicLink from openedx.core.djangoapps.discussions.utils import get_group_names_by_id from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread -from openedx.core.djangoapps.django_comment_common.comment_client.user import ( - User as CommentClientUser, -) -from openedx.core.djangoapps.django_comment_common.comment_client.utils import ( - CommentClientRequestError, -) -from openedx.core.djangoapps.django_comment_common.models import ( - CourseDiscussionSettings, -) +from openedx.core.djangoapps.django_comment_common.comment_client.user import User as CommentClientUser +from openedx.core.djangoapps.django_comment_common.comment_client.utils import CommentClientRequestError +from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings from openedx.core.djangoapps.user_api.accounts.api import get_profile_images from openedx.core.lib.api.serializers import CourseKeyField @@ -69,7 +59,6 @@ class TopicOrdering(TextChoices): """ Enum for the available options for ordering topics. """ - COURSE_STRUCTURE = "course_structure", "Course Structure" ACTIVITY = "activity", "Activity" NAME = "name", "Name" @@ -84,24 +73,16 @@ def get_context(course, request, thread=None): moderator_user_ids = get_moderator_users_list(course.id) ta_user_ids = get_course_ta_users_list(course.id) requester = request.user - cc_requester = CommentClientUser.from_django_user(requester).retrieve( - course_id=course.id - ) + cc_requester = CommentClientUser.from_django_user(requester).retrieve(course_id=course.id) cc_requester["course_id"] = course.id course_discussion_settings = CourseDiscussionSettings.get(course.id) is_global_staff = GlobalStaff().has_user(requester) - has_moderation_privilege = ( - requester.id in moderator_user_ids - or requester.id in ta_user_ids - or is_global_staff - ) + has_moderation_privilege = requester.id in moderator_user_ids or requester.id in ta_user_ids or is_global_staff return { "course": course, "request": request, "thread": thread, - "discussion_division_enabled": course_discussion_division_enabled( - course_discussion_settings - ), + "discussion_division_enabled": course_discussion_division_enabled(course_discussion_settings), "group_ids_to_names": get_group_names_by_id(course_discussion_settings), "moderator_user_ids": moderator_user_ids, "course_staff_user_ids": course_staff_user_ids, @@ -156,8 +137,8 @@ def _validate_privileged_access(context: Dict) -> bool: Returns: bool: Course exists and the user has privileged access. """ - course = context.get("course", None) - is_requester_privileged = context.get("has_moderation_privilege") + course = context.get('course', None) + is_requester_privileged = context.get('has_moderation_privilege') return course and is_requester_privileged @@ -177,7 +158,7 @@ def filter_spam_urls_from_html(html_string): patterns.append(re.compile(rf"(https?://)?{domain_pattern}", re.IGNORECASE)) for a_tag in soup.find_all("a", href=True): - href = a_tag.get("href") + href = a_tag.get('href') if href: if any(p.search(href) for p in patterns): a_tag.replace_with(a_tag.get_text(strip=True)) @@ -186,7 +167,7 @@ def filter_spam_urls_from_html(html_string): for text_node in soup.find_all(string=True): new_text = text_node for p in patterns: - new_text = p.sub("", new_text) + new_text = p.sub('', new_text) if new_text != text_node: text_node.replace_with(new_text.strip()) is_spam = True @@ -215,14 +196,8 @@ class _ContentSerializer(serializers.Serializer): anonymous = serializers.BooleanField(default=False) anonymous_to_peers = serializers.BooleanField(default=False) last_edit = serializers.SerializerMethodField(required=False) - edit_reason_code = serializers.CharField( - required=False, validators=[validate_edit_reason_code] - ) + edit_reason_code = serializers.CharField(required=False, validators=[validate_edit_reason_code]) edit_by_label = serializers.SerializerMethodField(required=False) - is_deleted = serializers.SerializerMethodField(read_only=True) - deleted_at = serializers.SerializerMethodField(read_only=True) - deleted_by = serializers.SerializerMethodField(read_only=True) - deleted_by_label = serializers.SerializerMethodField(read_only=True) non_updatable_fields = set() @@ -244,10 +219,7 @@ def _is_user_privileged(self, user_id): Returns a boolean indicating whether the given user_id identifies a privileged user. """ - return ( - user_id in self.context["moderator_user_ids"] - or user_id in self.context["ta_user_ids"] - ) + return user_id in self.context["moderator_user_ids"] or user_id in self.context["ta_user_ids"] def _is_anonymous(self, obj): """ @@ -255,12 +227,12 @@ def _is_anonymous(self, obj): the requester. """ user_id = self.context["request"].user.id - is_user_staff = ( - user_id in self.context["moderator_user_ids"] - or user_id in self.context["ta_user_ids"] - ) + is_user_staff = user_id in self.context["moderator_user_ids"] or user_id in self.context["ta_user_ids"] - return obj["anonymous"] or obj["anonymous_to_peers"] and not is_user_staff + return ( + obj["anonymous"] or + obj["anonymous_to_peers"] and not is_user_staff + ) def get_author(self, obj): """ @@ -278,9 +250,10 @@ def _get_user_label(self, user_id): is_ta = user_id in self.context["ta_user_ids"] return ( - "Staff" - if is_staff - else "Moderator" if is_moderator else "Community TA" if is_ta else None + "Staff" if is_staff else + "Moderator" if is_moderator else + "Community TA" if is_ta else + None ) def _get_user_label_from_username(self, username): @@ -330,9 +303,7 @@ def get_rendered_body(self, obj): """ if self._rendered_body is None: self._rendered_body = render_body(obj["body"]) - self._rendered_body, is_spam = filter_spam_urls_from_html( - self._rendered_body - ) + self._rendered_body, is_spam = filter_spam_urls_from_html(self._rendered_body) if is_spam and settings.CONTENT_FOR_SPAM_POSTS: self._rendered_body = settings.CONTENT_FOR_SPAM_POSTS return self._rendered_body @@ -344,9 +315,8 @@ def get_abuse_flagged(self, obj): """ total_abuse_flaggers = len(obj.get("abuse_flaggers", [])) return ( - self.context["has_moderation_privilege"] - and total_abuse_flaggers > 0 - or self.context["cc_requester"]["id"] in obj.get("abuse_flaggers", []) + self.context["has_moderation_privilege"] and total_abuse_flaggers > 0 or + self.context["cc_requester"]["id"] in obj.get("abuse_flaggers", []) ) def get_voted(self, obj): @@ -379,7 +349,7 @@ def get_last_edit(self, obj): Returns information about the last edit for this content for privileged users. """ - is_user_author = str(obj["user_id"]) == str(self.context["request"].user.id) + is_user_author = str(obj['user_id']) == str(self.context['request'].user.id) if not (_validate_privileged_access(self.context) or is_user_author): return None edit_history = obj.get("edit_history") @@ -395,57 +365,12 @@ def get_edit_by_label(self, obj): """ Returns the role label for the last edit user. """ - is_user_author = str(obj["user_id"]) == str(self.context["request"].user.id) + is_user_author = str(obj['user_id']) == str(self.context['request'].user.id) is_user_privileged = _validate_privileged_access(self.context) edit_history = obj.get("edit_history") if (is_user_author or is_user_privileged) and edit_history: last_edit = edit_history[-1] - return self._get_user_label_from_username(last_edit.get("editor_username")) - - def get_is_deleted(self, obj): - """ - Returns the is_deleted status for privileged users only. - """ - if not _validate_privileged_access(self.context): - return None - return obj.get("is_deleted", False) - - def get_deleted_at(self, obj): - """ - Returns the deletion timestamp for privileged users only. - """ - if not _validate_privileged_access(self.context): - return None - return obj.get("deleted_at") - - def get_deleted_by(self, obj): - """ - Returns the username of the user who deleted this content for privileged users only. - """ - if not _validate_privileged_access(self.context): - return None - deleted_by_id = obj.get("deleted_by") - if deleted_by_id: - try: - user = User.objects.get(id=int(deleted_by_id)) - return user.username - except (User.DoesNotExist, ValueError): - return None - return None - - def get_deleted_by_label(self, obj): - """ - Returns the role label for the user who deleted this content for privileged users only. - """ - if not _validate_privileged_access(self.context): - return None - deleted_by_id = obj.get("deleted_by") - if deleted_by_id: - try: - return self._get_user_label(int(deleted_by_id)) - except (ValueError, TypeError): - return None - return None + return self._get_user_label_from_username(last_edit.get('editor_username')) class ThreadSerializer(_ContentSerializer): @@ -456,15 +381,13 @@ class ThreadSerializer(_ContentSerializer): not had retrieve() called, because of the interaction between DRF's attempts at introspection and Thread's __getattr__. """ - course_id = serializers.CharField() - topic_id = serializers.CharField( - source="commentable_id", validators=[validate_not_blank] - ) + topic_id = serializers.CharField(source="commentable_id", validators=[validate_not_blank]) group_id = serializers.IntegerField(required=False, allow_null=True) group_name = serializers.SerializerMethodField() type = serializers.ChoiceField( - source="thread_type", choices=[(val, val) for val in ["discussion", "question"]] + source="thread_type", + choices=[(val, val) for val in ["discussion", "question"]] ) preview_body = serializers.SerializerMethodField() abuse_flagged_count = serializers.SerializerMethodField(required=False) @@ -479,12 +402,8 @@ class ThreadSerializer(_ContentSerializer): non_endorsed_comment_list_url = serializers.SerializerMethodField() read = serializers.BooleanField(required=False) has_endorsed = serializers.BooleanField(source="endorsed", read_only=True) - response_count = serializers.IntegerField( - source="resp_total", read_only=True, required=False - ) - close_reason_code = serializers.CharField( - required=False, validators=[validate_close_reason_code] - ) + response_count = serializers.IntegerField(source="resp_total", read_only=True, required=False) + close_reason_code = serializers.CharField(required=False, validators=[validate_close_reason_code]) close_reason = serializers.SerializerMethodField() closed_by = serializers.SerializerMethodField() closed_by_label = serializers.SerializerMethodField(required=False) @@ -530,8 +449,9 @@ def get_comment_list_url(self, obj, endorsed=None): Returns the URL to retrieve the thread's comments, optionally including the endorsed query parameter. """ - if (obj["thread_type"] == "question" and endorsed is None) or ( - obj["thread_type"] == "discussion" and endorsed is not None + if ( + (obj["thread_type"] == "question" and endorsed is None) or + (obj["thread_type"] == "discussion" and endorsed is not None) ): return None path = reverse("comment-list") @@ -575,17 +495,13 @@ def get_preview_body(self, obj): """ Returns a cleaned version of the thread's body to display in a preview capacity. """ - return ( - strip_tags(self.get_rendered_body(obj)) - .replace("\n", " ") - .replace(" ", " ") - ) + return strip_tags(self.get_rendered_body(obj)).replace('\n', ' ').replace(' ', ' ') def get_close_reason(self, obj): """ Returns the reason for which the thread was closed. """ - is_user_author = str(obj["user_id"]) == str(self.context["request"].user.id) + is_user_author = str(obj['user_id']) == str(self.context['request'].user.id) if not (_validate_privileged_access(self.context) or is_user_author): return None reason_code = obj.get("close_reason_code") @@ -596,7 +512,7 @@ def get_closed_by(self, obj): Returns the username of the moderator who closed this thread, only to other privileged users and author. """ - is_user_author = str(obj["user_id"]) == str(self.context["request"].user.id) + is_user_author = str(obj['user_id']) == str(self.context['request'].user.id) if _validate_privileged_access(self.context) or is_user_author: return obj.get("closed_by") @@ -604,7 +520,7 @@ def get_closed_by_label(self, obj): """ Returns the role label for the user who closed the post. """ - is_user_author = str(obj["user_id"]) == str(self.context["request"].user.id) + is_user_author = str(obj['user_id']) == str(self.context['request'].user.id) if is_user_author or _validate_privileged_access(self.context): return self._get_user_label_from_username(obj.get("closed_by")) @@ -619,31 +535,18 @@ def update(self, instance, validated_data): requesting_user_id = self.context["cc_requester"]["id"] if key == "closed" and val: instance["closing_user_id"] = requesting_user_id - track_thread_lock_unlock_event( - self.context["request"], - self.context["course"], - instance, - validated_data.get("close_reason_code"), - ) + track_thread_lock_unlock_event(self.context['request'], self.context['course'], + instance, validated_data.get('close_reason_code')) if key == "closed" and not val: instance["closing_user_id"] = requesting_user_id - track_thread_lock_unlock_event( - self.context["request"], - self.context["course"], - instance, - validated_data.get("close_reason_code"), - locked=False, - ) + track_thread_lock_unlock_event(self.context['request'], self.context['course'], + instance, validated_data.get('close_reason_code'), locked=False) if key == "body" and val: instance["editing_user_id"] = requesting_user_id - track_thread_edited_event( - self.context["request"], - self.context["course"], - instance, - validated_data.get("edit_reason_code"), - ) + track_thread_edited_event(self.context['request'], self.context['course'], + instance, validated_data.get('edit_reason_code')) instance.save() return instance @@ -656,7 +559,6 @@ class CommentSerializer(_ContentSerializer): not had retrieve() called, because of the interaction between DRF's attempts at introspection and Comment's __getattr__. """ - thread_id = serializers.CharField() parent_id = serializers.CharField(required=False, allow_null=True) endorsed = serializers.BooleanField(required=False) @@ -671,7 +573,7 @@ class CommentSerializer(_ContentSerializer): non_updatable_fields = NON_UPDATABLE_COMMENT_FIELDS def __init__(self, *args, **kwargs): - remove_fields = kwargs.pop("remove_fields", None) + remove_fields = kwargs.pop('remove_fields', None) super().__init__(*args, **kwargs) if remove_fields: @@ -693,8 +595,8 @@ def get_endorsed_by(self, obj): # Avoid revealing the identity of an anonymous non-staff question # author who has endorsed a comment in the thread if not ( - self._is_anonymous(self.context["thread"]) - and not self._is_user_privileged(endorser_id) + self._is_anonymous(self.context["thread"]) and + not self._is_user_privileged(endorser_id) ): return User.objects.get(id=endorser_id).username return None @@ -736,7 +638,7 @@ def to_representation(self, data): # Django Rest Framework v3 no longer includes None values # in the representation. To maintain the previous behavior, # we do this manually instead. - if "parent_id" not in data: + if 'parent_id' not in data: data["parent_id"] = None return data @@ -778,7 +680,7 @@ def create(self, validated_data): comment = Comment( course_id=self.context["thread"]["course_id"], user_id=self.context["cc_requester"]["id"], - **validated_data, + **validated_data ) comment.save() return comment @@ -791,18 +693,12 @@ def update(self, instance, validated_data): # endorsement_user_id on update requesting_user_id = self.context["cc_requester"]["id"] if key == "endorsed": - track_forum_response_mark_event( - self.context["request"], self.context["course"], instance, val - ) + track_forum_response_mark_event(self.context['request'], self.context['course'], instance, val) instance["endorsement_user_id"] = requesting_user_id if key == "body" and val: instance["editing_user_id"] = requesting_user_id - track_comment_edited_event( - self.context["request"], - self.context["course"], - instance, - validated_data.get("edit_reason_code"), - ) + track_comment_edited_event(self.context['request'], self.context['course'], + instance, validated_data.get('edit_reason_code')) instance.save() return instance @@ -812,7 +708,6 @@ class DiscussionTopicSerializer(serializers.Serializer): """ Serializer for DiscussionTopic """ - id = serializers.CharField(read_only=True) # pylint: disable=invalid-name name = serializers.CharField(read_only=True) thread_list_url = serializers.CharField(read_only=True) @@ -842,11 +737,10 @@ class DiscussionTopicSerializerV2(serializers.Serializer): """ Serializer for new style topics. """ - id = serializers.CharField( # pylint: disable=invalid-name read_only=True, source="external_id", - help_text="Provider-specific unique id for the topic", + help_text="Provider-specific unique id for the topic" ) usage_key = serializers.CharField( read_only=True, @@ -870,13 +764,10 @@ def get_thread_counts(self, obj: DiscussionTopicLink) -> Dict[str, int]: """ Get thread counts from provided context """ - return self.context["thread_counts"].get( - obj.external_id, - { - "discussion": 0, - "question": 0, - }, - ) + return self.context['thread_counts'].get(obj.external_id, { + "discussion": 0, + "question": 0, + }) class DiscussionRolesSerializer(serializers.Serializer): @@ -884,7 +775,10 @@ class DiscussionRolesSerializer(serializers.Serializer): Serializer for course discussion roles. """ - ACTION_CHOICES = (("allow", "allow"), ("revoke", "revoke")) + ACTION_CHOICES = ( + ('allow', 'allow'), + ('revoke', 'revoke') + ) action = serializers.ChoiceField(ACTION_CHOICES) user_id = serializers.CharField() @@ -905,16 +799,14 @@ def validate_user_id(self, user_id): self.user = get_user_by_username_or_email(user_id) return user_id except User.DoesNotExist as err: - raise ValidationError( - f"'{user_id}' is not a valid student identifier" - ) from err + raise ValidationError(f"'{user_id}' is not a valid student identifier") from err def validate(self, attrs): """Validate the data at an object level.""" # Store the user object to avoid fetching it again. - if hasattr(self, "user"): - attrs["user"] = self.user + if hasattr(self, 'user'): + attrs['user'] = self.user return attrs def create(self, validated_data): @@ -932,7 +824,6 @@ class DiscussionRolesMemberSerializer(serializers.Serializer): """ Serializer for course discussion roles member data. """ - username = serializers.CharField() email = serializers.EmailField() first_name = serializers.CharField() @@ -941,7 +832,7 @@ class DiscussionRolesMemberSerializer(serializers.Serializer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.course_discussion_settings = self.context["course_discussion_settings"] + self.course_discussion_settings = self.context['course_discussion_settings'] def get_group_name(self, instance): """Return the group name of the user.""" @@ -964,7 +855,6 @@ class DiscussionRolesListSerializer(serializers.Serializer): """ Serializer for course discussion roles member list. """ - course_id = serializers.CharField() results = serializers.SerializerMethodField() division_scheme = serializers.SerializerMethodField() @@ -972,17 +862,15 @@ class DiscussionRolesListSerializer(serializers.Serializer): def get_results(self, obj): """Return the nested serializer data representing a list of member users.""" context = { - "course_id": obj["course_id"], - "course_discussion_settings": self.context["course_discussion_settings"], + 'course_id': obj['course_id'], + 'course_discussion_settings': self.context['course_discussion_settings'] } - serializer = DiscussionRolesMemberSerializer( - obj["users"], context=context, many=True - ) + serializer = DiscussionRolesMemberSerializer(obj['users'], context=context, many=True) return serializer.data def get_division_scheme(self, obj): # pylint: disable=unused-argument """Return the division scheme for the course.""" - return self.context["course_discussion_settings"].division_scheme + return self.context['course_discussion_settings'].division_scheme def create(self, validated_data): """ @@ -999,13 +887,9 @@ class UserStatsSerializer(serializers.Serializer): """ Serializer for course user stats. """ - threads = serializers.IntegerField() replies = serializers.IntegerField() responses = serializers.IntegerField() - deleted_threads = serializers.IntegerField(required=False, default=0) - deleted_replies = serializers.IntegerField(required=False, default=0) - deleted_responses = serializers.IntegerField(required=False, default=0) active_flags = serializers.IntegerField() inactive_flags = serializers.IntegerField() username = serializers.CharField() @@ -1023,36 +907,27 @@ class BlackoutDateSerializer(serializers.Serializer): """ Serializer for blackout dates. """ - - start = serializers.DateTimeField( - help_text="The ISO 8601 timestamp for the start of the blackout period" - ) - end = serializers.DateTimeField( - help_text="The ISO 8601 timestamp for the end of the blackout period" - ) + start = serializers.DateTimeField(help_text="The ISO 8601 timestamp for the start of the blackout period") + end = serializers.DateTimeField(help_text="The ISO 8601 timestamp for the end of the blackout period") class ReasonCodeSeralizer(serializers.Serializer): """ Serializer for reason codes. """ - code = serializers.CharField(help_text="A code for the an edit or close reason") - label = serializers.CharField( - help_text="A user-friendly name text for the close or edit reason" - ) + label = serializers.CharField(help_text="A user-friendly name text for the close or edit reason") class CourseMetadataSerailizer(serializers.Serializer): """ Serializer for course metadata. """ - id = CourseKeyField(help_text="The identifier of the course") blackouts = serializers.ListField( child=BlackoutDateSerializer(), help_text="A list of objects representing blackout periods " - "(during which discussions are read-only except for privileged users).", + "(during which discussions are read-only except for privileged users)." ) thread_list_url = serializers.URLField( help_text="The URL of the list of all threads in the course.", @@ -1060,9 +935,7 @@ class CourseMetadataSerailizer(serializers.Serializer): following_thread_list_url = serializers.URLField( help_text="thread_list_url with parameter following=True", ) - topics_url = serializers.URLField( - help_text="The URL of the topic listing for the course." - ) + topics_url = serializers.URLField(help_text="The URL of the topic listing for the course.") allow_anonymous = serializers.BooleanField( help_text="A boolean indicating whether anonymous posts are allowed or not.", ) diff --git a/lms/djangoapps/discussion/rest_api/tasks.py b/lms/djangoapps/discussion/rest_api/tasks.py index 5773fbbc83b0..cd725a3513dc 100644 --- a/lms/djangoapps/discussion/rest_api/tasks.py +++ b/lms/djangoapps/discussion/rest_api/tasks.py @@ -1,36 +1,32 @@ """ Contain celery tasks """ - import logging from celery import shared_task from django.contrib.auth import get_user_model from edx_django_utils.monitoring import set_code_owner_attribute -from eventtracking import tracker from opaque_keys.edx.locator import CourseKey +from eventtracking import tracker -from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole +from common.djangoapps.student.roles import CourseStaffRole, CourseInstructorRole from common.djangoapps.track import segment from lms.djangoapps.courseware.courses import get_course_with_access from lms.djangoapps.discussion.django_comment_client.utils import get_user_role_names -from lms.djangoapps.discussion.rest_api.discussions_notifications import ( - DiscussionNotificationSender, -) +from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender from lms.djangoapps.discussion.rest_api.utils import can_user_notify_all_learners from openedx.core.djangoapps.django_comment_common.comment_client import Comment from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS + User = get_user_model() log = logging.getLogger(__name__) @shared_task @set_code_owner_attribute -def send_thread_created_notification( - thread_id, course_key_str, user_id, notify_all_learners=False -): +def send_thread_created_notification(thread_id, course_key_str, user_id, notify_all_learners=False): """ Send notification when a new thread is created """ @@ -44,21 +40,17 @@ def send_thread_created_notification( is_course_staff = CourseStaffRole(course_key).has_user(user) is_course_admin = CourseInstructorRole(course_key).has_user(user) user_roles = get_user_role_names(user, course_key) - if not can_user_notify_all_learners( - user_roles, is_course_staff, is_course_admin - ): + if not can_user_notify_all_learners(user_roles, is_course_staff, is_course_admin): return - course = get_course_with_access(user, "load", course_key, check_if_enrolled=True) + course = get_course_with_access(user, 'load', course_key, check_if_enrolled=True) notification_sender = DiscussionNotificationSender(thread, course, user) notification_sender.send_new_thread_created_notification(notify_all_learners) @shared_task @set_code_owner_attribute -def send_response_notifications( - thread_id, course_key_str, user_id, comment_id, parent_id=None -): +def send_response_notifications(thread_id, course_key_str, user_id, comment_id, parent_id=None): """ Send notifications to users who are subscribed to the thread. """ @@ -67,10 +59,8 @@ def send_response_notifications( return thread = Thread(id=thread_id).retrieve() user = User.objects.get(id=user_id) - course = get_course_with_access(user, "load", course_key, check_if_enrolled=True) - notification_sender = DiscussionNotificationSender( - thread, course, user, parent_id, comment_id - ) + course = get_course_with_access(user, 'load', course_key, check_if_enrolled=True) + notification_sender = DiscussionNotificationSender(thread, course, user, parent_id, comment_id) notification_sender.send_new_comment_notification() notification_sender.send_new_response_notification() notification_sender.send_new_comment_on_response_notification() @@ -79,9 +69,7 @@ def send_response_notifications( @shared_task @set_code_owner_attribute -def send_response_endorsed_notifications( - thread_id, response_id, course_key_str, endorsed_by -): +def send_response_endorsed_notifications(thread_id, response_id, course_key_str, endorsed_by): """ Send notifications when a response is marked answered/ endorsed """ @@ -92,10 +80,8 @@ def send_response_endorsed_notifications( response = Comment(id=response_id).retrieve() creator = User.objects.get(id=response.user_id) endorser = User.objects.get(id=endorsed_by) - course = get_course_with_access(creator, "load", course_key, check_if_enrolled=True) - notification_sender = DiscussionNotificationSender( - thread, course, creator, comment_id=response_id - ) + course = get_course_with_access(creator, 'load', course_key, check_if_enrolled=True) + notification_sender = DiscussionNotificationSender(thread, course, creator, comment_id=response_id) # skip sending notification to author of thread if they are the same as the author of the response if response.user_id != thread.user_id: # sends notification to author of thread @@ -113,63 +99,15 @@ def delete_course_post_for_user(user_id, username, course_ids, event_data=None): Deletes all posts for user in a course. """ event_data = event_data or {} - log.info( - f"<> Deleting all posts for {username} in course {course_ids}" - ) - # Get triggered_by user_id from event_data for audit trail - deleted_by_user_id = event_data.get("triggered_by_user_id") if event_data else None - threads_deleted = Thread.delete_user_threads( - user_id, course_ids, deleted_by=deleted_by_user_id - ) - comments_deleted = Comment.delete_user_comments( - user_id, course_ids, deleted_by=deleted_by_user_id - ) - log.info( - f"<> Deleted {threads_deleted} posts and {comments_deleted} comments for {username} " - f"in course {course_ids}" - ) - event_data.update( - { - "number_of_posts_deleted": threads_deleted, - "number_of_comments_deleted": comments_deleted, - } - ) - event_name = "edx.discussion.bulk_delete_user_posts" - tracker.emit(event_name, event_data) - segment.track("None", event_name, event_data) - - -@shared_task -@set_code_owner_attribute -def restore_course_post_for_user(user_id, username, course_ids, event_data=None): - """ - Restores all soft-deleted posts for user in a course by setting is_deleted=False. - """ - event_data = event_data or {} - log.info( - "<> Restoring all posts for %s in course %s", username, course_ids - ) - # Get triggered_by user_id from event_data for audit trail - restored_by_user_id = event_data.get("triggered_by_user_id") if event_data else None - threads_restored = Thread.restore_user_deleted_threads( - user_id, course_ids, restored_by=restored_by_user_id - ) - comments_restored = Comment.restore_user_deleted_comments( - user_id, course_ids, restored_by=restored_by_user_id - ) - log.info( - "<> Restored %s posts and %s comments for %s in course %s", - threads_restored, - comments_restored, - username, - course_ids, - ) - event_data.update( - { - "number_of_posts_restored": threads_restored, - "number_of_comments_restored": comments_restored, - } - ) - event_name = "edx.discussion.bulk_restore_user_posts" + log.info(f"<> Deleting all posts for {username} in course {course_ids}") + threads_deleted = Thread.delete_user_threads(user_id, course_ids) + comments_deleted = Comment.delete_user_comments(user_id, course_ids) + log.info(f"<> Deleted {threads_deleted} posts and {comments_deleted} comments for {username} " + f"in course {course_ids}") + event_data.update({ + "number_of_posts_deleted": threads_deleted, + "number_of_comments_deleted": comments_deleted, + }) + event_name = 'edx.discussion.bulk_delete_user_posts' tracker.emit(event_name, event_data) - segment.track("None", event_name, event_data) + segment.track('None', event_name, event_data) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py index 2fa761b46615..53c12454aec9 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py @@ -10,20 +10,34 @@ import random from datetime import datetime, timedelta from unittest import mock +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse import ddt import httpretty import pytest +from django.test import override_settings from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.test.client import RequestFactory +from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import CourseLocator from pytz import UTC from rest_framework.exceptions import PermissionDenied +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, + SharedModuleStoreTestCase, +) +from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory +from xmodule.partitions.partitions import Group, UserPartition + from common.djangoapps.student.tests.factories import ( AdminFactory, + BetaTesterFactory, CourseEnrollmentFactory, + StaffFactory, UserFactory, ) from common.djangoapps.util.testing import UrlResetMixin @@ -31,6 +45,10 @@ from lms.djangoapps.discussion.django_comment_client.tests.utils import ( ForumsEnableMixin, ) +from lms.djangoapps.discussion.tests.utils import ( + make_minimal_cs_comment, + make_minimal_cs_thread, +) from lms.djangoapps.discussion.rest_api import api from lms.djangoapps.discussion.rest_api.api import ( create_comment, @@ -38,9 +56,12 @@ delete_comment, delete_thread, get_comment_list, + get_course, + get_course_topics, get_course_topics_v2, get_thread, get_thread_list, + get_user_comments, update_comment, update_thread, ) @@ -52,19 +73,18 @@ ) from lms.djangoapps.discussion.rest_api.serializers import TopicOrdering from lms.djangoapps.discussion.rest_api.tests.utils import ( + CommentsServiceMockMixin, ForumMockUtilsMixin, make_paginated_api_response, + parsed_body, ) -from lms.djangoapps.discussion.tests.utils import ( - make_minimal_cs_comment, - make_minimal_cs_thread, -) +from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory from openedx.core.djangoapps.discussions.models import ( DiscussionsConfiguration, DiscussionTopicLink, - PostingRestriction, Provider, + PostingRestriction, ) from openedx.core.djangoapps.discussions.tasks import ( update_discussions_settings_from_course_task, @@ -78,13 +98,6 @@ Role, ) from openedx.core.lib.exceptions import CourseNotFoundError, PageNotFoundError -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import ( - ModuleStoreTestCase, - SharedModuleStoreTestCase, -) -from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory User = get_user_model() @@ -261,11 +274,7 @@ def test_basic(self, mock_emit): ) self.register_post_thread_response(cs_thread) with self.assert_signal_sent( - api, - "thread_created", - sender=None, - user=self.user, - exclude_args=("post", "notify_all_learners"), + api, "thread_created", sender=None, user=self.user, exclude_args=("post", "notify_all_learners") ): actual = create_thread(self.request, self.minimal_data) expected = self.expected_thread_data( @@ -344,11 +353,7 @@ def test_basic_in_blackout_period_with_user_access(self, mock_emit): ) with self.assert_signal_sent( - api, - "thread_created", - sender=None, - user=self.user, - exclude_args=("post", "notify_all_learners"), + api, "thread_created", sender=None, user=self.user, exclude_args=("post", "notify_all_learners") ): actual = create_thread(self.request, self.minimal_data) expected = self.expected_thread_data( @@ -374,7 +379,6 @@ def test_basic_in_blackout_period_with_user_access(self, mock_emit): "type", "voted", ], - "is_deleted": False, } ) assert actual == expected @@ -426,11 +430,7 @@ def test_title_truncation(self, mock_emit): ) self.register_post_thread_response(cs_thread) with self.assert_signal_sent( - api, - "thread_created", - sender=None, - user=self.user, - exclude_args=("post", "notify_all_learners"), + api, "thread_created", sender=None, user=self.user, exclude_args=("post", "notify_all_learners") ): create_thread(self.request, data) event_name, event_data = mock_emit.call_args[0] @@ -718,10 +718,6 @@ def test_success(self, parent_id, mock_emit): "image_url_medium": "http://testserver/static/default_50.png", "image_url_small": "http://testserver/static/default_30.png", }, - "is_deleted": None, - "deleted_at": None, - "deleted_by": None, - "deleted_by_label": None, } assert actual == expected @@ -830,10 +826,6 @@ def test_success_in_black_out_with_user_access(self, parent_id, mock_emit): "image_url_medium": "http://testserver/static/default_50.png", "image_url_small": "http://testserver/static/default_30.png", }, - "is_deleted": False, - "deleted_at": None, - "deleted_by": None, - "deleted_by_label": None, } assert actual == expected @@ -922,9 +914,7 @@ def test_endorsed(self, role_name, is_thread_author, thread_type): ) try: create_comment(self.request, data) - last_commemt_params = self.get_mock_func_calls("create_parent_comment")[-1][ - 1 - ] + last_commemt_params = self.get_mock_func_calls("create_parent_comment")[-1][1] assert last_commemt_params["endorsed"] assert not expected_error except ValidationError: @@ -1838,10 +1828,6 @@ def test_basic(self, parent_id): "image_url_medium": "http://testserver/static/default_50.png", "image_url_small": "http://testserver/static/default_30.png", }, - "is_deleted": None, - "deleted_at": None, - "deleted_by": None, - "deleted_by_label": None, } assert actual == expected params = { @@ -1902,7 +1888,7 @@ def test_abuse_flagged(self, old_flagged, new_flagged, mock_emit): else "edx.forum.response.unreported" ) expected_event_data = { - "discussion": {"id": "test_thread"}, + "discussion": {'id': 'test_thread'}, "body": "Original body", "id": "test_comment", "content_type": "Response", @@ -1965,7 +1951,7 @@ def test_comment_un_abuse_flag_for_moderator_role( "body": "Original body", "id": "test_comment", "content_type": "Response", - "discussion": {"id": "test_thread"}, + "discussion": {'id': 'test_thread'}, "commentable_id": "dummy", "truncated": False, "url": "", @@ -2384,7 +2370,6 @@ def test_basic(self, mock_emit): params = { "thread_id": self.thread_id, "course_id": str(self.course.id), - "deleted_by": str(self.user.id), } self.check_mock_called_with("delete_thread", -1, **params) @@ -2572,7 +2557,6 @@ def test_basic(self, mock_emit): params = { "comment_id": self.comment_id, "course_id": str(self.course.id), - "deleted_by": str(self.user.id), } self.check_mock_called_with("delete_comment", -1, **params) @@ -2937,7 +2921,6 @@ def test_get_threads_by_topic_id(self): "page": 1, "per_page": 1, "commentable_ids": ["topic_x", "topic_meow"], - "show_deleted": False, } self.check_mock_called_with( "get_user_threads", @@ -2953,7 +2936,6 @@ def test_basic_query_params(self): "sort_key": "activity", "page": 6, "per_page": 14, - "show_deleted": False, } self.check_mock_called_with( "get_user_threads", @@ -3094,10 +3076,10 @@ def test_request_group(self, role_name, course_is_cohorted): self.get_thread_list([], course=cohort_course) thread_func_params = self.get_mock_func_calls("get_user_threads")[-1][1] actual_has_group = "group_id" in thread_func_params - expected_has_group = course_is_cohorted and role_name in ( - FORUM_ROLE_STUDENT, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_GROUP_MODERATOR, + expected_has_group = ( + course_is_cohorted and role_name in ( + FORUM_ROLE_STUDENT, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_GROUP_MODERATOR + ) ) assert actual_has_group == expected_has_group @@ -3162,7 +3144,6 @@ def test_text_search(self, text_search_rewrite): "page": 1, "per_page": 10, "text": "test search string", - "show_deleted": False, } self.check_mock_called_with( "search_threads", @@ -3189,7 +3170,6 @@ def test_filter_threads_by_author(self): "page": 1, "per_page": 10, "author_id": str(self.user.id), - "show_deleted": False, } self.check_mock_called_with( "get_user_threads", @@ -3236,7 +3216,6 @@ def test_thread_type(self, thread_type): "page": 1, "per_page": 10, "thread_type": thread_type, - "show_deleted": False, } if thread_type is None: @@ -3274,7 +3253,6 @@ def test_flagged(self, flagged_boolean): "page": 1, "per_page": 10, "flagged": flagged_boolean, - "show_deleted": False, } if flagged_boolean is None: @@ -3315,7 +3293,6 @@ def test_flagged_count(self, role): "count_flagged": True, "page": 1, "per_page": 10, - "show_deleted": False, } self.check_mock_called_with( @@ -3364,7 +3341,6 @@ def test_following(self): "sort_key": "activity", "page": 1, "per_page": 11, - "show_deleted": False, } self.check_mock_called_with("get_user_subscriptions", -1, **params) @@ -3392,7 +3368,6 @@ def test_view_query(self, query): "page": 1, "per_page": 11, query: True, - "show_deleted": False, } self.check_mock_called_with( "get_user_threads", @@ -3434,7 +3409,6 @@ def test_order_by_query(self, http_query, cc_query): "sort_key": cc_query, "page": 1, "per_page": 11, - "show_deleted": False, } self.check_mock_called_with( "get_user_threads", @@ -3467,7 +3441,6 @@ def test_order_direction(self): "sort_key": "activity", "page": 1, "per_page": 11, - "show_deleted": False, } self.check_mock_called_with( "get_user_threads", @@ -3796,10 +3769,6 @@ def get_source_and_expected_comments(self): "image_url_medium": "http://testserver/static/default_50.png", "image_url_small": "http://testserver/static/default_30.png", }, - "is_deleted": None, - "deleted_at": None, - "deleted_by": None, - "deleted_by_label": None, }, { "id": "test_comment_2", @@ -3835,10 +3804,6 @@ def get_source_and_expected_comments(self): "image_url_medium": "http://testserver/static/default_50.png", "image_url_small": "http://testserver/static/default_30.png", }, - "is_deleted": None, - "deleted_at": None, - "deleted_by": None, - "deleted_by_label": None, }, ] return source_comments, expected_comments diff --git a/lms/djangoapps/discussion/rest_api/tests/test_forms.py b/lms/djangoapps/discussion/rest_api/tests/test_forms.py index 33359337933b..3be65964b6b9 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_forms.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_forms.py @@ -2,6 +2,7 @@ Tests for Discussion API forms """ + import itertools from unittest import TestCase from urllib.parse import urlencode @@ -11,9 +12,9 @@ from opaque_keys.edx.locator import CourseLocator from lms.djangoapps.discussion.rest_api.forms import ( + UserCommentListGetForm, CommentListGetForm, ThreadListGetForm, - UserCommentListGetForm, ) from openedx.core.djangoapps.util.test_forms import FormTestMixin @@ -35,9 +36,7 @@ def test_missing_page_size(self): def test_zero_page_size(self): self.form_data["page_size"] = "0" - self.assert_error( - "page_size", "Ensure this value is greater than or equal to 1." - ) + self.assert_error("page_size", "Ensure this value is greater than or equal to 1.") def test_excessive_page_size(self): self.form_data["page_size"] = "101" @@ -47,7 +46,6 @@ def test_excessive_page_size(self): @ddt.ddt class ThreadListGetFormTest(FormTestMixin, PaginationTestMixin, TestCase): """Tests for ThreadListGetForm""" - FORM_CLASS = ThreadListGetForm def setUp(self): @@ -60,41 +58,37 @@ def setUp(self): "page_size": "13", } ), - mutable=True, + mutable=True ) def test_basic(self): form = self.get_form(expected_valid=True) assert form.cleaned_data == { - "course_id": CourseLocator.from_string("Foo/Bar/Baz"), - "page": 2, - "page_size": 13, - "count_flagged": None, - "topic_id": set(), - "text_search": "", - "following": None, - "author": "", - "thread_type": "", - "flagged": None, - "show_deleted": None, - "view": "", - "order_by": "last_activity_at", - "order_direction": "desc", - "requested_fields": set(), + 'course_id': CourseLocator.from_string('Foo/Bar/Baz'), + 'page': 2, + 'page_size': 13, + 'count_flagged': None, + 'topic_id': set(), + 'text_search': '', + 'following': None, + 'author': '', + 'thread_type': '', + 'flagged': None, + 'view': '', + 'order_by': 'last_activity_at', + 'order_direction': 'desc', + 'requested_fields': set() } def test_topic_id(self): self.form_data.setlist("topic_id", ["example topic_id", "example 2nd topic_id"]) form = self.get_form(expected_valid=True) - assert form.cleaned_data["topic_id"] == { - "example topic_id", - "example 2nd topic_id", - } + assert form.cleaned_data['topic_id'] == {'example topic_id', 'example 2nd topic_id'} def test_text_search(self): self.form_data["text_search"] = "test search string" form = self.get_form(expected_valid=True) - assert form.cleaned_data["text_search"] == "test search string" + assert form.cleaned_data['text_search'] == 'test search string' def test_missing_course_id(self): self.form_data.pop("course_id") @@ -115,10 +109,7 @@ def test_thread_type(self, value): def test_thread_type_invalid(self): self.form_data["thread_type"] = "invalid-option" - self.assert_error( - "thread_type", - "Select a valid choice. invalid-option is not one of the available choices.", - ) + self.assert_error("thread_type", "Select a valid choice. invalid-option is not one of the available choices.") @ddt.data("True", "true", 1, True) def test_flagged_true(self, value): @@ -142,9 +133,7 @@ def test_following_true(self, value): @ddt.data("False", "false", 0, False) def test_following_false(self, value): self.form_data["following"] = value - self.assert_error( - "following", "The value of the 'following' parameter must be true." - ) + self.assert_error("following", "The value of the 'following' parameter must be true.") def test_invalid_following(self): self.form_data["following"] = "invalid-boolean" @@ -155,28 +144,25 @@ def test_mutually_exclusive(self, params): self.form_data.update({param: "True" for param in params}) self.assert_error( "__all__", - "The following query parameters are mutually exclusive: topic_id, text_search, following", + "The following query parameters are mutually exclusive: topic_id, text_search, following" ) def test_invalid_view_choice(self): self.form_data["view"] = "not_a_valid_choice" - self.assert_error( - "view", - "Select a valid choice. not_a_valid_choice is not one of the available choices.", - ) + self.assert_error("view", "Select a valid choice. not_a_valid_choice is not one of the available choices.") def test_invalid_sort_by_choice(self): self.form_data["order_by"] = "not_a_valid_choice" self.assert_error( "order_by", - "Select a valid choice. not_a_valid_choice is not one of the available choices.", + "Select a valid choice. not_a_valid_choice is not one of the available choices." ) def test_invalid_sort_direction_choice(self): self.form_data["order_direction"] = "not_a_valid_choice" self.assert_error( "order_direction", - "Select a valid choice. not_a_valid_choice is not one of the available choices.", + "Select a valid choice. not_a_valid_choice is not one of the available choices." ) @ddt.data( @@ -195,13 +181,12 @@ def test_valid_choice_fields(self, field, value): def test_requested_fields(self): self.form_data["requested_fields"] = "profile_image" form = self.get_form(expected_valid=True) - assert form.cleaned_data["requested_fields"] == {"profile_image"} + assert form.cleaned_data['requested_fields'] == {'profile_image'} @ddt.ddt class CommentListGetFormTest(FormTestMixin, PaginationTestMixin, TestCase): """Tests for CommentListGetForm""" - FORM_CLASS = CommentListGetForm def setUp(self): @@ -217,14 +202,13 @@ def setUp(self): def test_basic(self): form = self.get_form(expected_valid=True) assert form.cleaned_data == { - "thread_id": "deadbeef", - "endorsed": False, - "page": 2, - "page_size": 13, - "flagged": False, - "requested_fields": set(), - "merge_question_type_responses": False, - "show_deleted": None, + 'thread_id': 'deadbeef', + 'endorsed': False, + 'page': 2, + 'page_size': 13, + 'flagged': False, + 'requested_fields': set(), + 'merge_question_type_responses': False } def test_missing_thread_id(self): @@ -252,13 +236,12 @@ def test_invalid_endorsed(self): def test_requested_fields(self): self.form_data["requested_fields"] = {"profile_image"} form = self.get_form(expected_valid=True) - assert form.cleaned_data["requested_fields"] == {"profile_image"} + assert form.cleaned_data['requested_fields'] == {'profile_image'} @ddt.ddt class UserCommentListGetFormTest(FormTestMixin, PaginationTestMixin, TestCase): """Tests for UserCommentListGetForm""" - FORM_CLASS = UserCommentListGetForm def setUp(self): @@ -273,11 +256,11 @@ def setUp(self): def test_basic(self): form = self.get_form(expected_valid=True) assert form.cleaned_data == { - "course_id": CourseLocator.from_string("a/b/c"), - "flagged": False, - "page": 2, - "page_size": 13, - "requested_fields": set(), + 'course_id': CourseLocator.from_string('a/b/c'), + 'flagged': False, + 'page': 2, + 'page_size': 13, + 'requested_fields': set() } def test_missing_flagged(self): @@ -297,7 +280,7 @@ def test_flagged_true(self, value): def test_requested_fields(self): self.form_data["requested_fields"] = {"profile_image"} form = self.get_form(expected_valid=True) - assert form.cleaned_data["requested_fields"] == {"profile_image"} + assert form.cleaned_data['requested_fields'] == {'profile_image'} def test_missing_course_id(self): self.form_data.pop("course_id") diff --git a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py index 10f9b7a64248..a1443252a1ce 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py @@ -9,17 +9,19 @@ import httpretty from django.test.client import RequestFactory from django.test.utils import override_settings +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.util.testing import UrlResetMixin -from lms.djangoapps.discussion.django_comment_client.tests.utils import ( - ForumsEnableMixin, -) +from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin from lms.djangoapps.discussion.rest_api.serializers import ( CommentSerializer, ThreadSerializer, filter_spam_urls_from_html, - get_context, + get_context ) from lms.djangoapps.discussion.rest_api.tests.utils import ( CommentsServiceMockMixin, @@ -37,10 +39,6 @@ FORUM_ROLE_STUDENT, Role, ) -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory @ddt.ddt @@ -48,18 +46,13 @@ class SerializerTestMixin(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetM """ Test Mixin for Serializer tests """ - @classmethod - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUpClass(cls): super().setUpClass() cls.course = CourseFactory.create() - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() httpretty.reset() @@ -67,8 +60,8 @@ def setUp(self): self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -96,9 +89,7 @@ def create_role(self, role_name, users, course=None): (FORUM_ROLE_STUDENT, False, True, True), ) @ddt.unpack - def test_anonymity( - self, role_name, anonymous, anonymous_to_peers, expected_serialized_anonymous - ): + def test_anonymity(self, role_name, anonymous, anonymous_to_peers, expected_serialized_anonymous): """ Test that content is properly made anonymous. @@ -116,9 +107,7 @@ def test_anonymity( """ self.create_role(role_name, [self.user]) serialized = self.serialize( - self.make_cs_content( - {"anonymous": anonymous, "anonymous_to_peers": anonymous_to_peers} - ) + self.make_cs_content({"anonymous": anonymous, "anonymous_to_peers": anonymous_to_peers}) ) actual_serialized_anonymous = serialized["author"] is None assert actual_serialized_anonymous == expected_serialized_anonymous @@ -149,19 +138,17 @@ def test_author_labels(self, role_name, anonymous, expected_label): """ self.create_role(role_name, [self.author]) serialized = self.serialize(self.make_cs_content({"anonymous": anonymous})) - assert serialized["author_label"] == expected_label + assert serialized['author_label'] == expected_label def test_abuse_flagged(self): - serialized = self.serialize( - self.make_cs_content({"abuse_flaggers": [str(self.user.id)]}) - ) - assert serialized["abuse_flagged"] is True + serialized = self.serialize(self.make_cs_content({"abuse_flaggers": [str(self.user.id)]})) + assert serialized['abuse_flagged'] is True def test_voted(self): thread_id = "test_thread" self.register_get_user_response(self.user, upvoted_ids=[thread_id]) serialized = self.serialize(self.make_cs_content({"id": thread_id})) - assert serialized["voted"] is True + assert serialized['voted'] is True @ddt.ddt @@ -188,61 +175,47 @@ def serialize(self, thread): Create a serializer with an appropriate context and use it to serialize the given thread, returning the result. """ - return ThreadSerializer( - thread, context=get_context(self.course, self.request) - ).data + return ThreadSerializer(thread, context=get_context(self.course, self.request)).data def test_basic(self): - thread = make_minimal_cs_thread( - { - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(self.author.id), - "username": self.author.username, - "title": "Test Title", - "body": "Test body", - "pinned": True, - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - } - ) - expected = self.expected_thread_data( - { - "author": self.author.username, - "can_delete": False, - "vote_count": 4, - "comment_count": 6, - "unread_comment_count": 3, - "pinned": True, - "editable_fields": [ - "abuse_flagged", - "copy_link", - "following", - "read", - "voted", - ], - "abuse_flagged_count": None, - "edit_by_label": None, - "closed_by_label": None, - } - ) + thread = make_minimal_cs_thread({ + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.author.id), + "username": self.author.username, + "title": "Test Title", + "body": "Test body", + "pinned": True, + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + }) + expected = self.expected_thread_data({ + "author": self.author.username, + "can_delete": False, + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "pinned": True, + "editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"], + "abuse_flagged_count": None, + "edit_by_label": None, + "closed_by_label": None, + }) assert self.serialize(thread) == expected thread["thread_type"] = "question" - expected.update( - { - "type": "question", - "comment_list_url": None, - "endorsed_comment_list_url": ( - "http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=True" - ), - "non_endorsed_comment_list_url": ( - "http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=False" - ), - } - ) + expected.update({ + "type": "question", + "comment_list_url": None, + "endorsed_comment_list_url": ( + "http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=True" + ), + "non_endorsed_comment_list_url": ( + "http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=False" + ), + }) assert self.serialize(thread) == expected def test_pinned_missing(self): @@ -254,34 +227,34 @@ def test_pinned_missing(self): del thread_data["pinned"] self.register_get_thread_response(thread_data) serialized = self.serialize(thread_data) - assert serialized["pinned"] is False + assert serialized['pinned'] is False def test_group(self): self.course.cohort_config = {"cohorted": True} modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) cohort = CohortFactory.create(course_id=self.course.id) serialized = self.serialize(self.make_cs_content({"group_id": cohort.id})) - assert serialized["group_id"] == cohort.id - assert serialized["group_name"] == cohort.name + assert serialized['group_id'] == cohort.id + assert serialized['group_name'] == cohort.name def test_following(self): thread_id = "test_thread" self.register_get_user_response(self.user, subscribed_thread_ids=[thread_id]) serialized = self.serialize(self.make_cs_content({"id": thread_id})) - assert serialized["following"] is True + assert serialized['following'] is True def test_response_count(self): thread_data = self.make_cs_content({"resp_total": 2}) self.register_get_thread_response(thread_data) serialized = self.serialize(thread_data) - assert serialized["response_count"] == 2 + assert serialized['response_count'] == 2 def test_response_count_missing(self): thread_data = self.make_cs_content({}) del thread_data["resp_total"] self.register_get_thread_response(thread_data) serialized = self.serialize(thread_data) - assert "response_count" not in serialized + assert 'response_count' not in serialized @ddt.data( (FORUM_ROLE_MODERATOR, True), @@ -299,62 +272,43 @@ def test_closed_by_label_field(self, role, visible): self.create_role(FORUM_ROLE_MODERATOR, [moderator]) self.create_role(request_role, [self.user]) - thread = make_minimal_cs_thread( - { - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(author.id), - "username": author.username, - "title": "Test Title", - "body": "Test body", - "pinned": True, - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - "closed_by": moderator, - } - ) + thread = make_minimal_cs_thread({ + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(author.id), + "username": author.username, + "title": "Test Title", + "body": "Test body", + "pinned": True, + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + "closed_by": moderator + }) closed_by_label = "Moderator" if visible else None closed_by = moderator if visible else None can_delete = role != FORUM_ROLE_STUDENT editable_fields = ["abuse_flagged", "copy_link", "following", "read", "voted"] if role == "author": editable_fields.remove("voted") - editable_fields.extend( - ["anonymous", "raw_body", "title", "topic_id", "type"] - ) + editable_fields.extend(['anonymous', 'raw_body', 'title', 'topic_id', 'type']) elif role == FORUM_ROLE_MODERATOR: - editable_fields.extend( - [ - "close_reason_code", - "closed", - "edit_reason_code", - "pinned", - "raw_body", - "title", - "topic_id", - "type", - ] - ) - # is_deleted is visible (False) for privileged users, hidden (None) for others - is_deleted = False if role == FORUM_ROLE_MODERATOR else None - expected = self.expected_thread_data( - { - "author": author.username, - "can_delete": can_delete, - "vote_count": 4, - "comment_count": 6, - "unread_comment_count": 3, - "pinned": True, - "editable_fields": sorted(editable_fields), - "abuse_flagged_count": None, - "edit_by_label": None, - "closed_by_label": closed_by_label, - "closed_by": closed_by, - "is_deleted": is_deleted, - } - ) + editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned', + 'raw_body', 'title', 'topic_id', 'type']) + expected = self.expected_thread_data({ + "author": author.username, + "can_delete": can_delete, + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "pinned": True, + "editable_fields": sorted(editable_fields), + "abuse_flagged_count": None, + "edit_by_label": None, + "closed_by_label": closed_by_label, + "closed_by": closed_by, + }) assert self.serialize(thread) == expected @ddt.data( @@ -373,69 +327,48 @@ def test_edit_by_label_field(self, role, visible): self.create_role(FORUM_ROLE_MODERATOR, [moderator]) self.create_role(request_role, [self.user]) - thread = make_minimal_cs_thread( - { - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(author.id), - "username": author.username, - "title": "Test Title", - "body": "Test body", - "pinned": True, - "votes": {"up_count": 4}, - "edit_history": [{"editor_username": moderator}], - "comments_count": 5, - "unread_comments_count": 3, - "closed_by": None, - } - ) + thread = make_minimal_cs_thread({ + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(author.id), + "username": author.username, + "title": "Test Title", + "body": "Test body", + "pinned": True, + "votes": {"up_count": 4}, + "edit_history": [{"editor_username": moderator}], + "comments_count": 5, + "unread_comments_count": 3, + "closed_by": None + }) edit_by_label = "Moderator" if visible else None can_delete = role != FORUM_ROLE_STUDENT - last_edit = ( - None if role == FORUM_ROLE_STUDENT else {"editor_username": moderator} - ) + last_edit = None if role == FORUM_ROLE_STUDENT else {"editor_username": moderator} editable_fields = ["abuse_flagged", "copy_link", "following", "read", "voted"] if role == "author": editable_fields.remove("voted") - editable_fields.extend( - ["anonymous", "raw_body", "title", "topic_id", "type"] - ) + editable_fields.extend(['anonymous', 'raw_body', 'title', 'topic_id', 'type']) elif role == FORUM_ROLE_MODERATOR: - editable_fields.extend( - [ - "close_reason_code", - "closed", - "edit_reason_code", - "pinned", - "raw_body", - "title", - "topic_id", - "type", - ] - ) + editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned', + 'raw_body', 'title', 'topic_id', 'type']) - # is_deleted is visible (False) for privileged users, hidden (None) for others - is_deleted = False if role == FORUM_ROLE_MODERATOR else None - expected = self.expected_thread_data( - { - "author": author.username, - "can_delete": can_delete, - "vote_count": 4, - "comment_count": 6, - "unread_comment_count": 3, - "pinned": True, - "editable_fields": sorted(editable_fields), - "abuse_flagged_count": None, - "last_edit": last_edit, - "edit_by_label": edit_by_label, - "closed_by_label": None, - "closed_by": None, - "is_deleted": is_deleted, - } - ) + expected = self.expected_thread_data({ + "author": author.username, + "can_delete": can_delete, + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "pinned": True, + "editable_fields": sorted(editable_fields), + "abuse_flagged_count": None, + "last_edit": last_edit, + "edit_by_label": edit_by_label, + "closed_by_label": None, + "closed_by": None, + }) assert self.serialize(thread) == expected def test_get_preview_body(self): @@ -451,10 +384,7 @@ def test_get_preview_body(self): {"body": "

This is a test thread body with some text.

"} ) serialized = self.serialize(thread_data) - assert ( - serialized["preview_body"] - == "This is a test thread body with some text." - ) + assert serialized['preview_body'] == "This is a test thread body with some text." @ddt.ddt @@ -472,12 +402,12 @@ def make_cs_content(self, overrides=None, with_endorsement=False): """ merged_overrides = { "user_id": str(self.author.id), - "username": self.author.username, + "username": self.author.username } if with_endorsement: merged_overrides["endorsement"] = { "user_id": str(self.endorser.id), - "time": self.endorsed_at, + "time": self.endorsed_at } merged_overrides.update(overrides or {}) return make_minimal_cs_comment(merged_overrides) @@ -487,9 +417,7 @@ def serialize(self, comment, thread_data=None): Create a serializer with an appropriate context and use it to serialize the given comment, returning the result. """ - context = get_context( - self.course, self.request, make_minimal_cs_thread(thread_data) - ) + context = get_context(self.course, self.request, make_minimal_cs_thread(thread_data)) return CommentSerializer(comment, context=context).data def test_basic(self): @@ -544,10 +472,6 @@ def test_basic(self): "image_url_medium": "http://testserver/static/default_50.png", "image_url_small": "http://testserver/static/default_30.png", }, - "is_deleted": None, - "deleted_at": None, - "deleted_by": None, - "deleted_by_label": None, } assert self.serialize(comment) == expected @@ -560,7 +484,7 @@ def test_basic(self): FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_STUDENT, ], - [True, False], + [True, False] ) ) @ddt.unpack @@ -577,12 +501,10 @@ def test_endorsed_by(self, endorser_role_name, thread_anonymous): self.create_role(endorser_role_name, [self.endorser]) serialized = self.serialize( self.make_cs_content(with_endorsement=True), - thread_data={"anonymous": thread_anonymous}, + thread_data={"anonymous": thread_anonymous} ) actual_endorser_anonymous = serialized["endorsed_by"] is None - expected_endorser_anonymous = ( - endorser_role_name == FORUM_ROLE_STUDENT and thread_anonymous - ) + expected_endorser_anonymous = endorser_role_name == FORUM_ROLE_STUDENT and thread_anonymous assert actual_endorser_anonymous == expected_endorser_anonymous @ddt.data( @@ -605,69 +527,56 @@ def test_endorsed_by_labels(self, role_name, expected_label): """ self.create_role(role_name, [self.endorser]) serialized = self.serialize(self.make_cs_content(with_endorsement=True)) - assert serialized["endorsed_by_label"] == expected_label + assert serialized['endorsed_by_label'] == expected_label def test_endorsed_at(self): serialized = self.serialize(self.make_cs_content(with_endorsement=True)) - assert serialized["endorsed_at"] == self.endorsed_at + assert serialized['endorsed_at'] == self.endorsed_at def test_children(self): - comment = self.make_cs_content( - { - "id": "test_root", - "children": [ - self.make_cs_content( - { - "id": "test_child_1", - "parent_id": "test_root", - } - ), - self.make_cs_content( - { - "id": "test_child_2", - "parent_id": "test_root", - "children": [ - self.make_cs_content( - { - "id": "test_grandchild", - "parent_id": "test_child_2", - } - ) - ], - } - ), - ], - } - ) + comment = self.make_cs_content({ + "id": "test_root", + "children": [ + self.make_cs_content({ + "id": "test_child_1", + "parent_id": "test_root", + }), + self.make_cs_content({ + "id": "test_child_2", + "parent_id": "test_root", + "children": [ + self.make_cs_content({ + "id": "test_grandchild", + "parent_id": "test_child_2" + }) + ], + }), + ], + }) serialized = self.serialize(comment) - assert serialized["children"][0]["id"] == "test_child_1" - assert serialized["children"][0]["parent_id"] == "test_root" - assert serialized["children"][1]["id"] == "test_child_2" - assert serialized["children"][1]["parent_id"] == "test_root" - assert serialized["children"][1]["children"][0]["id"] == "test_grandchild" - assert serialized["children"][1]["children"][0]["parent_id"] == "test_child_2" + assert serialized['children'][0]['id'] == 'test_child_1' + assert serialized['children'][0]['parent_id'] == 'test_root' + assert serialized['children'][1]['id'] == 'test_child_2' + assert serialized['children'][1]['parent_id'] == 'test_root' + assert serialized['children'][1]['children'][0]['id'] == 'test_grandchild' + assert serialized['children'][1]['children'][0]['parent_id'] == 'test_child_2' @ddt.ddt class ThreadSerializerDeserializationTest( - ForumsEnableMixin, - CommentsServiceMockMixin, - UrlResetMixin, - SharedModuleStoreTestCase, + ForumsEnableMixin, + CommentsServiceMockMixin, + UrlResetMixin, + SharedModuleStoreTestCase ): """Tests for ThreadSerializer deserialization.""" - @classmethod - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUpClass(cls): super().setUpClass() cls.course = CourseFactory.create() - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() httpretty.reset() @@ -675,8 +584,8 @@ def setUp(self): self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -691,22 +600,18 @@ def setUp(self): "title": "Test Title", "raw_body": "Test body", } - self.existing_thread = Thread( - **make_minimal_cs_thread( - { - "id": "existing_thread", - "course_id": str(self.course.id), - "commentable_id": "original_topic", - "thread_type": "discussion", - "title": "Original Title", - "body": "Original body", - "user_id": str(self.user.id), - "username": self.user.username, - "read": "False", - "endorsed": "False", - } - ) - ) + self.existing_thread = Thread(**make_minimal_cs_thread({ + "id": "existing_thread", + "course_id": str(self.course.id), + "commentable_id": "original_topic", + "thread_type": "discussion", + "title": "Original Title", + "body": "Original body", + "user_id": str(self.user.id), + "username": self.user.username, + "read": "False", + "endorsed": "False" + })) def save_and_reserialize(self, data, instance=None): """ @@ -718,7 +623,7 @@ def save_and_reserialize(self, data, instance=None): instance, data=data, partial=(instance is not None), - context=get_context(self.course, self.request), + context=get_context(self.course, self.request) ) assert serializer.is_valid() serializer.save() @@ -730,36 +635,33 @@ def test_create_missing_field(self): data.pop(field) serializer = ThreadSerializer(data=data) assert not serializer.is_valid() - assert serializer.errors == {field: ["This field is required."]} + assert serializer.errors == {field: ['This field is required.']} @ddt.data("", " ") def test_create_empty_string(self, value): data = self.minimal_data.copy() data.update({field: value for field in ["topic_id", "title", "raw_body"]}) - serializer = ThreadSerializer( - data=data, context=get_context(self.course, self.request) - ) + serializer = ThreadSerializer(data=data, context=get_context(self.course, self.request)) assert not serializer.is_valid() assert serializer.errors == { - field: ["This field may not be blank."] - for field in ["topic_id", "title", "raw_body"] + field: ['This field may not be blank.'] for field in ['topic_id', 'title', 'raw_body'] } def test_update_empty(self): self.register_put_thread_response(self.existing_thread.attributes) self.save_and_reserialize({}, self.existing_thread) assert parsed_body(httpretty.last_request()) == { - "course_id": [str(self.course.id)], - "commentable_id": ["original_topic"], - "thread_type": ["discussion"], - "title": ["Original Title"], - "body": ["Original body"], - "anonymous": ["False"], - "anonymous_to_peers": ["False"], - "closed": ["False"], - "pinned": ["False"], - "user_id": [str(self.user.id)], - "read": ["False"], + 'course_id': [str(self.course.id)], + 'commentable_id': ['original_topic'], + 'thread_type': ['discussion'], + 'title': ['Original Title'], + 'body': ['Original body'], + 'anonymous': ['False'], + 'anonymous_to_peers': ['False'], + 'closed': ['False'], + 'pinned': ['False'], + 'user_id': [str(self.user.id)], + 'read': ['False'] } @ddt.data(True, False) @@ -774,18 +676,18 @@ def test_update_all(self, read): } saved = self.save_and_reserialize(data, self.existing_thread) assert parsed_body(httpretty.last_request()) == { - "course_id": [str(self.course.id)], - "commentable_id": ["edited_topic"], - "thread_type": ["question"], - "title": ["Edited Title"], - "body": ["Edited body"], - "anonymous": ["False"], - "anonymous_to_peers": ["False"], - "closed": ["False"], - "pinned": ["False"], - "user_id": [str(self.user.id)], - "read": [str(read)], - "editing_user_id": [str(self.user.id)], + 'course_id': [str(self.course.id)], + 'commentable_id': ['edited_topic'], + 'thread_type': ['question'], + 'title': ['Edited Title'], + 'body': ['Edited body'], + 'anonymous': ['False'], + 'anonymous_to_peers': ['False'], + 'closed': ['False'], + 'pinned': ['False'], + 'user_id': [str(self.user.id)], + 'read': [str(read)], + 'editing_user_id': [str(self.user.id)], } for key in data: assert saved[key] == data[key] @@ -800,7 +702,7 @@ def test_update_anonymous(self): "anonymous": True, } self.save_and_reserialize(data, self.existing_thread) - assert parsed_body(httpretty.last_request())["anonymous"] == ["True"] + assert parsed_body(httpretty.last_request())["anonymous"] == ['True'] def test_update_anonymous_to_peers(self): """ @@ -812,7 +714,7 @@ def test_update_anonymous_to_peers(self): "anonymous_to_peers": True, } self.save_and_reserialize(data, self.existing_thread) - assert parsed_body(httpretty.last_request())["anonymous_to_peers"] == ["True"] + assert parsed_body(httpretty.last_request())["anonymous_to_peers"] == ['True'] @ddt.data("", " ") def test_update_empty_string(self, value): @@ -820,12 +722,11 @@ def test_update_empty_string(self, value): self.existing_thread, data={field: value for field in ["topic_id", "title", "raw_body"]}, partial=True, - context=get_context(self.course, self.request), + context=get_context(self.course, self.request) ) assert not serializer.is_valid() assert serializer.errors == { - field: ["This field may not be blank."] - for field in ["topic_id", "title", "raw_body"] + field: ['This field may not be blank.'] for field in ['topic_id', 'title', 'raw_body'] } def test_update_course_id(self): @@ -833,20 +734,15 @@ def test_update_course_id(self): self.existing_thread, data={"course_id": "some/other/course"}, partial=True, - context=get_context(self.course, self.request), + context=get_context(self.course, self.request) ) assert not serializer.is_valid() - assert serializer.errors == { - "course_id": ["This field is not allowed in an update."] - } + assert serializer.errors == {'course_id': ['This field is not allowed in an update.']} @ddt.ddt -class CommentSerializerDeserializationTest( - ForumsEnableMixin, CommentsServiceMockMixin, SharedModuleStoreTestCase -): +class CommentSerializerDeserializationTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedModuleStoreTestCase): """Tests for ThreadSerializer deserialization.""" - @classmethod def setUpClass(cls): super().setUpClass() @@ -859,8 +755,8 @@ def setUp(self): self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -882,18 +778,14 @@ def setUp(self): "thread_id": "test_thread", "raw_body": "Test body", } - self.existing_comment = Comment( - **make_minimal_cs_comment( - { - "id": "existing_comment", - "thread_id": "dummy", - "body": "Original body", - "user_id": str(self.user.id), - "username": self.user.username, - "course_id": str(self.course.id), - } - ) - ) + self.existing_comment = Comment(**make_minimal_cs_comment({ + "id": "existing_comment", + "thread_id": "dummy", + "body": "Original body", + "user_id": str(self.user.id), + "username": self.user.username, + "course_id": str(self.course.id), + })) def save_and_reserialize(self, data, instance=None): """ @@ -903,10 +795,13 @@ def save_and_reserialize(self, data, instance=None): context = get_context( self.course, self.request, - make_minimal_cs_thread({"course_id": str(self.course.id)}), + make_minimal_cs_thread({"course_id": str(self.course.id)}) ) serializer = CommentSerializer( - instance, data=data, partial=(instance is not None), context=context + instance, + data=data, + partial=(instance is not None), + context=context ) assert serializer.is_valid() serializer.save() @@ -918,23 +813,21 @@ def test_create_missing_field(self): data.pop(field) serializer = CommentSerializer( data=data, - context=get_context( - self.course, self.request, make_minimal_cs_thread() - ), + context=get_context(self.course, self.request, make_minimal_cs_thread()) ) assert not serializer.is_valid() - assert serializer.errors == {field: ["This field is required."]} + assert serializer.errors == {field: ['This field is required.']} def test_update_empty(self): self.register_put_comment_response(self.existing_comment.attributes) self.save_and_reserialize({}, instance=self.existing_comment) assert parsed_body(httpretty.last_request()) == { - "body": ["Original body"], - "course_id": [str(self.course.id)], - "user_id": [str(self.user.id)], - "anonymous": ["False"], - "anonymous_to_peers": ["False"], - "endorsed": ["False"], + 'body': ['Original body'], + 'course_id': [str(self.course.id)], + 'user_id': [str(self.user.id)], + 'anonymous': ['False'], + 'anonymous_to_peers': ['False'], + 'endorsed': ['False'] } def test_update_anonymous(self): @@ -947,7 +840,7 @@ def test_update_anonymous(self): "anonymous": True, } self.save_and_reserialize(data, self.existing_comment) - assert parsed_body(httpretty.last_request())["anonymous"] == ["True"] + assert parsed_body(httpretty.last_request())["anonymous"] == ['True'] def test_update_anonymous_to_peers(self): """ @@ -959,7 +852,7 @@ def test_update_anonymous_to_peers(self): "anonymous_to_peers": True, } self.save_and_reserialize(data, self.existing_comment) - assert parsed_body(httpretty.last_request())["anonymous_to_peers"] == ["True"] + assert parsed_body(httpretty.last_request())["anonymous_to_peers"] == ['True'] @ddt.data("thread_id", "parent_id") def test_update_non_updatable(self, field): @@ -967,26 +860,23 @@ def test_update_non_updatable(self, field): self.existing_comment, data={field: "different_value"}, partial=True, - context=get_context(self.course, self.request), + context=get_context(self.course, self.request) ) assert not serializer.is_valid() - assert serializer.errors == {field: ["This field is not allowed in an update."]} + assert serializer.errors == {field: ['This field is not allowed in an update.']} class FilterSpamTest(SharedModuleStoreTestCase): """ Tests for the filter_spam method """ - - @override_settings(DISCUSSION_SPAM_URLS=["example.com"]) + @override_settings(DISCUSSION_SPAM_URLS=['example.com']) def test_filter(self): self.assertEqual( - filter_spam_urls_from_html( - '' - )[0], - "
abc
", + filter_spam_urls_from_html('')[0], + '
abc
' ) self.assertEqual( - filter_spam_urls_from_html("
example.com/abc/def
")[0], - "
", + filter_spam_urls_from_html('
example.com/abc/def
')[0], + '
' ) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index 8a9405076c2d..e4d46168c46d 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -2,6 +2,7 @@ Tests for Discussion API views """ + import json import random from datetime import datetime @@ -19,22 +20,22 @@ from rest_framework import status from rest_framework.test import APIClient, APITestCase +from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE +from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, check_mongo_calls + from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory -from common.djangoapps.student.models import ( - CourseEnrollment, - get_retired_username_by_username, -) -from common.djangoapps.student.roles import ( - CourseInstructorRole, - CourseStaffRole, - GlobalStaff, -) +from common.djangoapps.student.models import get_retired_username_by_username, CourseEnrollment +from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff from common.djangoapps.student.tests.factories import ( AdminFactory, CourseEnrollmentFactory, SuperuserFactory, - UserFactory, + UserFactory ) from common.djangoapps.util.testing import UrlResetMixin from lms.djangoapps.discussion.django_comment_client.tests.utils import ( @@ -47,50 +48,21 @@ make_minimal_cs_comment, make_minimal_cs_thread, ) -from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string -from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts -from openedx.core.djangoapps.discussions.config.waffle import ( - ENABLE_NEW_STRUCTURE_DISCUSSIONS, -) -from openedx.core.djangoapps.discussions.models import ( - DiscussionsConfiguration, - DiscussionTopicLink, - Provider, -) -from openedx.core.djangoapps.discussions.tasks import ( - update_discussions_settings_from_course_task, -) +from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS +from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, DiscussionTopicLink, Provider +from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task from openedx.core.djangoapps.django_comment_common.models import ( CourseDiscussionSettings, Role, ) from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user -from openedx.core.djangoapps.oauth_dispatch.tests.factories import ( - AccessTokenFactory, - ApplicationFactory, -) -from openedx.core.djangoapps.user_api.models import ( - RetirementState, - UserRetirementStatus, -) -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import ( - ModuleStoreTestCase, - SharedModuleStoreTestCase, -) -from xmodule.modulestore.tests.factories import ( - BlockFactory, - CourseFactory, - check_mongo_calls, -) +from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory +from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus -class DiscussionAPIViewTestMixin( - ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin -): +class DiscussionAPIViewTestMixin(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin): """ Mixin for common code in tests of Discussion API views. This includes creation of common structures (e.g. a course, user, and enrollment), logging @@ -100,9 +72,7 @@ class DiscussionAPIViewTestMixin( client_class = APIClient - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() self.maxDiff = None # pylint: disable=invalid-name @@ -111,7 +81,7 @@ def setUp(self): course="y", run="z", start=datetime.now(UTC), - discussion_topics={"Test Topic": {"id": "test_topic"}}, + discussion_topics={"Test Topic": {"id": "test_topic"}} ) self.password = "Password1234" self.user = UserFactory.create(password=self.password) @@ -126,25 +96,23 @@ def assert_response_correct(self, response, expected_status, expected_content): Assert that the response has the given status code and parsed content """ assert response.status_code == expected_status - parsed_content = json.loads(response.content.decode("utf-8")) + parsed_content = json.loads(response.content.decode('utf-8')) assert parsed_content == expected_content def register_thread(self, overrides=None): """ Create cs_thread with minimal fields and register response """ - cs_thread = make_minimal_cs_thread( - { - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "username": self.user.username, - "user_id": str(self.user.id), - "thread_type": "discussion", - "title": "Test Title", - "body": "Test body", - } - ) + cs_thread = make_minimal_cs_thread({ + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "username": self.user.username, + "user_id": str(self.user.id), + "thread_type": "discussion", + "title": "Test Title", + "body": "Test body", + }) cs_thread.update(overrides or {}) self.register_get_thread_response(cs_thread) self.register_put_thread_response(cs_thread) @@ -153,16 +121,14 @@ def register_comment(self, overrides=None): """ Create cs_comment with minimal fields and register response """ - cs_comment = make_minimal_cs_comment( - { - "id": "test_comment", - "course_id": str(self.course.id), - "thread_id": "test_thread", - "username": self.user.username, - "user_id": str(self.user.id), - "body": "Original body", - } - ) + cs_comment = make_minimal_cs_comment({ + "id": "test_comment", + "course_id": str(self.course.id), + "thread_id": "test_thread", + "username": self.user.username, + "user_id": str(self.user.id), + "body": "Original body", + }) cs_comment.update(overrides or {}) self.register_get_comment_response(cs_comment) self.register_put_comment_response(cs_comment) @@ -174,7 +140,7 @@ def test_not_authenticated(self): self.assert_response_correct( response, 401, - {"developer_message": "Authentication credentials were not provided."}, + {"developer_message": "Authentication credentials were not provided."} ) def test_inactive(self): @@ -183,16 +149,12 @@ def test_inactive(self): @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class UploadFileViewTest( - ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestCase -): +class UploadFileViewTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestCase): """ Tests for UploadFileView. """ - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() self.valid_file = { @@ -203,13 +165,11 @@ def setUp(self): ), } self.user = UserFactory.create(password=self.TEST_PASSWORD) - self.course = CourseFactory.create( - org="a", course="b", run="c", start=datetime.now(UTC) - ) + self.course = CourseFactory.create(org='a', course='b', run='c', start=datetime.now(UTC)) self.url = reverse("upload_file", kwargs={"course_id": str(self.course.id)}) patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -297,13 +257,10 @@ def test_file_upload_with_thread_key(self): """ self.user_login() self.enroll_user_in_course() - response = self.client.post( - self.url, - { - **self.valid_file, - "thread_key": "somethread", - }, - ) + response = self.client.post(self.url, { + **self.valid_file, + "thread_key": "somethread", + }) response_data = json.loads(response.content) assert "/somethread/" in response_data["location"] @@ -357,9 +314,7 @@ class CommentViewSetListByUserTest( Common test cases for views retrieving user-published content. """ - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() @@ -368,8 +323,8 @@ def setUp(self): self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -380,9 +335,7 @@ def setUp(self): self.other_user = UserFactory.create(password=self.TEST_PASSWORD) self.register_get_user_response(self.other_user) - self.course = CourseFactory.create( - org="a", course="b", run="c", start=datetime.now(UTC) - ) + self.course = CourseFactory.create(org="a", course="b", run="c", start=datetime.now(UTC)) CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) self.url = self.build_url(self.user.username, self.course.id) @@ -393,18 +346,16 @@ def register_mock_endpoints(self): """ self.register_get_threads_response( threads=[ - make_minimal_cs_thread( - { - "id": f"test_thread_{index}", - "course_id": str(self.course.id), - "commentable_id": f"test_topic_{index}", - "username": self.user.username, - "user_id": str(self.user.id), - "thread_type": "discussion", - "title": f"Test Title #{index}", - "body": f"Test body #{index}", - } - ) + make_minimal_cs_thread({ + "id": f"test_thread_{index}", + "course_id": str(self.course.id), + "commentable_id": f"test_topic_{index}", + "username": self.user.username, + "user_id": str(self.user.id), + "thread_type": "discussion", + "title": f"Test Title #{index}", + "body": f"Test body #{index}", + }) for index in range(30) ], page=1, @@ -412,18 +363,16 @@ def register_mock_endpoints(self): ) self.register_get_comments_response( comments=[ - make_minimal_cs_comment( - { - "id": f"test_comment_{index}", - "thread_id": "test_thread", - "user_id": str(self.user.id), - "username": self.user.username, - "created_at": "2015-05-11T00:00:00Z", - "updated_at": "2015-05-11T11:11:11Z", - "body": f"Test body #{index}", - "votes": {"up_count": 4}, - } - ) + make_minimal_cs_comment({ + "id": f"test_comment_{index}", + "thread_id": "test_thread", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-05-11T00:00:00Z", + "updated_at": "2015-05-11T11:11:11Z", + "body": f"Test body #{index}", + "votes": {"up_count": 4}, + }) for index in range(30) ], page=1, @@ -435,13 +384,11 @@ def build_url(self, username, course_id, **kwargs): Builds an URL to access content from an user on a specific course. """ base = reverse("comment-list") - query = urlencode( - { - "username": username, - "course_id": str(course_id), - **kwargs, - } - ) + query = urlencode({ + "username": username, + "course_id": str(course_id), + **kwargs, + }) return f"{base}?{query}" def assert_successful_response(self, response): @@ -467,9 +414,7 @@ def test_request_by_unauthorized_user(self): they're not either enrolled or staff members. """ self.register_mock_endpoints() - self.client.login( - username=self.other_user.username, password=self.TEST_PASSWORD - ) + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) assert response.status_code == status.HTTP_404_NOT_FOUND assert json.loads(response.content)["developer_message"] == "Course not found." @@ -480,9 +425,7 @@ def test_request_by_enrolled_user(self): comments in that course. """ self.register_mock_endpoints() - self.client.login( - username=self.other_user.username, password=self.TEST_PASSWORD - ) + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) CourseEnrollmentFactory.create(user=self.other_user, course_id=self.course.id) self.assert_successful_response(self.client.get(self.url)) @@ -491,9 +434,7 @@ def test_request_by_global_staff(self): Staff users are allowed to get any user's comments. """ self.register_mock_endpoints() - self.client.login( - username=self.other_user.username, password=self.TEST_PASSWORD - ) + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) GlobalStaff().add_users(self.other_user) self.assert_successful_response(self.client.get(self.url)) @@ -504,9 +445,7 @@ def test_request_by_course_staff(self, role): course. """ self.register_mock_endpoints() - self.client.login( - username=self.other_user.username, password=self.TEST_PASSWORD - ) + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) role(course_key=self.course.id).add_users(self.other_user) self.assert_successful_response(self.client.get(self.url)) @@ -515,9 +454,7 @@ def test_request_with_non_existent_user(self): Requests for users that don't exist result in a 404 response. """ self.register_mock_endpoints() - self.client.login( - username=self.other_user.username, password=self.TEST_PASSWORD - ) + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) GlobalStaff().add_users(self.other_user) url = self.build_url("non_existent", self.course.id) response = self.client.get(url) @@ -528,9 +465,7 @@ def test_request_with_non_existent_course(self): Requests for courses that don't exist result in a 404 response. """ self.register_mock_endpoints() - self.client.login( - username=self.other_user.username, password=self.TEST_PASSWORD - ) + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) GlobalStaff().add_users(self.other_user) url = self.build_url(self.user.username, "course-v1:x+y+z") response = self.client.get(url) @@ -541,18 +476,14 @@ def test_request_with_invalid_course_id(self): Requests with invalid course ID should fail form validation. """ self.register_mock_endpoints() - self.client.login( - username=self.other_user.username, password=self.TEST_PASSWORD - ) + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) GlobalStaff().add_users(self.other_user) url = self.build_url(self.user.username, "an invalid course") response = self.client.get(url) assert response.status_code == status.HTTP_400_BAD_REQUEST parsed_response = json.loads(response.content) - assert ( - parsed_response["field_errors"]["course_id"]["developer_message"] - == "'an invalid course' is not a valid course id" - ) + assert parsed_response["field_errors"]["course_id"]["developer_message"] == \ + "'an invalid course' is not a valid course id" def test_request_with_empty_results_page(self): """ @@ -562,9 +493,7 @@ def test_request_with_empty_results_page(self): self.register_get_threads_response(threads=[], page=1, num_pages=1) self.register_get_comments_response(comments=[], page=1, num_pages=1) - self.client.login( - username=self.other_user.username, password=self.TEST_PASSWORD - ) + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) GlobalStaff().add_users(self.other_user) url = self.build_url(self.user.username, self.course.id, page=2) response = self.client.get(url) @@ -572,23 +501,17 @@ def test_request_with_empty_results_page(self): @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -@override_settings( - DISCUSSION_MODERATION_EDIT_REASON_CODES={"test-edit-reason": "Test Edit Reason"} -) -@override_settings( - DISCUSSION_MODERATION_CLOSE_REASON_CODES={"test-close-reason": "Test Close Reason"} -) +@override_settings(DISCUSSION_MODERATION_EDIT_REASON_CODES={"test-edit-reason": "Test Edit Reason"}) +@override_settings(DISCUSSION_MODERATION_CLOSE_REASON_CODES={"test-close-reason": "Test Close Reason"}) class CourseViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): """Tests for CourseView""" def setUp(self): super().setUp() - self.url = reverse( - "discussion_course", kwargs={"course_id": str(self.course.id)} - ) + self.url = reverse("discussion_course", kwargs={"course_id": str(self.course.id)}) patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -598,7 +521,9 @@ def test_404(self): reverse("course_topics", kwargs={"course_id": "non/existent/course"}) ) self.assert_response_correct( - response, 404, {"developer_message": "Course not found."} + response, + 404, + {"developer_message": "Course not found."} ) def test_basic(self): @@ -622,27 +547,23 @@ def test_basic(self): "allow_anonymous_to_peers": False, "has_bulk_delete_privileges": False, "has_moderation_privileges": False, - "is_course_admin": False, - "is_course_staff": False, + 'is_course_admin': False, + 'is_course_staff': False, "is_group_ta": False, - "is_user_admin": False, + 'is_user_admin': False, "user_roles": ["Student"], - "edit_reasons": [ - {"code": "test-edit-reason", "label": "Test Edit Reason"} - ], - "post_close_reasons": [ - {"code": "test-close-reason", "label": "Test Close Reason"} - ], - "show_discussions": True, - "is_notify_all_learners_enabled": False, - "captcha_settings": { - "enabled": False, - "site_key": None, + "edit_reasons": [{"code": "test-edit-reason", "label": "Test Edit Reason"}], + "post_close_reasons": [{"code": "test-close-reason", "label": "Test Close Reason"}], + 'show_discussions': True, + 'is_notify_all_learners_enabled': False, + 'captcha_settings': { + 'enabled': False, + 'site_key': None, }, "is_email_verified": True, "only_verified_users_can_post": False, - "content_creation_rate_limited": False, - }, + "content_creation_rate_limited": False + } ) @@ -653,10 +574,8 @@ class RetireViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): def setUp(self): super().setUp() - RetirementState.objects.create(state_name="PENDING", state_execution_order=1) - self.retire_forums_state = RetirementState.objects.create( - state_name="RETIRE_FORUMS", state_execution_order=11 - ) + RetirementState.objects.create(state_name='PENDING', state_execution_order=1) + self.retire_forums_state = RetirementState.objects.create(state_name='RETIRE_FORUMS', state_execution_order=11) self.retirement = UserRetirementStatus.create_retirement(self.user) self.retirement.current_state = self.retire_forums_state @@ -667,8 +586,8 @@ def setUp(self): self.retired_username = get_retired_username_by_username(self.user.username) self.url = reverse("retire_discussion_user") patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -680,14 +599,14 @@ def assert_response_correct(self, response, expected_status, expected_content): assert response.status_code == expected_status if expected_content: - assert response.content.decode("utf-8") == expected_content + assert response.content.decode('utf-8') == expected_content def build_jwt_headers(self, user): """ Helper function for creating headers for the JWT authentication. """ token = create_jwt_for_user(user) - headers = {"HTTP_AUTHORIZATION": "JWT " + token} + headers = {'HTTP_AUTHORIZATION': 'JWT ' + token} return headers def test_basic(self): @@ -696,7 +615,7 @@ def test_basic(self): """ self.register_get_user_retire_response(self.user) headers = self.build_jwt_headers(self.superuser) - data = {"username": self.user.username} + data = {'username': self.user.username} response = self.superuser_client.post(self.url, data, **headers) self.assert_response_correct(response, 204, b"") @@ -704,11 +623,9 @@ def test_downstream_forums_error(self): """ Check that we bubble up errors from the comments service """ - self.register_get_user_retire_response( - self.user, status=500, body="Server error" - ) + self.register_get_user_retire_response(self.user, status=500, body="Server error") headers = self.build_jwt_headers(self.superuser) - data = {"username": self.user.username} + data = {'username': self.user.username} response = self.superuser_client.post(self.url, data, **headers) self.assert_response_correct(response, 500, '"Server error"') @@ -718,7 +635,7 @@ def test_nonexistent_user(self): """ nonexistent_username = "nonexistent user" self.retired_username = get_retired_username_by_username(nonexistent_username) - data = {"username": nonexistent_username} + data = {'username': nonexistent_username} headers = self.build_jwt_headers(self.superuser) response = self.superuser_client.post(self.url, data, **headers) self.assert_response_correct(response, 404, None) @@ -732,10 +649,7 @@ def test_not_authenticated(self): @ddt.ddt @httpretty.activate -@mock.patch( - "django.conf.settings.USERNAME_REPLACEMENT_WORKER", - "test_replace_username_service_worker", -) +@mock.patch('django.conf.settings.USERNAME_REPLACEMENT_WORKER', 'test_replace_username_service_worker') @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) class ReplaceUsernamesViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): """Tests for ReplaceUsernamesView""" @@ -748,8 +662,8 @@ def setUp(self): self.new_username = "test_username_replacement" self.url = reverse("replace_discussion_username") patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -768,28 +682,34 @@ def build_jwt_headers(self, user): Helper function for creating headers for the JWT authentication. """ token = create_jwt_for_user(user) - headers = {"HTTP_AUTHORIZATION": "JWT " + token} + headers = {'HTTP_AUTHORIZATION': 'JWT ' + token} return headers def call_api(self, user, client, data): - """Helper function to call API with data""" + """ Helper function to call API with data """ data = json.dumps(data) headers = self.build_jwt_headers(user) - return client.post(self.url, data, content_type="application/json", **headers) + return client.post(self.url, data, content_type='application/json', **headers) - @ddt.data([{}, {}], {}, [{"test_key": "test_value", "test_key_2": "test_value_2"}]) + @ddt.data( + [{}, {}], + {}, + [{"test_key": "test_value", "test_key_2": "test_value_2"}] + ) def test_bad_schema(self, mapping_data): - """Verify the endpoint rejects bad data schema""" - data = {"username_mappings": mapping_data} + """ Verify the endpoint rejects bad data schema """ + data = { + "username_mappings": mapping_data + } response = self.call_api(self.worker, self.worker_client, data) assert response.status_code == 400 def test_auth(self): - """Verify the endpoint only works with the service worker""" + """ Verify the endpoint only works with the service worker """ data = { "username_mappings": [ {"test_username_1": "test_new_username_1"}, - {"test_username_2": "test_new_username_2"}, + {"test_username_2": "test_new_username_2"} ] } @@ -807,15 +727,15 @@ def test_auth(self): assert response.status_code == 200 def test_basic(self): - """Check successful replacement""" + """ Check successful replacement """ data = { "username_mappings": [ {self.user.username: self.new_username}, ] } expected_response = { - "failed_replacements": [], - "successful_replacements": data["username_mappings"], + 'failed_replacements': [], + 'successful_replacements': data["username_mappings"] } self.register_get_username_replacement_response(self.user) response = self.call_api(self.worker, self.worker_client, data) @@ -831,9 +751,7 @@ def test_not_authenticated(self): @ddt.ddt @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class CourseTopicsViewTest( - DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase -): +class CourseTopicsViewTest(DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase): """ Tests for CourseTopicsView """ @@ -850,12 +768,10 @@ def setUp(self): "courseware-2": {"discussion": 4, "question": 5}, "courseware-3": {"discussion": 7, "question": 2}, } - self.register_get_course_commentable_counts_response( - self.course.id, self.thread_counts_map - ) + self.register_get_course_commentable_counts_response(self.course.id, self.thread_counts_map) patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -870,7 +786,7 @@ def create_course(self, blocks_count, module_store, topics): run="c", start=datetime.now(UTC), default_store=module_store, - discussion_topics=topics, + discussion_topics=topics ) CourseEnrollmentFactory.create(user=self.user, course_id=course.id) course_url = reverse("course_topics", kwargs={"course_id": str(course.id)}) @@ -878,10 +794,10 @@ def create_course(self, blocks_count, module_store, topics): for i in range(blocks_count): BlockFactory.create( parent_location=course.location, - category="discussion", - discussion_id=f"id_module_{i}", - discussion_category=f"Category {i}", - discussion_target=f"Discussion {i}", + category='discussion', + discussion_id=f'id_module_{i}', + discussion_category=f'Category {i}', + discussion_target=f'Discussion {i}', publish_item=False, ) return course_url, course.id @@ -896,7 +812,7 @@ def make_discussion_xblock(self, topic_id, category, subcategory, **kwargs): discussion_id=topic_id, discussion_category=category, discussion_target=subcategory, - **kwargs, + **kwargs ) def test_404(self): @@ -904,7 +820,9 @@ def test_404(self): reverse("course_topics", kwargs={"course_id": "non/existent/course"}) ) self.assert_response_correct( - response, 404, {"developer_message": "Course not found."} + response, + 404, + {"developer_message": "Course not found."} ) def test_basic(self): @@ -914,30 +832,21 @@ def test_basic(self): 200, { "courseware_topics": [], - "non_courseware_topics": [ - { - "id": "test_topic", - "name": "Test Topic", - "children": [], - "thread_list_url": "http://testserver/api/discussion/v1/threads/" - "?course_id=course-v1%3Ax%2By%2Bz&topic_id=test_topic", - "thread_counts": {"discussion": 0, "question": 0}, - } - ], - }, + "non_courseware_topics": [{ + "id": "test_topic", + "name": "Test Topic", + "children": [], + "thread_list_url": 'http://testserver/api/discussion/v1/threads/' + '?course_id=course-v1%3Ax%2By%2Bz&topic_id=test_topic', + "thread_counts": {"discussion": 0, "question": 0}, + }], + } ) @ddt.data( (2, ModuleStoreEnum.Type.split, 2, {"Test Topic 1": {"id": "test_topic_1"}}), - ( - 2, - ModuleStoreEnum.Type.split, - 2, - { - "Test Topic 1": {"id": "test_topic_1"}, - "Test Topic 2": {"id": "test_topic_2"}, - }, - ), + (2, ModuleStoreEnum.Type.split, 2, + {"Test Topic 1": {"id": "test_topic_1"}, "Test Topic 2": {"id": "test_topic_2"}}), (10, ModuleStoreEnum.Type.split, 2, {"Test Topic 1": {"id": "test_topic_1"}}), ) @ddt.unpack @@ -959,7 +868,7 @@ def test_discussion_topic_404(self): self.assert_response_correct( response, 404, - {"developer_message": "Discussion not found for 'invalid_topic_id'."}, + {"developer_message": "Discussion not found for 'invalid_topic_id'."} ) def test_topic_id(self): @@ -979,41 +888,38 @@ def test_topic_id(self): "non_courseware_topics": [], "courseware_topics": [ { - "children": [ - { - "children": [], - "id": "topic_id_1", - "thread_list_url": "http://testserver/api/discussion/v1/threads/?" - "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1", - "name": "test_target_1", - "thread_counts": {"discussion": 0, "question": 0}, - } - ], + "children": [{ + "children": [], + "id": "topic_id_1", + "thread_list_url": "http://testserver/api/discussion/v1/threads/?" + "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1", + "name": "test_target_1", + "thread_counts": {"discussion": 0, "question": 0}, + }], "id": None, "thread_list_url": "http://testserver/api/discussion/v1/threads/?" - "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1", + "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1", "name": "test_category_1", "thread_counts": None, }, { - "children": [ - { + "children": + [{ "children": [], "id": "topic_id_2", "thread_list_url": "http://testserver/api/discussion/v1/threads/?" - "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2", + "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2", "name": "test_target_2", "thread_counts": {"discussion": 0, "question": 0}, - } - ], + }], "id": None, "thread_list_url": "http://testserver/api/discussion/v1/threads/?" - "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2", + "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2", "name": "test_category_2", "thread_counts": None, - }, - ], - }, + } + ] + } ) @override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, True) @@ -1024,46 +930,45 @@ def test_new_course_structure_response(self): """ chapter = BlockFactory.create( parent_location=self.course.location, - category="chapter", + category='chapter', display_name="Week 1", start=datetime(2015, 3, 1, tzinfo=UTC), ) sequential = BlockFactory.create( parent_location=chapter.location, - category="sequential", + category='sequential', display_name="Lesson 1", start=datetime(2015, 3, 1, tzinfo=UTC), ) BlockFactory.create( parent_location=sequential.location, - category="vertical", - display_name="vertical", + category='vertical', + display_name='vertical', start=datetime(2015, 4, 1, tzinfo=UTC), ) DiscussionsConfiguration.objects.create( - context_key=self.course.id, provider_type=Provider.OPEN_EDX + context_key=self.course.id, + provider_type=Provider.OPEN_EDX ) update_discussions_settings_from_course_task(str(self.course.id)) response = json.loads(self.client.get(self.url).content.decode()) - keys = ["children", "id", "name", "thread_counts", "thread_list_url"] - assert list(response.keys()) == ["courseware_topics", "non_courseware_topics"] - assert len(response["courseware_topics"]) == 1 - courseware_keys = list(response["courseware_topics"][0].keys()) + keys = ['children', 'id', 'name', 'thread_counts', 'thread_list_url'] + assert list(response.keys()) == ['courseware_topics', 'non_courseware_topics'] + assert len(response['courseware_topics']) == 1 + courseware_keys = list(response['courseware_topics'][0].keys()) courseware_keys.sort() assert courseware_keys == keys - assert len(response["non_courseware_topics"]) == 1 - non_courseware_keys = list(response["non_courseware_topics"][0].keys()) + assert len(response['non_courseware_topics']) == 1 + non_courseware_keys = list(response['non_courseware_topics'][0].keys()) non_courseware_keys.sort() assert non_courseware_keys == keys @ddt.ddt -@mock.patch("lms.djangoapps.discussion.rest_api.api._get_course", mock.Mock()) +@mock.patch('lms.djangoapps.discussion.rest_api.api._get_course', mock.Mock()) @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) @override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, True) -class CourseTopicsViewV3Test( - DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase -): +class CourseTopicsViewV3Test(DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase): """ Tests for CourseTopicsViewV3 """ @@ -1079,68 +984,55 @@ def setUp(self) -> None: end=datetime(2028, 1, 1), enrollment_start=datetime(2020, 1, 1), enrollment_end=datetime(2028, 1, 1), - discussion_topics={ - "Course Wide Topic": { - "id": "course-wide-topic", - "usage_key": None, - } - }, + discussion_topics={"Course Wide Topic": { + "id": 'course-wide-topic', + "usage_key": None, + }} ) self.chapter = BlockFactory.create( parent_location=self.course.location, - category="chapter", + category='chapter', display_name="Week 1", start=datetime(2015, 3, 1, tzinfo=UTC), ) self.sequential = BlockFactory.create( parent_location=self.chapter.location, - category="sequential", + category='sequential', display_name="Lesson 1", start=datetime(2015, 3, 1, tzinfo=UTC), ) self.verticals = [ BlockFactory.create( parent_location=self.sequential.location, - category="vertical", - display_name="vertical", + category='vertical', + display_name='vertical', start=datetime(2015, 4, 1, tzinfo=UTC), ) ] course_key = self.course.id - self.config = DiscussionsConfiguration.objects.create( - context_key=course_key, provider_type=Provider.OPEN_EDX - ) + self.config = DiscussionsConfiguration.objects.create(context_key=course_key, provider_type=Provider.OPEN_EDX) topic_links = [] update_discussions_settings_from_course_task(str(course_key)) - topic_id_query = DiscussionTopicLink.objects.filter( - context_key=course_key - ).values_list( - "external_id", - flat=True, + topic_id_query = DiscussionTopicLink.objects.filter(context_key=course_key).values_list( + 'external_id', flat=True, ) - topic_ids = list(topic_id_query.order_by("ordering")) + topic_ids = list(topic_id_query.order_by('ordering')) DiscussionTopicLink.objects.bulk_create(topic_links) self.topic_stats = { - **{ - topic_id: dict( - discussion=random.randint(0, 10), question=random.randint(0, 10) - ) - for topic_id in set(topic_ids) - }, + **{topic_id: dict(discussion=random.randint(0, 10), question=random.randint(0, 10)) + for topic_id in set(topic_ids)}, topic_ids[0]: dict(discussion=0, question=0), } patcher = mock.patch( - "lms.djangoapps.discussion.rest_api.api.get_course_commentable_counts", + 'lms.djangoapps.discussion.rest_api.api.get_course_commentable_counts', mock.Mock(return_value=self.topic_stats), ) patcher.start() self.addCleanup(patcher.stop) - self.url = reverse( - "course_topics_v3", kwargs={"course_id": str(self.course.id)} - ) + self.url = reverse("course_topics_v3", kwargs={"course_id": str(self.course.id)}) patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -1149,23 +1041,12 @@ def test_basic(self): response = self.client.get(self.url) data = json.loads(response.content.decode()) expected_non_courseware_keys = [ - "id", - "usage_key", - "name", - "thread_counts", - "enabled_in_context", - "courseware", + 'id', 'usage_key', 'name', 'thread_counts', 'enabled_in_context', + 'courseware' ] expected_courseware_keys = [ - "id", - "block_id", - "lms_web_url", - "legacy_web_url", - "student_view_url", - "type", - "display_name", - "children", - "courseware", + 'id', 'block_id', 'lms_web_url', 'legacy_web_url', 'student_view_url', + 'type', 'display_name', 'children', 'courseware' ] assert response.status_code == 200 assert len(data) == 2 @@ -1173,11 +1054,11 @@ def test_basic(self): assert non_courseware_topic_keys == expected_non_courseware_keys courseware_topic_keys = list(data[1].keys()) assert courseware_topic_keys == expected_courseware_keys - expected_courseware_keys.remove("courseware") - sequential_keys = list(data[1]["children"][0].keys()) - assert sequential_keys == (expected_courseware_keys + ["thread_counts"]) - expected_non_courseware_keys.remove("courseware") - vertical_keys = list(data[1]["children"][0]["children"][0].keys()) + expected_courseware_keys.remove('courseware') + sequential_keys = list(data[1]['children'][0].keys()) + assert sequential_keys == (expected_courseware_keys + ['thread_counts']) + expected_non_courseware_keys.remove('courseware') + vertical_keys = list(data[1]['children'][0]['children'][0].keys()) assert vertical_keys == expected_non_courseware_keys @@ -1218,21 +1099,14 @@ def setUp(self): {"key": "close_reason", "value": None}, { "key": "comment_list_url", - "value": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", + "value": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread" }, { "key": "editable_fields", "value": [ - "abuse_flagged", - "anonymous", - "copy_link", - "following", - "raw_body", - "read", - "title", - "topic_id", - "type", - ], + 'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body', + 'read', 'title', 'topic_id', 'type' + ] }, {"key": "endorsed_comment_list_url", "value": None}, {"key": "following", "value": False}, @@ -1243,39 +1117,32 @@ def setUp(self): {"key": "non_endorsed_comment_list_url", "value": None}, {"key": "preview_body", "value": "Test body"}, {"key": "raw_body", "value": "Test body"}, + {"key": "rendered_body", "value": "

Test body

"}, {"key": "response_count", "value": 0}, {"key": "topic_id", "value": "test_topic"}, {"key": "type", "value": "discussion"}, - { - "key": "users", - "value": { - self.user.username: { - "profile": { - "image": { - "has_image": False, - "image_url_full": "http://testserver/static/default_500.png", - "image_url_large": "http://testserver/static/default_120.png", - "image_url_medium": "http://testserver/static/default_50.png", - "image_url_small": "http://testserver/static/default_30.png", - } + {"key": "users", "value": { + self.user.username: { + "profile": { + "image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", } } - }, - }, + } + }}, {"key": "vote_count", "value": 4}, {"key": "voted", "value": False}, - {"key": "is_deleted", "value": None}, - {"key": "deleted_at", "value": None}, - {"key": "deleted_by", "value": None}, - {"key": "deleted_by_label", "value": None}, + ] - self.url = reverse( - "discussion_learner_threads", kwargs={"course_id": str(self.course.id)} - ) + self.url = reverse("discussion_learner_threads", kwargs={'course_id': str(self.course.id)}) patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -1286,12 +1153,12 @@ def update_thread(self, thread): Value of these keys has been defined in setUp function """ for element in self.add_keys: - thread[element["key"]] = element["value"] + thread[element['key']] = element['value'] for pair in self.replace_keys: - thread[pair["to"]] = thread.pop(pair["from"]) + thread[pair['to']] = thread.pop(pair['from']) for key in self.remove_keys: thread.pop(key) - thread["comment_count"] += 1 + thread['comment_count'] += 1 return thread def test_basic(self): @@ -1303,26 +1170,22 @@ def test_basic(self): """ self.register_get_user_response(self.user) expected_cs_comments_response = { - "collection": [ - make_minimal_cs_thread( - { - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(self.user.id), - "username": self.user.username, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "title": "Test Title", - "body": "Test body", - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - "closed_by_label": None, - "edit_by_label": None, - } - ) - ], + "collection": [make_minimal_cs_thread({ + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + "closed_by_label": None, + "edit_by_label": None, + })], "page": 1, "num_pages": 1, } @@ -1330,14 +1193,14 @@ def test_basic(self): self.url += f"?username={self.user.username}" response = self.client.get(self.url) assert response.status_code == 200 - response_data = json.loads(response.content.decode("utf-8")) - expected_api_response = expected_cs_comments_response["collection"] + response_data = json.loads(response.content.decode('utf-8')) + expected_api_response = expected_cs_comments_response['collection'] for thread in expected_api_response: self.update_thread(thread) - assert response_data["results"] == expected_api_response - assert response_data["pagination"] == { + assert response_data['results'] == expected_api_response + assert response_data['pagination'] == { "next": None, "previous": None, "count": 1, @@ -1367,24 +1230,20 @@ def test_thread_type_by(self, thread_type): thread_type (str): Value of thread_type can be 'None', 'discussion' and 'question' """ - threads = [ - make_minimal_cs_thread( - { - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(self.user.id), - "username": self.user.username, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "title": "Test Title", - "body": "Test body", - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - } - ) - ] + threads = [make_minimal_cs_thread({ + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + })] expected_cs_comments_response = { "collection": threads, "page": 1, @@ -1398,26 +1257,23 @@ def test_thread_type_by(self, thread_type): "course_id": str(self.course.id), "username": self.user.username, "thread_type": thread_type, - }, - ) - assert response.status_code == 200 - self.assert_last_query_params( - { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "page": ["1"], - "per_page": ["10"], - "thread_type": [thread_type], - "sort_key": ["activity"], - "count_flagged": ["False"], - "show_deleted": ["False"], } ) + assert response.status_code == 200 + self.assert_last_query_params({ + "user_id": [str(self.user.id)], + "course_id": [str(self.course.id)], + "page": ["1"], + "per_page": ["10"], + "thread_type": [thread_type], + "sort_key": ['activity'], + "count_flagged": ["False"] + }) @ddt.data( ("last_activity_at", "activity"), ("comment_count", "comments"), - ("vote_count", "votes"), + ("vote_count", "votes") ) @ddt.unpack def test_order_by(self, http_query, cc_query): @@ -1428,24 +1284,20 @@ def test_order_by(self, http_query, cc_query): http_query (str): Query string sent in the http request cc_query (str): Query string used for the comments client service """ - threads = [ - make_minimal_cs_thread( - { - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(self.user.id), - "username": self.user.username, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "title": "Test Title", - "body": "Test body", - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - } - ) - ] + threads = [make_minimal_cs_thread({ + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + })] expected_cs_comments_response = { "collection": threads, "page": 1, @@ -1459,20 +1311,17 @@ def test_order_by(self, http_query, cc_query): "course_id": str(self.course.id), "username": self.user.username, "order_by": http_query, - }, - ) - assert response.status_code == 200 - self.assert_last_query_params( - { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "page": ["1"], - "per_page": ["10"], - "sort_key": [cc_query], - "count_flagged": ["False"], - "show_deleted": ["False"], } ) + assert response.status_code == 200 + self.assert_last_query_params({ + "user_id": [str(self.user.id)], + "course_id": [str(self.course.id)], + "page": ["1"], + "per_page": ["10"], + "sort_key": [cc_query], + "count_flagged": ["False"] + }) @ddt.data("flagged", "unanswered", "unread", "unresponded") def test_status_by(self, post_status): @@ -1483,24 +1332,20 @@ def test_status_by(self, post_status): post_status (str): Value of post_status can be 'flagged', 'unanswered' and 'unread' """ - threads = [ - make_minimal_cs_thread( - { - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(self.user.id), - "username": self.user.username, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "title": "Test Title", - "body": "Test body", - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - } - ) - ] + threads = [make_minimal_cs_thread({ + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + })] expected_cs_comments_response = { "collection": threads, "page": 1, @@ -1514,37 +1359,29 @@ def test_status_by(self, post_status): "course_id": str(self.course.id), "username": self.user.username, "status": post_status, - }, + } ) if post_status == "flagged": assert response.status_code == 403 else: assert response.status_code == 200 - self.assert_last_query_params( - { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "page": ["1"], - "per_page": ["10"], - post_status: ["True"], - "sort_key": ["activity"], - "count_flagged": ["False"], - "show_deleted": ["False"], - } - ) + self.assert_last_query_params({ + "user_id": [str(self.user.id)], + "course_id": [str(self.course.id)], + "page": ["1"], + "per_page": ["10"], + post_status: ['True'], + "sort_key": ['activity'], + "count_flagged": ["False"] + }) @ddt.ddt -class CourseDiscussionSettingsAPIViewTest( - APITestCase, UrlResetMixin, ModuleStoreTestCase -): +class CourseDiscussionSettingsAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTestCase): """ Test the course discussion settings handler API endpoint. """ - - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() self.course = CourseFactory.create( @@ -1552,26 +1389,24 @@ def setUp(self): course="y", run="z", start=datetime.now(UTC), - discussion_topics={"Test Topic": {"id": "test_topic"}}, - ) - self.path = reverse( - "discussion_course_settings", kwargs={"course_id": str(self.course.id)} + discussion_topics={"Test Topic": {"id": "test_topic"}} ) + self.path = reverse('discussion_course_settings', kwargs={'course_id': str(self.course.id)}) self.password = self.TEST_PASSWORD - self.user = UserFactory(username="staff", password=self.password, is_staff=True) + self.user = UserFactory(username='staff', password=self.password, is_staff=True) patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) def _get_oauth_headers(self, user): """Return the OAuth headers for testing OAuth authentication""" - access_token = AccessTokenFactory.create( - user=user, application=ApplicationFactory() - ).token - headers = {"HTTP_AUTHORIZATION": "Bearer " + access_token} + access_token = AccessTokenFactory.create(user=user, application=ApplicationFactory()).token + headers = { + 'HTTP_AUTHORIZATION': 'Bearer ' + access_token + } return headers def _login_as_staff(self): @@ -1579,30 +1414,24 @@ def _login_as_staff(self): self.client.login(username=self.user.username, password=self.password) def _login_as_discussion_staff(self): - user = UserFactory(username="abc", password="abc") - role = Role.objects.create(name="Administrator", course_id=self.course.id) + user = UserFactory(username='abc', password='abc') + role = Role.objects.create(name='Administrator', course_id=self.course.id) role.users.set([user]) - self.client.login(username=user.username, password="abc") + self.client.login(username=user.username, password='abc') def _create_divided_discussions(self): """Create some divided discussions for testing.""" - divided_inline_discussions = [ - "Topic A", - ] - divided_course_wide_discussions = [ - "Topic B", - ] - divided_discussions = ( - divided_inline_discussions + divided_course_wide_discussions - ) + divided_inline_discussions = ['Topic A', ] + divided_course_wide_discussions = ['Topic B', ] + divided_discussions = divided_inline_discussions + divided_course_wide_discussions BlockFactory.create( parent=self.course, - category="discussion", - discussion_id=topic_name_to_id(self.course, "Topic A"), - discussion_category="Chapter", - discussion_target="Discussion", - start=datetime.now(), + category='discussion', + discussion_id=topic_name_to_id(self.course, 'Topic A'), + discussion_category='Chapter', + discussion_target='Discussion', + start=datetime.now() ) discussion_topics = { "Topic B": {"id": "Topic B"}, @@ -1611,36 +1440,31 @@ def _create_divided_discussions(self): config_course_discussions( self.course, discussion_topics=discussion_topics, - divided_discussions=divided_discussions, + divided_discussions=divided_discussions ) return divided_inline_discussions, divided_course_wide_discussions def _get_expected_response(self): """Return the default expected response before any changes to the discussion settings.""" return { - "always_divide_inline_discussions": False, - "divided_inline_discussions": [], - "divided_course_wide_discussions": [], - "id": 1, - "division_scheme": "cohort", - "available_division_schemes": ["cohort"], - "reported_content_email_notifications": False, + 'always_divide_inline_discussions': False, + 'divided_inline_discussions': [], + 'divided_course_wide_discussions': [], + 'id': 1, + 'division_scheme': 'cohort', + 'available_division_schemes': ['cohort'], + 'reported_content_email_notifications': False, } def patch_request(self, data, headers=None): headers = headers if headers else {} - return self.client.patch( - self.path, - json.dumps(data), - content_type="application/merge-patch+json", - **headers, - ) + return self.client.patch(self.path, json.dumps(data), content_type='application/merge-patch+json', **headers) def _assert_current_settings(self, expected_response): """Validate the current discussion settings against the expected response.""" response = self.client.get(self.path) assert response.status_code == 200 - content = json.loads(response.content.decode("utf-8")) + content = json.loads(response.content.decode('utf-8')) assert content == expected_response def _assert_patched_settings(self, data, expected_response): @@ -1649,7 +1473,7 @@ def _assert_patched_settings(self, data, expected_response): assert response.status_code == 204 self._assert_current_settings(expected_response) - @ddt.data("get", "patch") + @ddt.data('get', 'patch') def test_authentication_required(self, method): """Test and verify that authentication is required for this endpoint.""" self.client.logout() @@ -1657,8 +1481,8 @@ def test_authentication_required(self, method): assert response.status_code == 401 @ddt.data( - {"is_staff": False, "get_status": 403, "put_status": 403}, - {"is_staff": True, "get_status": 200, "put_status": 204}, + {'is_staff': False, 'get_status': 403, 'put_status': 403}, + {'is_staff': True, 'get_status': 200, 'put_status': 204}, ) @ddt.unpack def test_oauth(self, is_staff, get_status, put_status): @@ -1671,7 +1495,7 @@ def test_oauth(self, is_staff, get_status, put_status): assert response.status_code == get_status response = self.patch_request( - {"always_divide_inline_discussions": True}, headers + {'always_divide_inline_discussions': True}, headers ) assert response.status_code == put_status @@ -1679,68 +1503,66 @@ def test_non_existent_course_id(self): """Test the response when this endpoint is passed a non-existent course id.""" self._login_as_staff() response = self.client.get( - reverse( - "discussion_course_settings", kwargs={"course_id": "course-v1:a+b+c"} - ) + reverse('discussion_course_settings', kwargs={ + 'course_id': 'course-v1:a+b+c' + }) ) assert response.status_code == 404 def test_patch_request_by_discussion_staff(self): """Test the response when patch request is sent by a user with discussions staff role.""" self._login_as_discussion_staff() - response = self.patch_request({"always_divide_inline_discussions": True}) + response = self.patch_request( + {'always_divide_inline_discussions': True} + ) assert response.status_code == 403 def test_get_request_by_discussion_staff(self): """Test the response when get request is sent by a user with discussions staff role.""" self._login_as_discussion_staff() - divided_inline_discussions, divided_course_wide_discussions = ( - self._create_divided_discussions() - ) + divided_inline_discussions, divided_course_wide_discussions = self._create_divided_discussions() response = self.client.get(self.path) assert response.status_code == 200 expected_response = self._get_expected_response() - expected_response["divided_course_wide_discussions"] = [ - topic_name_to_id(self.course, name) - for name in divided_course_wide_discussions + expected_response['divided_course_wide_discussions'] = [ + topic_name_to_id(self.course, name) for name in divided_course_wide_discussions ] - expected_response["divided_inline_discussions"] = [ + expected_response['divided_inline_discussions'] = [ topic_name_to_id(self.course, name) for name in divided_inline_discussions ] - content = json.loads(response.content.decode("utf-8")) + content = json.loads(response.content.decode('utf-8')) assert content == expected_response def test_get_request_by_non_staff_user(self): """Test the response when get request is sent by a regular user with no staff role.""" - user = UserFactory(username="abc", password="abc") - self.client.login(username=user.username, password="abc") + user = UserFactory(username='abc', password='abc') + self.client.login(username=user.username, password='abc') response = self.client.get(self.path) assert response.status_code == 403 def test_patch_request_by_non_staff_user(self): """Test the response when patch request is sent by a regular user with no staff role.""" - user = UserFactory(username="abc", password="abc") - self.client.login(username=user.username, password="abc") - response = self.patch_request({"always_divide_inline_discussions": True}) + user = UserFactory(username='abc', password='abc') + self.client.login(username=user.username, password='abc') + response = self.patch_request( + {'always_divide_inline_discussions': True} + ) assert response.status_code == 403 def test_get_settings(self): """Test the current discussion settings against the expected response.""" - divided_inline_discussions, divided_course_wide_discussions = ( - self._create_divided_discussions() - ) + divided_inline_discussions, divided_course_wide_discussions = self._create_divided_discussions() self._login_as_staff() response = self.client.get(self.path) assert response.status_code == 200 expected_response = self._get_expected_response() - expected_response["divided_course_wide_discussions"] = [ - topic_name_to_id(self.course, name) - for name in divided_course_wide_discussions + expected_response['divided_course_wide_discussions'] = [ + topic_name_to_id(self.course, name) for name in divided_course_wide_discussions ] - expected_response["divided_inline_discussions"] = [ + expected_response['divided_inline_discussions'] = [ topic_name_to_id(self.course, name) for name in divided_inline_discussions ] - content = json.loads(response.content.decode("utf-8")) + content = json.loads(response.content.decode('utf-8')) assert content == expected_response def test_available_schemes(self): @@ -1748,23 +1570,18 @@ def test_available_schemes(self): config_course_cohorts(self.course, is_cohorted=False) self._login_as_staff() expected_response = self._get_expected_response() - expected_response["available_division_schemes"] = [] + expected_response['available_division_schemes'] = [] self._assert_current_settings(expected_response) CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT) - CourseModeFactory.create( - course_id=self.course.id, mode_slug=CourseMode.VERIFIED - ) + CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED) - expected_response["available_division_schemes"] = [ - CourseDiscussionSettings.ENROLLMENT_TRACK - ] + expected_response['available_division_schemes'] = [CourseDiscussionSettings.ENROLLMENT_TRACK] self._assert_current_settings(expected_response) config_course_cohorts(self.course, is_cohorted=True) - expected_response["available_division_schemes"] = [ - CourseDiscussionSettings.COHORT, - CourseDiscussionSettings.ENROLLMENT_TRACK, + expected_response['available_division_schemes'] = [ + CourseDiscussionSettings.COHORT, CourseDiscussionSettings.ENROLLMENT_TRACK ] self._assert_current_settings(expected_response) @@ -1778,11 +1595,11 @@ def test_empty_body_patch_request(self): assert response.status_code == 400 @ddt.data( - {"abc": 123}, - {"divided_course_wide_discussions": 3}, - {"divided_inline_discussions": "a"}, - {"always_divide_inline_discussions": ["a"]}, - {"division_scheme": True}, + {'abc': 123}, + {'divided_course_wide_discussions': 3}, + {'divided_inline_discussions': 'a'}, + {'always_divide_inline_discussions': ['a']}, + {'division_scheme': True} ) def test_invalid_body_parameters(self, body): """Test the response status code on sending a PATCH request with parameters having incorrect types.""" @@ -1796,34 +1613,31 @@ def test_update_always_divide_inline_discussion_settings(self): self._login_as_staff() expected_response = self._get_expected_response() self._assert_current_settings(expected_response) - expected_response["always_divide_inline_discussions"] = True + expected_response['always_divide_inline_discussions'] = True - self._assert_patched_settings( - {"always_divide_inline_discussions": True}, expected_response - ) + self._assert_patched_settings({'always_divide_inline_discussions': True}, expected_response) def test_update_course_wide_discussion_settings(self): """Test whether the 'divided_course_wide_discussions' setting is updated.""" - discussion_topics = {"Topic B": {"id": "Topic B"}} + discussion_topics = { + 'Topic B': {'id': 'Topic B'} + } config_course_cohorts(self.course, is_cohorted=True) config_course_discussions(self.course, discussion_topics=discussion_topics) expected_response = self._get_expected_response() self._login_as_staff() self._assert_current_settings(expected_response) - expected_response["divided_course_wide_discussions"] = [ + expected_response['divided_course_wide_discussions'] = [ topic_name_to_id(self.course, "Topic B") ] self._assert_patched_settings( - { - "divided_course_wide_discussions": [ - topic_name_to_id(self.course, "Topic B") - ] - }, - expected_response, + {'divided_course_wide_discussions': [topic_name_to_id(self.course, "Topic B")]}, + expected_response ) - expected_response["divided_course_wide_discussions"] = [] + expected_response['divided_course_wide_discussions'] = [] self._assert_patched_settings( - {"divided_course_wide_discussions": []}, expected_response + {'divided_course_wide_discussions': []}, + expected_response ) def test_update_inline_discussion_settings(self): @@ -1836,23 +1650,17 @@ def test_update_inline_discussion_settings(self): now = datetime.now() BlockFactory.create( parent_location=self.course.location, - category="discussion", - discussion_id="Topic_A", - discussion_category="Chapter", - discussion_target="Discussion", - start=now, - ) - expected_response["divided_inline_discussions"] = [ - "Topic_A", - ] - self._assert_patched_settings( - {"divided_inline_discussions": ["Topic_A"]}, expected_response + category='discussion', + discussion_id='Topic_A', + discussion_category='Chapter', + discussion_target='Discussion', + start=now ) + expected_response['divided_inline_discussions'] = ['Topic_A', ] + self._assert_patched_settings({'divided_inline_discussions': ['Topic_A']}, expected_response) - expected_response["divided_inline_discussions"] = [] - self._assert_patched_settings( - {"divided_inline_discussions": []}, expected_response - ) + expected_response['divided_inline_discussions'] = [] + self._assert_patched_settings({'divided_inline_discussions': []}, expected_response) def test_update_division_scheme(self): """Test whether the 'division_scheme' setting is updated.""" @@ -1860,17 +1668,15 @@ def test_update_division_scheme(self): self._login_as_staff() expected_response = self._get_expected_response() self._assert_current_settings(expected_response) - expected_response["division_scheme"] = "none" - self._assert_patched_settings({"division_scheme": "none"}, expected_response) + expected_response['division_scheme'] = 'none' + self._assert_patched_settings({'division_scheme': 'none'}, expected_response) def test_update_reported_content_email_notifications(self): """Test whether the 'reported_content_email_notifications' setting is updated.""" config_course_cohorts(self.course, is_cohorted=True) - config_course_discussions( - self.course, reported_content_email_notifications=True - ) + config_course_discussions(self.course, reported_content_email_notifications=True) expected_response = self._get_expected_response() - expected_response["reported_content_email_notifications"] = True + expected_response['reported_content_email_notifications'] = True self._login_as_staff() self._assert_current_settings(expected_response) @@ -1880,15 +1686,12 @@ class CourseDiscussionRolesAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTe """ Test the course discussion roles management endpoint. """ - - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -1899,27 +1702,26 @@ def setUp(self): start=datetime.now(UTC), ) self.password = self.TEST_PASSWORD - self.user = UserFactory(username="staff", password=self.password, is_staff=True) - course_key = CourseKey.from_string("course-v1:x+y+z") + self.user = UserFactory(username='staff', password=self.password, is_staff=True) + course_key = CourseKey.from_string('course-v1:x+y+z') seed_permissions_roles(course_key) - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def path(self, course_id=None, role=None): """Return the URL path to the endpoint based on the provided arguments.""" course_id = str(self.course.id) if course_id is None else course_id - role = "Moderator" if role is None else role + role = 'Moderator' if role is None else role return reverse( - "discussion_course_roles", kwargs={"course_id": course_id, "rolename": role} + 'discussion_course_roles', + kwargs={'course_id': course_id, 'rolename': role} ) def _get_oauth_headers(self, user): """Return the OAuth headers for testing OAuth authentication.""" - access_token = AccessTokenFactory.create( - user=user, application=ApplicationFactory() - ).token - headers = {"HTTP_AUTHORIZATION": "Bearer " + access_token} + access_token = AccessTokenFactory.create(user=user, application=ApplicationFactory()).token + headers = { + 'HTTP_AUTHORIZATION': 'Bearer ' + access_token + } return headers def _login_as_staff(self): @@ -1944,11 +1746,9 @@ def _add_users_to_role(self, users, rolename): def post(self, role, user_id, action): """Make a POST request to the endpoint using the provided parameters.""" self._login_as_staff() - return self.client.post( - self.path(role=role), {"user_id": user_id, "action": action} - ) + return self.client.post(self.path(role=role), {'user_id': user_id, 'action': action}) - @ddt.data("get", "post") + @ddt.data('get', 'post') def test_authentication_required(self, method): """Test and verify that authentication is required for this endpoint.""" self.client.logout() @@ -1961,31 +1761,29 @@ def test_oauth(self): self.client.logout() response = self.client.get(self.path(), **oauth_headers) assert response.status_code == 200 - body = {"user_id": "staff", "action": "allow"} - response = self.client.post(self.path(), body, format="json", **oauth_headers) + body = {'user_id': 'staff', 'action': 'allow'} + response = self.client.post(self.path(), body, format='json', **oauth_headers) assert response.status_code == 200 @ddt.data( - {"username": "u1", "is_staff": False, "expected_status": 403}, - {"username": "u2", "is_staff": True, "expected_status": 200}, + {'username': 'u1', 'is_staff': False, 'expected_status': 403}, + {'username': 'u2', 'is_staff': True, 'expected_status': 200}, ) @ddt.unpack def test_staff_permission_required(self, username, is_staff, expected_status): """Test and verify that only users with staff permission can access this endpoint.""" - UserFactory(username=username, password="edx", is_staff=is_staff) - self.client.login(username=username, password="edx") + UserFactory(username=username, password='edx', is_staff=is_staff) + self.client.login(username=username, password='edx') response = self.client.get(self.path()) assert response.status_code == expected_status - response = self.client.post( - self.path(), {"user_id": username, "action": "allow"}, format="json" - ) + response = self.client.post(self.path(), {'user_id': username, 'action': 'allow'}, format='json') assert response.status_code == expected_status def test_non_existent_course_id(self): """Test the response when the endpoint URL contains a non-existent course id.""" self._login_as_staff() - path = self.path(course_id="course-v1:a+b+c") + path = self.path(course_id='course-v1:a+b+c') response = self.client.get(path) assert response.status_code == 404 @@ -1996,7 +1794,7 @@ def test_non_existent_course_id(self): def test_non_existent_course_role(self): """Test the response when the endpoint URL contains a non-existent role.""" self._login_as_staff() - path = self.path(role="A") + path = self.path(role='A') response = self.client.get(path) assert response.status_code == 400 @@ -2005,10 +1803,10 @@ def test_non_existent_course_role(self): assert response.status_code == 400 @ddt.data( - {"role": "Moderator", "count": 0}, - {"role": "Moderator", "count": 1}, - {"role": "Group Moderator", "count": 2}, - {"role": "Community TA", "count": 3}, + {'role': 'Moderator', 'count': 0}, + {'role': 'Moderator', 'count': 1}, + {'role': 'Group Moderator', 'count': 2}, + {'role': 'Community TA', 'count': 3}, ) @ddt.unpack def test_get_role_members(self, role, count): @@ -2022,14 +1820,14 @@ def test_get_role_members(self, role, count): assert response.status_code == 200 - content = json.loads(response.content.decode("utf-8")) - assert content["course_id"] == "course-v1:x+y+z" - assert len(content["results"]) == count - expected_fields = ("username", "email", "first_name", "last_name", "group_name") - for item in content["results"]: + content = json.loads(response.content.decode('utf-8')) + assert content['course_id'] == 'course-v1:x+y+z' + assert len(content['results']) == count + expected_fields = ('username', 'email', 'first_name', 'last_name', 'group_name') + for item in content['results']: for expected_field in expected_fields: assert expected_field in item - assert content["division_scheme"] == "cohort" + assert content['division_scheme'] == 'cohort' def test_post_missing_body(self): """Test the response with a POST request without a body.""" @@ -2038,9 +1836,9 @@ def test_post_missing_body(self): assert response.status_code == 400 @ddt.data( - {"a": 1}, - {"user_id": "xyz", "action": "allow"}, - {"user_id": "staff", "action": 123}, + {'a': 1}, + {'user_id': 'xyz', 'action': 'allow'}, + {'user_id': 'staff', 'action': 123}, ) def test_missing_or_invalid_parameters(self, body): """ @@ -2051,100 +1849,82 @@ def test_missing_or_invalid_parameters(self, body): response = self.client.post(self.path(), body) assert response.status_code == 400 - response = self.client.post(self.path(), body, format="json") + response = self.client.post(self.path(), body, format='json') assert response.status_code == 400 @ddt.data( - {"action": "allow", "user_in_role": False}, - {"action": "allow", "user_in_role": True}, - {"action": "revoke", "user_in_role": False}, - {"action": "revoke", "user_in_role": True}, + {'action': 'allow', 'user_in_role': False}, + {'action': 'allow', 'user_in_role': True}, + {'action': 'revoke', 'user_in_role': False}, + {'action': 'revoke', 'user_in_role': True} ) @ddt.unpack def test_post_update_user_role(self, action, user_in_role): """Test the response when updating the user's role""" users = self._create_and_enroll_users(count=1) user = users[0] - role = "Moderator" + role = 'Moderator' if user_in_role: self._add_users_to_role(users, role) response = self.post(role, user.username, action) assert response.status_code == 200 - content = json.loads(response.content.decode("utf-8")) - assertion = self.assertTrue if action == "allow" else self.assertFalse - assertion(any(user.username in x["username"] for x in content["results"])) + content = json.loads(response.content.decode('utf-8')) + assertion = self.assertTrue if action == 'allow' else self.assertFalse + assertion(any(user.username in x['username'] for x in content['results'])) @ddt.ddt @httpretty.activate @override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True) -class CourseActivityStatsTest( - ForumsEnableMixin, - UrlResetMixin, - CommentsServiceMockMixin, - APITestCase, - SharedModuleStoreTestCase, -): +class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceMockMixin, APITestCase, + SharedModuleStoreTestCase): """ Tests for the course stats endpoint """ - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self) -> None: super().setUp() patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) self.course = CourseFactory.create() self.course_key = str(self.course.id) seed_permissions_roles(self.course.id) - self.user = UserFactory(username="user") - self.moderator = UserFactory(username="moderator") + self.user = UserFactory(username='user') + self.moderator = UserFactory(username='moderator') moderator_role = Role.objects.get(name="Moderator", course_id=self.course.id) moderator_role.users.add(self.moderator) self.stats = [ { - "threads": random.randint(0, 10), - "replies": random.randint(0, 30), - "responses": random.randint(0, 100), - "deleted_threads": 0, - "deleted_replies": 0, - "deleted_responses": 0, "active_flags": random.randint(0, 3), "inactive_flags": random.randint(0, 2), - "username": f"user-{idx}", + "replies": random.randint(0, 30), + "responses": random.randint(0, 100), + "threads": random.randint(0, 10), + "username": f"user-{idx}" } for idx in range(10) ] for stat in self.stats: user = UserFactory.create( - username=stat["username"], + username=stat['username'], email=f"{stat['username']}@example.com", - password=self.TEST_PASSWORD, + password=self.TEST_PASSWORD ) - CourseEnrollment.enroll(user, self.course.id, mode="audit") + CourseEnrollment.enroll(user, self.course.id, mode='audit') - CourseEnrollment.enroll(self.moderator, self.course.id, mode="audit") - self.stats_without_flags = [ - {**stat, "active_flags": None, "inactive_flags": None} - for stat in self.stats - ] + CourseEnrollment.enroll(self.moderator, self.course.id, mode='audit') + self.stats_without_flags = [{**stat, "active_flags": None, "inactive_flags": None} for stat in self.stats] self.register_course_stats_response(self.course_key, self.stats, 1, 3) - self.url = reverse( - "discussion_course_activity_stats", - kwargs={"course_key_string": self.course_key}, - ) + self.url = reverse("discussion_course_activity_stats", kwargs={"course_key_string": self.course_key}) - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def test_regular_user(self): """ Tests that for a regular user stats are returned without flag counts @@ -2154,9 +1934,7 @@ def test_regular_user(self): data = response.json() assert data["results"] == self.stats_without_flags - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def test_moderator_user(self): """ Tests that for a moderator user stats are returned with flag counts @@ -2176,9 +1954,7 @@ def test_moderator_user(self): ("user", "recency", "recency"), ) @ddt.unpack - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def test_sorting(self, username, ordering_requested, ordering_performed): """ Test valid sorting options and defaults @@ -2188,22 +1964,15 @@ def test_sorting(self, username, ordering_requested, ordering_performed): if ordering_requested: params = {"order_by": ordering_requested} self.client.get(self.url, params) - assert ( - urlparse( - httpretty.last_request().path # lint-amnesty, pylint: disable=no-member - ).path - == f"/api/v1/users/{self.course_key}/stats" - ) + assert urlparse( + httpretty.last_request().path # lint-amnesty, pylint: disable=no-member + ).path == f"/api/v1/users/{self.course_key}/stats" assert parse_qs( - urlparse( - httpretty.last_request().path - ).query # lint-amnesty, pylint: disable=no-member + urlparse(httpretty.last_request().path).query # lint-amnesty, pylint: disable=no-member ).get("sort_key", None) == [ordering_performed] @ddt.data("flagged", "xyz") - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def test_sorting_error_regular_user(self, order_by): """ Test for invalid sorting options for regular users. @@ -2213,60 +1982,47 @@ def test_sorting_error_regular_user(self, order_by): assert "order_by" in response.json()["field_errors"] @ddt.data( - ( - "user", - "user-0,user-1,user-2,user-3,user-4,user-5,user-6,user-7,user-8,user-9", - ), - ("moderator", "moderator"), + ('user', 'user-0,user-1,user-2,user-3,user-4,user-5,user-6,user-7,user-8,user-9'), + ('moderator', 'moderator'), ) @ddt.unpack - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) - def test_with_username_param( - self, username_search_string, comma_separated_usernames - ): + @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True}) + def test_with_username_param(self, username_search_string, comma_separated_usernames): """ Test for endpoint with username param. """ - params = {"username": username_search_string} + params = {'username': username_search_string} self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD) self.client.get(self.url, params) - assert ( - urlparse( - httpretty.last_request().path # lint-amnesty, pylint: disable=no-member - ).path - == f"/api/v1/users/{self.course_key}/stats" - ) + assert urlparse( + httpretty.last_request().path # lint-amnesty, pylint: disable=no-member + ).path == f'/api/v1/users/{self.course_key}/stats' assert parse_qs( - urlparse( - httpretty.last_request().path - ).query # lint-amnesty, pylint: disable=no-member - ).get("usernames", [None]) == [comma_separated_usernames] + urlparse(httpretty.last_request().path).query # lint-amnesty, pylint: disable=no-member + ).get('usernames', [None]) == [comma_separated_usernames] - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True}) def test_with_username_param_with_no_matches(self): """ Test for endpoint with username param with no matches. """ - params = {"username": "unknown"} + params = {'username': 'unknown'} self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD) response = self.client.get(self.url, params) data = response.json() - self.assertFalse(data["results"]) - assert data["pagination"]["count"] == 0 + self.assertFalse(data['results']) + assert data['pagination']['count'] == 0 - @ddt.data("user-0", "USER-1", "User-2", "UsEr-3") - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + @ddt.data( + 'user-0', + 'USER-1', + 'User-2', + 'UsEr-3' ) + @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True}) def test_with_username_param_case(self, username_search_string): """ Test user search function is case-insensitive. """ - response = get_usernames_from_search_string( - self.course_key, username_search_string, 1, 1 - ) + response = get_usernames_from_search_string(self.course_key, username_search_string, 1, 1) assert response == (username_search_string.lower(), 1, 1) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py index 39e48dd41ff9..431304a9a2b5 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py @@ -14,6 +14,8 @@ from unittest import mock import ddt +from forum.backends.mongodb.comments import Comment +from forum.backends.mongodb.threads import CommentThread import httpretty from django.urls import reverse from pytz import UTC @@ -21,39 +23,30 @@ from rest_framework.parsers import JSONParser from rest_framework.test import APIClient +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory from common.djangoapps.student.tests.factories import ( CourseEnrollmentFactory, UserFactory, ) from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin from common.test.utils import disable_signal -from forum.backends.mongodb.comments import Comment -from forum.backends.mongodb.threads import CommentThread -from lms.djangoapps.discussion.django_comment_client.tests.utils import ( - ForumsEnableMixin, +from lms.djangoapps.discussion.tests.utils import ( + make_minimal_cs_comment, + make_minimal_cs_thread, ) +from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin from lms.djangoapps.discussion.rest_api import api from lms.djangoapps.discussion.rest_api.tests.utils import ( ForumMockUtilsMixin, ProfileImageTestMixin, make_paginated_api_response, ) -from lms.djangoapps.discussion.tests.utils import ( - make_minimal_cs_comment, - make_minimal_cs_thread, -) from openedx.core.djangoapps.django_comment_common.models import ( - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_STUDENT, - assign_role, + FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR, FORUM_ROLE_STUDENT, + assign_role ) -from openedx.core.djangoapps.user_api.accounts.image_helpers import ( - get_profile_image_storage, -) -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory +from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlResetMixin): @@ -394,10 +387,6 @@ def expected_response_data(self, overrides=None): "image_url_small": "http://testserver/static/default_30.png", }, "learner_status": "new", - "is_deleted": None, - "deleted_at": None, - "deleted_by": None, - "deleted_by_label": None, } response_data.update(overrides or {}) return response_data @@ -523,17 +512,15 @@ def test_course_id_missing(self): self.assert_response_correct( response, 400, - { - "field_errors": { - "course_id": {"developer_message": "This field is required."} - } - }, + {"field_errors": {"course_id": {"developer_message": "This field is required."}}} ) def test_404(self): response = self.client.get(self.url, {"course_id": "non/existent/course"}) self.assert_response_correct( - response, 404, {"developer_message": "Course not found."} + response, + 404, + {"developer_message": "Course not found."} ) def test_basic(self): @@ -884,9 +871,7 @@ class BulkDeleteUserPostsTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): def setUp(self): super().setUp() - self.url = reverse( - "bulk_delete_user_posts", kwargs={"course_id": str(self.course.id)} - ) + self.url = reverse("bulk_delete_user_posts", kwargs={"course_id": str(self.course.id)}) self.user2 = UserFactory.create(password=self.password) CourseEnrollmentFactory.create(user=self.user2, course_id=self.course.id) @@ -902,19 +887,13 @@ def mock_comment_and_thread_count(self, comment_count=1, thread_count=1): thread_collection = mock.MagicMock() thread_collection.count_documents.return_value = thread_count patch_thread = mock.patch.object( - CommentThread, - "_collection", - new_callable=mock.PropertyMock, - return_value=thread_collection, + CommentThread, "_collection", new_callable=mock.PropertyMock, return_value=thread_collection ) comment_collection = mock.MagicMock() comment_collection.count_documents.return_value = comment_count patch_comment = mock.patch.object( - Comment, - "_collection", - new_callable=mock.PropertyMock, - return_value=comment_collection, + Comment, "_collection", new_callable=mock.PropertyMock, return_value=comment_collection ) thread_mock = patch_thread.start() @@ -929,9 +908,7 @@ def test_bulk_delete_denied_for_discussion_roles(self, role): """ Test bulk delete user posts denied with discussion roles. """ - thread_mock, comment_mock = self.mock_comment_and_thread_count( - comment_count=1, thread_count=1 - ) + thread_mock, comment_mock = self.mock_comment_and_thread_count(comment_count=1, thread_count=1) assign_role(self.course.id, self.user, role) response = self.client.post( f"{self.url}?username={self.user2.username}", @@ -955,9 +932,7 @@ def test_bulk_delete_allowed_for_discussion_roles(self, role): assert response.status_code == status.HTTP_202_ACCEPTED assert response.json() == {"comment_count": 1, "thread_count": 1} - @mock.patch( - "lms.djangoapps.discussion.rest_api.views.delete_course_post_for_user.apply_async" - ) + @mock.patch('lms.djangoapps.discussion.rest_api.views.delete_course_post_for_user.apply_async') @ddt.data(True, False) def test_task_only_runs_if_execute_param_is_true(self, execute, task_mock): """ diff --git a/lms/djangoapps/discussion/rest_api/tests/utils.py b/lms/djangoapps/discussion/rest_api/tests/utils.py index 990e68a30af9..8c1615690ad5 100644 --- a/lms/djangoapps/discussion/rest_api/tests/utils.py +++ b/lms/djangoapps/discussion/rest_api/tests/utils.py @@ -2,6 +2,7 @@ Discussion API test utilities """ + import hashlib import json import re @@ -13,18 +14,11 @@ from PIL import Image from pytz import UTC -from lms.djangoapps.discussion.django_comment_client.tests.mixins import ( - MockForumApiMixin, -) -from openedx.core.djangoapps.django_comment_common.comment_client.utils import ( - CommentClientRequestError, -) +from lms.djangoapps.discussion.django_comment_client.tests.mixins import MockForumApiMixin +from openedx.core.djangoapps.django_comment_common.comment_client.utils import CommentClientRequestError from openedx.core.djangoapps.profile_images.images import create_profile_images from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file -from openedx.core.djangoapps.user_api.accounts.image_helpers import ( - get_profile_image_names, - set_has_profile_image, -) +from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image def _get_thread_callback(thread_data): @@ -32,7 +26,6 @@ def _get_thread_callback(thread_data): Get a callback function that will return POST/PUT data overridden by response_overrides. """ - def callback(request, _uri, headers): """ Simulate the thread creation or update endpoint by returning the provided @@ -49,7 +42,7 @@ def callback(request, _uri, headers): response_data["edit_history"] = [ { "original_body": original_data["body"], - "author": thread_data.get("username"), + "author": thread_data.get('username'), "reason_code": val, }, ] @@ -75,13 +68,11 @@ def callback(*args, **kwargs): if key in ["anonymous", "anonymous_to_peers", "closed", "pinned"]: response_data[key] = val is True or val == "True" elif key == "edit_reason_code": - response_data["edit_history"] = [ - { - "original_body": original_data["body"], - "author": thread_data.get("username"), - "reason_code": val, - } - ] + response_data["edit_history"] = [{ + "original_body": original_data["body"], + "author": thread_data.get("username"), + "reason_code": val, + }] else: response_data[key] = val @@ -96,7 +87,6 @@ def _get_comment_callback(comment_data, thread_id, parent_id): plus necessary dummy data, overridden by the content of the POST/PUT request. """ - def callback(request, _uri, headers): """ Simulate the comment creation or update endpoint as described above. @@ -115,7 +105,7 @@ def callback(request, _uri, headers): response_data["edit_history"] = [ { "original_body": original_data["body"], - "author": comment_data.get("username"), + "author": comment_data.get('username'), "reason_code": val, }, ] @@ -145,13 +135,11 @@ def callback(*args, **kwargs): if key in ["anonymous", "anonymous_to_peers", "endorsed"]: response_data[key] = val is True or val == "True" elif key == "edit_reason_code": - response_data["edit_history"] = [ - { - "original_body": original_data["body"], - "author": comment_data.get("username"), - "reason_code": val, - } - ] + response_data["edit_history"] = [{ + "original_body": original_data["body"], + "author": comment_data.get("username"), + "reason_code": val, + }] else: response_data[key] = val @@ -164,11 +152,9 @@ def make_user_callbacks(user_map): """ Returns a callable that mimics user creation. """ - def callback(*args, **kwargs): - user_id = args[0] if args else kwargs.get("user_id") + user_id = args[0] if args else kwargs.get('user_id') return user_map[str(user_id)] - return callback @@ -177,58 +163,54 @@ class CommentsServiceMockMixin: def register_get_threads_response(self, threads, page, num_pages): """Register a mock response for GET on the CS thread list endpoint""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, "http://localhost:4567/api/v1/threads", - body=json.dumps( - { - "collection": threads, - "page": page, - "num_pages": num_pages, - "thread_count": len(threads), - } - ), - status=200, + body=json.dumps({ + "collection": threads, + "page": page, + "num_pages": num_pages, + "thread_count": len(threads), + }), + status=200 ) def register_get_course_commentable_counts_response(self, course_id, thread_counts): """Register a mock response for GET on the CS thread list endpoint""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/commentables/{course_id}/counts", body=json.dumps(thread_counts), - status=200, + status=200 ) def register_get_threads_search_response(self, threads, rewrite, num_pages=1): """Register a mock response for GET on the CS thread search endpoint""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, "http://localhost:4567/api/v1/search/threads", - body=json.dumps( - { - "collection": threads, - "page": 1, - "num_pages": num_pages, - "corrected_text": rewrite, - "thread_count": len(threads), - } - ), - status=200, + body=json.dumps({ + "collection": threads, + "page": 1, + "num_pages": num_pages, + "corrected_text": rewrite, + "thread_count": len(threads), + }), + status=200 ) def register_post_thread_response(self, thread_data): """Register a mock response for POST on the CS commentable endpoint""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.POST, re.compile(r"http://localhost:4567/api/v1/(\w+)/threads"), - body=_get_thread_callback(thread_data), + body=_get_thread_callback(thread_data) ) def register_put_thread_response(self, thread_data): @@ -236,51 +218,49 @@ def register_put_thread_response(self, thread_data): Register a mock response for PUT on the CS endpoint for the given thread_id. """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.PUT, "http://localhost:4567/api/v1/threads/{}".format(thread_data["id"]), - body=_get_thread_callback(thread_data), + body=_get_thread_callback(thread_data) ) def register_get_thread_error_response(self, thread_id, status_code): """Register a mock error response for GET on the CS thread endpoint.""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/threads/{thread_id}", body="", - status=status_code, + status=status_code ) def register_get_thread_response(self, thread): """ Register a mock response for GET on the CS thread instance endpoint. """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, "http://localhost:4567/api/v1/threads/{id}".format(id=thread["id"]), body=json.dumps(thread), - status=200, + status=200 ) def register_get_comments_response(self, comments, page, num_pages): """Register a mock response for GET on the CS comments list endpoint""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, "http://localhost:4567/api/v1/comments", - body=json.dumps( - { - "collection": comments, - "page": page, - "num_pages": num_pages, - "comment_count": len(comments), - } - ), - status=200, + body=json.dumps({ + "collection": comments, + "page": page, + "num_pages": num_pages, + "comment_count": len(comments), + }), + status=200 ) def register_post_comment_response(self, comment_data, thread_id, parent_id=None): @@ -294,11 +274,11 @@ def register_post_comment_response(self, comment_data, thread_id, parent_id=None else: url = f"http://localhost:4567/api/v1/threads/{thread_id}/comments" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.POST, url, - body=_get_comment_callback(comment_data, thread_id, parent_id), + body=_get_comment_callback(comment_data, thread_id, parent_id) ) def register_put_comment_response(self, comment_data): @@ -308,11 +288,11 @@ def register_put_comment_response(self, comment_data): """ thread_id = comment_data["thread_id"] parent_id = comment_data.get("parent_id") - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.PUT, "http://localhost:4567/api/v1/comments/{}".format(comment_data["id"]), - body=_get_comment_callback(comment_data, thread_id, parent_id), + body=_get_comment_callback(comment_data, thread_id, parent_id) ) def register_get_comment_error_response(self, comment_id, status_code): @@ -320,12 +300,12 @@ def register_get_comment_error_response(self, comment_id, status_code): Register a mock error response for GET on the CS comment instance endpoint. """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/comments/{comment_id}", body="", - status=status_code, + status=status_code ) def register_get_comment_response(self, response_overrides): @@ -333,83 +313,75 @@ def register_get_comment_response(self, response_overrides): Register a mock response for GET on the CS comment instance endpoint. """ comment = make_minimal_cs_comment(response_overrides) - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, "http://localhost:4567/api/v1/comments/{id}".format(id=comment["id"]), body=json.dumps(comment), - status=200, + status=200 ) - def register_get_user_response( - self, user, subscribed_thread_ids=None, upvoted_ids=None - ): + def register_get_user_response(self, user, subscribed_thread_ids=None, upvoted_ids=None): """Register a mock response for GET on the CS user instance endpoint""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/users/{user.id}", - body=json.dumps( - { - "id": str(user.id), - "subscribed_thread_ids": subscribed_thread_ids or [], - "upvoted_ids": upvoted_ids or [], - } - ), - status=200, + body=json.dumps({ + "id": str(user.id), + "subscribed_thread_ids": subscribed_thread_ids or [], + "upvoted_ids": upvoted_ids or [], + }), + status=200 ) def register_get_user_retire_response(self, user, status=200, body=""): """Register a mock response for GET on the CS user retirement endpoint""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.POST, f"http://localhost:4567/api/v1/users/{user.id}/retire", body=body, - status=status, + status=status ) def register_get_username_replacement_response(self, user, status=200, body=""): - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.POST, f"http://localhost:4567/api/v1/users/{user.id}/replace_username", body=body, - status=status, + status=status ) def register_subscribed_threads_response(self, user, threads, page, num_pages): """Register a mock response for GET on the CS user instance endpoint""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/users/{user.id}/subscribed_threads", - body=json.dumps( - { - "collection": threads, - "page": page, - "num_pages": num_pages, - "thread_count": len(threads), - } - ), - status=200, + body=json.dumps({ + "collection": threads, + "page": page, + "num_pages": num_pages, + "thread_count": len(threads), + }), + status=200 ) def register_course_stats_response(self, course_key, stats, page, num_pages): """Register a mock response for GET on the CS user course stats instance endpoint""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/users/{course_key}/stats", - body=json.dumps( - { - "user_stats": stats, - "page": page, - "num_pages": num_pages, - "count": len(stats), - } - ), - status=200, + body=json.dumps({ + "user_stats": stats, + "page": page, + "num_pages": num_pages, + "count": len(stats), + }), + status=200 ) def register_subscription_response(self, user): @@ -417,13 +389,13 @@ def register_subscription_response(self, user): Register a mock response for POST and DELETE on the CS user subscription endpoint """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' for method in [httpretty.POST, httpretty.DELETE]: httpretty.register_uri( method, f"http://localhost:4567/api/v1/users/{user.id}/subscriptions", body=json.dumps({}), # body is unused - status=200, + status=200 ) def register_thread_votes_response(self, thread_id): @@ -431,13 +403,13 @@ def register_thread_votes_response(self, thread_id): Register a mock response for PUT and DELETE on the CS thread votes endpoint """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' for method in [httpretty.PUT, httpretty.DELETE]: httpretty.register_uri( method, f"http://localhost:4567/api/v1/threads/{thread_id}/votes", body=json.dumps({}), # body is unused - status=200, + status=200 ) def register_comment_votes_response(self, comment_id): @@ -445,39 +417,41 @@ def register_comment_votes_response(self, comment_id): Register a mock response for PUT and DELETE on the CS comment votes endpoint """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' for method in [httpretty.PUT, httpretty.DELETE]: httpretty.register_uri( method, f"http://localhost:4567/api/v1/comments/{comment_id}/votes", body=json.dumps({}), # body is unused - status=200, + status=200 ) def register_flag_response(self, content_type, content_id): """Register a mock response for PUT on the CS flag endpoints""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' for path in ["abuse_flag", "abuse_unflag"]: httpretty.register_uri( "PUT", "http://localhost:4567/api/v1/{content_type}s/{content_id}/{path}".format( - content_type=content_type, content_id=content_id, path=path + content_type=content_type, + content_id=content_id, + path=path ), body=json.dumps({}), # body is unused - status=200, + status=200 ) def register_read_response(self, user, content_type, content_id): """ Register a mock response for POST on the CS 'read' endpoint """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.POST, f"http://localhost:4567/api/v1/users/{user.id}/read", - params={"source_type": content_type, "source_id": content_id}, + params={'source_type': content_type, 'source_id': content_id}, body=json.dumps({}), # body is unused - status=200, + status=200 ) def register_thread_flag_response(self, thread_id): @@ -492,48 +466,48 @@ def register_delete_thread_response(self, thread_id): """ Register a mock response for DELETE on the CS thread instance endpoint """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.DELETE, f"http://localhost:4567/api/v1/threads/{thread_id}", body=json.dumps({}), # body is unused - status=200, + status=200 ) def register_delete_comment_response(self, comment_id): """ Register a mock response for DELETE on the CS comment instance endpoint """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.DELETE, f"http://localhost:4567/api/v1/comments/{comment_id}", body=json.dumps({}), # body is unused - status=200, + status=200 ) def register_user_active_threads(self, user_id, response): """ Register a mock response for GET on the CS comment active threads endpoint """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/users/{user_id}/active_threads", body=json.dumps(response), - status=200, + status=200 ) def register_get_subscriptions(self, thread_id, response): """ Register a mock response for GET on the CS comment active threads endpoint """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/threads/{thread_id}/subscriptions", body=json.dumps(response), - status=200, + status=200 ) def assert_query_params_equal(self, httpretty_request, expected_params): @@ -557,7 +531,7 @@ def request_patch(self, request_data): return self.client.patch( self.url, json.dumps(request_data), - content_type="application/merge-patch+json", + content_type="application/merge-patch+json" ) def expected_thread_data(self, overrides=None): @@ -615,10 +589,6 @@ def expected_thread_data(self, overrides=None): "close_reason": None, "close_reason_code": None, "learner_status": "new", - "is_deleted": None, - "deleted_at": None, - "deleted_by": None, - "deleted_by_label": None, } response_data.update(overrides or {}) return response_data @@ -629,153 +599,137 @@ class ForumMockUtilsMixin(MockForumApiMixin): def register_get_threads_response(self, threads, page, num_pages): """Register a mock response for GET on the CS thread list endpoint""" - self.set_mock_return_value( - "get_user_threads", - { - "collection": threads, - "page": page, - "num_pages": num_pages, - "thread_count": len(threads), - }, - ) + self.set_mock_return_value('get_user_threads', { + "collection": threads, + "page": page, + "num_pages": num_pages, + "thread_count": len(threads), + }) def register_get_course_commentable_counts_response(self, course_id, thread_counts): """Register a mock response for GET on the CS thread list endpoint""" - self.set_mock_return_value("get_commentables_stats", thread_counts) + self.set_mock_return_value('get_commentables_stats', thread_counts) def register_get_threads_search_response(self, threads, rewrite, num_pages=1): """Register a mock response for GET on the CS thread search endpoint""" - self.set_mock_return_value( - "search_threads", - { - "collection": threads, - "page": 1, - "num_pages": num_pages, - "corrected_text": rewrite, - "thread_count": len(threads), - }, - ) + self.set_mock_return_value('search_threads', { + "collection": threads, + "page": 1, + "num_pages": num_pages, + "corrected_text": rewrite, + "thread_count": len(threads), + }) def register_post_thread_response(self, thread_data): - self.set_mock_side_effect("create_thread", make_thread_callback(thread_data)) + self.set_mock_side_effect('create_thread', make_thread_callback(thread_data)) def register_put_thread_response(self, thread_data): - self.set_mock_side_effect("update_thread", make_thread_callback(thread_data)) + self.set_mock_side_effect('update_thread', make_thread_callback(thread_data)) def register_get_thread_error_response(self, thread_id, status_code): self.set_mock_side_effect( - "get_thread", - CommentClientRequestError(f"Thread does not exist with Id: {thread_id}"), + 'get_thread', + CommentClientRequestError(f"Thread does not exist with Id: {thread_id}") ) def register_get_thread_response(self, thread): - self.set_mock_return_value("get_thread", thread) + self.set_mock_return_value('get_thread', thread) def register_get_comments_response(self, comments, page, num_pages): - self.set_mock_return_value( - "get_parent_comment", - { - "collection": comments, - "page": page, - "num_pages": num_pages, - "comment_count": len(comments), - }, - ) + self.set_mock_return_value('get_parent_comment', { + "collection": comments, + "page": page, + "num_pages": num_pages, + "comment_count": len(comments), + }) def register_post_comment_response(self, comment_data, thread_id, parent_id=None): self.set_mock_side_effect( - "create_child_comment" if parent_id else "create_parent_comment", - make_comment_callback(comment_data, thread_id, parent_id), + 'create_child_comment' if parent_id else 'create_parent_comment', + make_comment_callback(comment_data, thread_id, parent_id) ) def register_put_comment_response(self, comment_data): thread_id = comment_data["thread_id"] parent_id = comment_data.get("parent_id") self.set_mock_side_effect( - "update_comment", make_comment_callback(comment_data, thread_id, parent_id) + 'update_comment', + make_comment_callback(comment_data, thread_id, parent_id) ) def register_get_comment_error_response(self, comment_id, status_code): self.set_mock_side_effect( - "get_parent_comment", - CommentClientRequestError(f"Comment does not exist with Id: {comment_id}"), + 'get_parent_comment', + CommentClientRequestError(f"Comment does not exist with Id: {comment_id}") ) def register_get_comment_response(self, response_overrides): comment = make_minimal_cs_comment(response_overrides) - self.set_mock_return_value("get_parent_comment", comment) + self.set_mock_return_value('get_parent_comment', comment) - def register_get_user_response( - self, user, subscribed_thread_ids=None, upvoted_ids=None - ): + def register_get_user_response(self, user, subscribed_thread_ids=None, upvoted_ids=None): """Register a mock response for GET on the CS user endpoint""" self.users_map[str(user.id)] = { "id": str(user.id), "subscribed_thread_ids": subscribed_thread_ids or [], "upvoted_ids": upvoted_ids or [], } - self.set_mock_side_effect("get_user", make_user_callbacks(self.users_map)) + self.set_mock_side_effect('get_user', make_user_callbacks(self.users_map)) def register_get_user_retire_response(self, user, body=""): - self.set_mock_return_value("retire_user", body) + self.set_mock_return_value('retire_user', body) def register_get_username_replacement_response(self, user, status=200, body=""): - self.set_mock_return_value("update_username", body) + self.set_mock_return_value('update_username', body) def register_subscribed_threads_response(self, user, threads, page, num_pages): - self.set_mock_return_value( - "get_user_subscriptions", - { - "collection": threads, - "page": page, - "num_pages": num_pages, - "thread_count": len(threads), - }, - ) + self.set_mock_return_value('get_user_subscriptions', { + "collection": threads, + "page": page, + "num_pages": num_pages, + "thread_count": len(threads), + }) def register_course_stats_response(self, course_key, stats, page, num_pages): - self.set_mock_return_value( - "get_user_course_stats", - { - "user_stats": stats, - "page": page, - "num_pages": num_pages, - "count": len(stats), - }, - ) + self.set_mock_return_value('get_user_course_stats', { + "user_stats": stats, + "page": page, + "num_pages": num_pages, + "count": len(stats), + }) def register_subscription_response(self, user): - self.set_mock_return_value("create_subscription", {}) - self.set_mock_return_value("delete_subscription", {}) + self.set_mock_return_value('create_subscription', {}) + self.set_mock_return_value('delete_subscription', {}) def register_thread_votes_response(self, thread_id): - self.set_mock_return_value("update_thread_votes", {}) - self.set_mock_return_value("delete_thread_vote", {}) + self.set_mock_return_value('update_thread_votes', {}) + self.set_mock_return_value('delete_thread_vote', {}) def register_comment_votes_response(self, comment_id): - self.set_mock_return_value("update_comment_votes", {}) - self.set_mock_return_value("delete_comment_vote", {}) + self.set_mock_return_value('update_comment_votes', {}) + self.set_mock_return_value('delete_comment_vote', {}) def register_flag_response(self, content_type, content_id): - if content_type == "thread": - self.set_mock_return_value("update_thread_flag", {}) - elif content_type == "comment": - self.set_mock_return_value("update_comment_flag", {}) + if content_type == 'thread': + self.set_mock_return_value('update_thread_flag', {}) + elif content_type == 'comment': + self.set_mock_return_value('update_comment_flag', {}) def register_read_response(self, user, content_type, content_id): - self.set_mock_return_value("mark_thread_as_read", {}) + self.set_mock_return_value('mark_thread_as_read', {}) def register_delete_thread_response(self, thread_id): - self.set_mock_return_value("delete_thread", {}) + self.set_mock_return_value('delete_thread', {}) def register_delete_comment_response(self, comment_id): - self.set_mock_return_value("delete_comment", {}) + self.set_mock_return_value('delete_comment', {}) def register_user_active_threads(self, user_id, response): - self.set_mock_return_value("get_user_active_threads", response) + self.set_mock_return_value('get_user_active_threads', response) def register_get_subscriptions(self, thread_id, response): - self.set_mock_return_value("get_thread_subscriptions", response) + self.set_mock_return_value('get_thread_subscriptions', response) def register_thread_flag_response(self, thread_id): """Register a mock response for PUT on the CS thread flag endpoints""" @@ -806,7 +760,7 @@ def request_patch(self, request_data): return self.client.patch( self.url, json.dumps(request_data), - content_type="application/merge-patch+json", + content_type="application/merge-patch+json" ) def expected_thread_data(self, overrides=None): @@ -864,10 +818,6 @@ def expected_thread_data(self, overrides=None): "close_reason": None, "close_reason_code": None, "learner_status": "new", - "is_deleted": None, - "deleted_at": None, - "deleted_by": None, - "deleted_by_label": None, } response_data.update(overrides or {}) return response_data @@ -940,9 +890,7 @@ def make_minimal_cs_comment(overrides=None): return ret -def make_paginated_api_response( - results=None, count=0, num_pages=0, next_link=None, previous_link=None -): +def make_paginated_api_response(results=None, count=0, num_pages=0, next_link=None, previous_link=None): """ Generates the response dictionary of paginated APIs with passed data """ @@ -953,7 +901,7 @@ def make_paginated_api_response( "count": count, "num_pages": num_pages, }, - "results": results or [], + "results": results or [] } @@ -971,9 +919,7 @@ def create_profile_image(self, user, storage): with make_image_file() as image_file: create_profile_images(image_file, get_profile_image_names(user.username)) self.check_images(user, storage) - set_has_profile_image( - user.username, True, self.TEST_PROFILE_IMAGE_UPLOADED_AT - ) + set_has_profile_image(user.username, True, self.TEST_PROFILE_IMAGE_UPLOADED_AT) def check_images(self, user, storage, exist=True): """ @@ -987,7 +933,7 @@ def check_images(self, user, storage, exist=True): assert storage.exists(name) with closing(Image.open(storage.path(name))) as img: assert img.size == (size, size) - assert img.format == "JPEG" + assert img.format == 'JPEG' else: assert not storage.exists(name) @@ -995,18 +941,18 @@ def get_expected_user_profile(self, username): """ Returns the expected user profile data for a given username """ - url = "http://example-storage.com/profile-images/{filename}_{{size}}.jpg?v={timestamp}".format( - filename=hashlib.md5(b"secret" + username.encode("utf-8")).hexdigest(), - timestamp=self.TEST_PROFILE_IMAGE_UPLOADED_AT.strftime("%s"), + url = 'http://example-storage.com/profile-images/{filename}_{{size}}.jpg?v={timestamp}'.format( + filename=hashlib.md5(b'secret' + username.encode('utf-8')).hexdigest(), + timestamp=self.TEST_PROFILE_IMAGE_UPLOADED_AT.strftime("%s") ) return { - "profile": { - "image": { - "has_image": True, - "image_url_full": url.format(size=500), - "image_url_large": url.format(size=120), - "image_url_medium": url.format(size=50), - "image_url_small": url.format(size=30), + 'profile': { + 'image': { + 'has_image': True, + 'image_url_full': url.format(size=500), + 'image_url_large': url.format(size=120), + 'image_url_medium': url.format(size=50), + 'image_url_small': url.format(size=30), } } } @@ -1016,14 +962,14 @@ def parsed_body(request): """Returns a parsed dictionary version of a request body""" # This could just be HTTPrettyRequest.parsed_body, but that method double-decodes '%2B' -> '+' -> ' '. # You can just remove this method when this issue is fixed: https://github.com/gabrielfalcao/HTTPretty/issues/240 - return parse_qs(request.body.decode("utf8")) + return parse_qs(request.body.decode('utf8')) def querystring(request): """Returns a parsed dictionary version of a query string""" # This could just be HTTPrettyRequest.querystring, but that method double-decodes '%2B' -> '+' -> ' '. # You can just remove this method when this issue is fixed: https://github.com/gabrielfalcao/HTTPretty/issues/240 - return parse_qs(request.path.split("?", 1)[-1]) + return parse_qs(request.path.split('?', 1)[-1]) class ThreadMock(object): @@ -1031,9 +977,7 @@ class ThreadMock(object): A mock thread object """ - def __init__( - self, thread_id, creator, title, parent_id=None, body="", commentable_id=None - ): + def __init__(self, thread_id, creator, title, parent_id=None, body='', commentable_id=None): self.id = thread_id self.user_id = str(creator.id) self.username = creator.username diff --git a/lms/djangoapps/discussion/rest_api/urls.py b/lms/djangoapps/discussion/rest_api/urls.py index 9753774f075c..f102dc41f249 100644 --- a/lms/djangoapps/discussion/rest_api/urls.py +++ b/lms/djangoapps/discussion/rest_api/urls.py @@ -9,7 +9,6 @@ from lms.djangoapps.discussion.rest_api.views import ( BulkDeleteUserPosts, - BulkRestoreUserPosts, CommentViewSet, CourseActivityStatsView, CourseDiscussionRolesAPIView, @@ -19,10 +18,8 @@ CourseTopicsViewV3, CourseView, CourseViewV2, - DeletedContentView, LearnerThreadView, ReplaceUsernamesView, - RestoreContent, RetireUserView, ThreadViewSet, UploadFileView, @@ -34,22 +31,26 @@ urlpatterns = [ re_path( - r"^v1/courses/{}/settings$".format(settings.COURSE_ID_PATTERN), + r"^v1/courses/{}/settings$".format( + settings.COURSE_ID_PATTERN + ), CourseDiscussionSettingsAPIView.as_view(), name="discussion_course_settings", ), re_path( - r"^v1/courses/{}/learner/$".format(settings.COURSE_ID_PATTERN), + r"^v1/courses/{}/learner/$".format( + settings.COURSE_ID_PATTERN + ), LearnerThreadView.as_view(), name="discussion_learner_threads", ), re_path( - rf"^v1/courses/{settings.COURSE_KEY_PATTERN}/activity_stats", + fr"^v1/courses/{settings.COURSE_KEY_PATTERN}/activity_stats", CourseActivityStatsView.as_view(), name="discussion_course_activity_stats", ), re_path( - rf"^v1/courses/{settings.COURSE_ID_PATTERN}/upload$", + fr"^v1/courses/{settings.COURSE_ID_PATTERN}/upload$", UploadFileView.as_view(), name="upload_file", ), @@ -61,55 +62,36 @@ name="discussion_course_roles", ), re_path( - rf"^v1/courses/{settings.COURSE_ID_PATTERN}", + fr"^v1/courses/{settings.COURSE_ID_PATTERN}", CourseView.as_view(), - name="discussion_course", + name="discussion_course" ), re_path( - rf"^v2/courses/{settings.COURSE_ID_PATTERN}", + fr"^v2/courses/{settings.COURSE_ID_PATTERN}", CourseViewV2.as_view(), - name="discussion_course_v2", + name="discussion_course_v2" ), + re_path(r'^v1/accounts/retire_forum/?$', RetireUserView.as_view(), name="retire_discussion_user"), + path('v1/accounts/replace_username', ReplaceUsernamesView.as_view(), name="replace_discussion_username"), re_path( - r"^v1/accounts/retire_forum/?$", - RetireUserView.as_view(), - name="retire_discussion_user", - ), - path( - "v1/accounts/replace_username", - ReplaceUsernamesView.as_view(), - name="replace_discussion_username", - ), - re_path( - rf"^v1/course_topics/{settings.COURSE_ID_PATTERN}", + fr"^v1/course_topics/{settings.COURSE_ID_PATTERN}", CourseTopicsView.as_view(), - name="course_topics", + name="course_topics" ), re_path( - rf"^v2/course_topics/{settings.COURSE_ID_PATTERN}", + fr"^v2/course_topics/{settings.COURSE_ID_PATTERN}", CourseTopicsViewV2.as_view(), - name="course_topics_v2", + name="course_topics_v2" ), re_path( - rf"^v3/course_topics/{settings.COURSE_ID_PATTERN}", + fr"^v3/course_topics/{settings.COURSE_ID_PATTERN}", CourseTopicsViewV3.as_view(), - name="course_topics_v3", + name="course_topics_v3" ), re_path( - rf"^v1/bulk_delete_user_posts/{settings.COURSE_ID_PATTERN}", + fr"^v1/bulk_delete_user_posts/{settings.COURSE_ID_PATTERN}", BulkDeleteUserPosts.as_view(), - name="bulk_delete_user_posts", - ), - re_path( - rf"^v1/bulk_restore_user_posts/{settings.COURSE_ID_PATTERN}", - BulkRestoreUserPosts.as_view(), - name="bulk_restore_user_posts", - ), - path("v1/restore_content", RestoreContent.as_view(), name="restore_content"), - re_path( - rf"^v1/deleted_content/{settings.COURSE_ID_PATTERN}", - DeletedContentView.as_view(), - name="deleted_content", + name="bulk_delete_user_posts" ), - path("v1/", include(ROUTER.urls)), + path('v1/', include(ROUTER.urls)), ] diff --git a/lms/djangoapps/discussion/rest_api/views.py b/lms/djangoapps/discussion/rest_api/views.py index 3556f78562fe..ba9818124e08 100644 --- a/lms/djangoapps/discussion/rest_api/views.py +++ b/lms/djangoapps/discussion/rest_api/views.py @@ -1,19 +1,17 @@ """ Discussion API views """ - import logging import uuid import edx_api_doc_tools as apidocs + from django.contrib.auth import get_user_model from django.core.exceptions import BadRequest, ValidationError from django.shortcuts import get_object_or_404 from drf_yasg import openapi from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication -from edx_rest_framework_extensions.auth.session.authentication import ( - SessionAuthenticationAllowInactiveUser, -) +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from opaque_keys.edx.keys import CourseKey from rest_framework import permissions, status from rest_framework.authentication import SessionAuthentication @@ -23,49 +21,31 @@ from rest_framework.views import APIView from rest_framework.viewsets import ViewSet +from xmodule.modulestore.django import modulestore + from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.util.file import store_uploaded_file from lms.djangoapps.course_api.blocks.api import get_blocks from lms.djangoapps.course_goals.models import UserActivity -from lms.djangoapps.discussion.django_comment_client import settings as cc_settings -from lms.djangoapps.discussion.django_comment_client.utils import ( - get_group_id_for_comments_service, -) from lms.djangoapps.discussion.rate_limit import is_content_creation_rate_limited from lms.djangoapps.discussion.rest_api.permissions import IsAllowedToBulkDelete -from lms.djangoapps.discussion.rest_api.tasks import ( - delete_course_post_for_user, - restore_course_post_for_user, -) +from lms.djangoapps.discussion.rest_api.tasks import delete_course_post_for_user from lms.djangoapps.discussion.toggles import ONLY_VERIFIED_USERS_CAN_POST +from lms.djangoapps.discussion.django_comment_client import settings as cc_settings +from lms.djangoapps.discussion.django_comment_client.utils import get_group_id_for_comments_service from lms.djangoapps.instructor.access import update_forum_role -from openedx.core.djangoapps.discussions.config.waffle import ( - ENABLE_NEW_STRUCTURE_DISCUSSIONS, -) -from openedx.core.djangoapps.discussions.models import ( - DiscussionsConfiguration, - Provider, -) +from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS +from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, Provider from openedx.core.djangoapps.discussions.serializers import DiscussionSettingsSerializer from openedx.core.djangoapps.django_comment_common import comment_client +from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings, Role from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread -from openedx.core.djangoapps.django_comment_common.models import ( - CourseDiscussionSettings, - Role, -) -from openedx.core.djangoapps.user_api.accounts.permissions import ( - CanReplaceUsername, - CanRetireUser, -) +from openedx.core.djangoapps.user_api.accounts.permissions import CanReplaceUsername, CanRetireUser from openedx.core.djangoapps.user_api.models import UserRetirementStatus -from openedx.core.lib.api.authentication import ( - BearerAuthentication, - BearerAuthenticationAllowInactiveUser, -) +from openedx.core.lib.api.authentication import BearerAuthentication, BearerAuthenticationAllowInactiveUser from openedx.core.lib.api.parsers import MergePatchParser from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes -from xmodule.modulestore.django import modulestore from ..rest_api.api import ( create_comment, @@ -77,10 +57,10 @@ get_course_discussion_user_stats, get_course_topics, get_course_topics_v2, - get_learner_active_thread_list, get_response_comments, get_thread, get_thread_list, + get_learner_active_thread_list, get_user_comments, get_v2_course_topics_as_v1, update_comment, @@ -108,10 +88,10 @@ from .utils import ( create_blocks_params, create_topics_v3_structure, - get_course_id_from_thread_id, is_captcha_enabled, - is_only_student, verify_recaptcha_token, + get_course_id_from_thread_id, + is_only_student, ) log = logging.getLogger(__name__) @@ -127,16 +107,14 @@ class CourseView(DeveloperErrorViewMixin, APIView): @apidocs.schema( parameters=[ - apidocs.string_parameter( - "course_id", apidocs.ParameterLocation.PATH, description="Course ID" - ) + apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID") ], responses={ 200: CourseMetadataSerailizer(read_only=True, required=False), 401: "The requester is not authenticated.", 403: "The requester cannot access the specified course.", 404: "The requested course does not exist.", - }, + } ) def get(self, request, course_id): """ @@ -148,9 +126,7 @@ def get(self, request, course_id): """ course_key = CourseKey.from_string(course_id) # TODO: which class is right? # Record user activity for tracking progress towards a user's course goals (for mobile app) - UserActivity.record_user_activity( - request.user, course_key, request=request, only_if_mobile_app=True - ) + UserActivity.record_user_activity(request.user, course_key, request=request, only_if_mobile_app=True) return Response(get_course(request, course_key)) @@ -162,16 +138,14 @@ class CourseViewV2(DeveloperErrorViewMixin, APIView): @apidocs.schema( parameters=[ - apidocs.string_parameter( - "course_id", apidocs.ParameterLocation.PATH, description="Course ID" - ) + apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID") ], responses={ 200: CourseMetadataSerailizer(read_only=True, required=False), 401: "The requester is not authenticated.", 403: "The requester cannot access the specified course.", 404: "The requested course does not exist.", - }, + } ) def get(self, request, course_id): """ @@ -182,9 +156,7 @@ def get(self, request, course_id): """ course_key = CourseKey.from_string(course_id) # Record user activity for tracking progress towards a user's course goals (for mobile app) - UserActivity.record_user_activity( - request.user, course_key, request=request, only_if_mobile_app=True - ) + UserActivity.record_user_activity(request.user, course_key, request=request, only_if_mobile_app=True) return Response(get_course(request, course_key, False)) @@ -249,14 +221,14 @@ def get(self, request, course_key_string): form_query_string = CourseActivityStatsForm(request.query_params) if not form_query_string.is_valid(): raise ValidationError(form_query_string.errors) - order_by = form_query_string.cleaned_data.get("order_by", None) + order_by = form_query_string.cleaned_data.get('order_by', None) order_by = UserOrdering(order_by) if order_by else None - username_search_string = form_query_string.cleaned_data.get("username", None) + username_search_string = form_query_string.cleaned_data.get('username', None) data = get_course_discussion_user_stats( request, course_key_string, - form_query_string.cleaned_data["page"], - form_query_string.cleaned_data["page_size"], + form_query_string.cleaned_data['page'], + form_query_string.cleaned_data['page_size'], order_by, username_search_string, ) @@ -296,17 +268,19 @@ def get(self, request, course_id): Implements the GET method as described in the class docstring. """ course_key = CourseKey.from_string(course_id) - topic_ids = self.request.GET.get("topic_id") - topic_ids = set(topic_ids.strip(",").split(",")) if topic_ids else None + topic_ids = self.request.GET.get('topic_id') + topic_ids = set(topic_ids.strip(',').split(',')) if topic_ids else None with modulestore().bulk_operations(course_key): configuration = DiscussionsConfiguration.get(context_key=course_key) provider = configuration.provider_type # This will be removed when mobile app will support new topic structure - new_structure_enabled = ENABLE_NEW_STRUCTURE_DISCUSSIONS.is_enabled( - course_key - ) + new_structure_enabled = ENABLE_NEW_STRUCTURE_DISCUSSIONS.is_enabled(course_key) if provider == Provider.OPEN_EDX and new_structure_enabled: - response = get_v2_course_topics_as_v1(request, course_key, topic_ids) + response = get_v2_course_topics_as_v1( + request, + course_key, + topic_ids + ) else: response = get_course_topics( request, @@ -314,9 +288,7 @@ def get(self, request, course_id): topic_ids, ) # Record user activity for tracking progress towards a user's course goals (for mobile app) - UserActivity.record_user_activity( - request.user, course_key, request=request, only_if_mobile_app=True - ) + UserActivity.record_user_activity(request.user, course_key, request=request, only_if_mobile_app=True) return Response(response) @@ -332,17 +304,17 @@ class CourseTopicsViewV2(DeveloperErrorViewMixin, APIView): @apidocs.schema( parameters=[ apidocs.string_parameter( - "course_id", + 'course_id', apidocs.ParameterLocation.PATH, description="Course ID", ), apidocs.string_parameter( - "topic_id", + 'topic_id', apidocs.ParameterLocation.QUERY, description="Comma-separated list of topic ids to filter", ), openapi.Parameter( - "order_by", + 'order_by', apidocs.ParameterLocation.QUERY, required=False, type=openapi.TYPE_STRING, @@ -355,7 +327,7 @@ class CourseTopicsViewV2(DeveloperErrorViewMixin, APIView): 401: "The requester is not authenticated.", 403: "The requester cannot access the specified course.", 404: "The requested course does not exist.", - }, + } ) def get(self, request, course_id): """ @@ -376,7 +348,7 @@ def get(self, request, course_id): course_key, request.user, form_query_params.cleaned_data["topic_id"], - form_query_params.cleaned_data["order_by"], + form_query_params.cleaned_data["order_by"] ) return Response(response) @@ -444,17 +416,17 @@ def get(self, request, course_id): blocks_params = create_blocks_params(course_usage_key, request.user) blocks = get_blocks( request, - blocks_params["usage_key"], - blocks_params["user"], - blocks_params["depth"], - blocks_params["nav_depth"], - blocks_params["requested_fields"], - blocks_params["block_counts"], - blocks_params["student_view_data"], - blocks_params["return_type"], - blocks_params["block_types_filter"], + blocks_params['usage_key'], + blocks_params['user'], + blocks_params['depth'], + blocks_params['nav_depth'], + blocks_params['requested_fields'], + blocks_params['block_counts'], + blocks_params['student_view_data'], + blocks_params['return_type'], + blocks_params['block_types_filter'], hide_access_denials=False, - )["blocks"] + )['blocks'] topics = create_topics_v3_structure(blocks, topics) return Response(topics) @@ -655,12 +627,8 @@ class ThreadViewSet(DeveloperErrorViewMixin, ViewSet): No content is returned for a DELETE request """ - lookup_field = "thread_id" - parser_classes = ( - JSONParser, - MergePatchParser, - ) + parser_classes = (JSONParser, MergePatchParser,) def list(self, request): """ @@ -673,10 +641,7 @@ class docstring. # Record user activity for tracking progress towards a user's course goals (for mobile app) UserActivity.record_user_activity( - request.user, - form.cleaned_data["course_id"], - request=request, - only_if_mobile_app=True, + request.user, form.cleaned_data["course_id"], request=request, only_if_mobile_app=True ) return get_thread_list( @@ -695,15 +660,14 @@ class docstring. form.cleaned_data["order_direction"], form.cleaned_data["requested_fields"], form.cleaned_data["count_flagged"], - form.cleaned_data["show_deleted"], ) def retrieve(self, request, thread_id=None): """ Implements the GET method for thread ID """ - requested_fields = request.GET.get("requested_fields") - course_id = request.GET.get("course_id") + requested_fields = request.GET.get('requested_fields') + course_id = request.GET.get('course_id') return Response(get_thread(request, thread_id, requested_fields, course_id)) def create(self, request): @@ -717,28 +681,21 @@ class docstring. course_key = CourseKey.from_string(course_key_str) if is_content_creation_rate_limited(request, course_key=course_key): - return Response( - "Too many requests", status=status.HTTP_429_TOO_MANY_REQUESTS - ) + return Response("Too many requests", status=status.HTTP_429_TOO_MANY_REQUESTS) if is_captcha_enabled(course_key) and is_only_student(course_key, request.user): - captcha_token = request.data.get("captcha_token") + captcha_token = request.data.get('captcha_token') if not captcha_token: - raise ValidationError({"captcha_token": "This field is required."}) + raise ValidationError({'captcha_token': 'This field is required.'}) if not verify_recaptcha_token(captcha_token): - return Response({"error": "CAPTCHA verification failed."}, status=400) - - if ( - ONLY_VERIFIED_USERS_CAN_POST.is_enabled(course_key) - and not request.user.is_active - ): - raise ValidationError( - {"detail": "Only verified users can post in discussions."} - ) + return Response({'error': 'CAPTCHA verification failed.'}, status=400) + + if ONLY_VERIFIED_USERS_CAN_POST.is_enabled(course_key) and not request.user.is_active: + raise ValidationError({"detail": "Only verified users can post in discussions."}) data = request.data.copy() - data.pop("captcha_token", None) + data.pop('captcha_token', None) return Response(create_thread(request, data)) def partial_update(self, request, thread_id): @@ -805,27 +762,24 @@ def get(self, request, course_id=None): Implements the GET method as described in the class docstring. """ course_key = CourseKey.from_string(course_id) - page_num = request.GET.get("page", 1) - threads_per_page = request.GET.get("page_size", 10) - count_flagged = request.GET.get("count_flagged", False) - thread_type = request.GET.get("thread_type") - order_by = request.GET.get("order_by") + page_num = request.GET.get('page', 1) + threads_per_page = request.GET.get('page_size', 10) + count_flagged = request.GET.get('count_flagged', False) + thread_type = request.GET.get('thread_type') + order_by = request.GET.get('order_by') order_by_mapping = { "last_activity_at": "activity", "comment_count": "comments", - "vote_count": "votes", + "vote_count": "votes" } - order_by = order_by_mapping.get(order_by, "activity") - post_status = request.GET.get("status", None) - show_deleted = request.GET.get("show_deleted", "false").lower() == "true" + order_by = order_by_mapping.get(order_by, 'activity') + post_status = request.GET.get('status', None) discussion_id = None - username = request.GET.get("username", None) + username = request.GET.get('username', None) user = get_object_or_404(User, username=username) group_id = None try: - group_id = get_group_id_for_comments_service( - request, course_key, discussion_id - ) + group_id = get_group_id_for_comments_service(request, course_key, discussion_id) except ValueError: pass @@ -838,17 +792,14 @@ def get(self, request, course_id=None): "count_flagged": count_flagged, "thread_type": thread_type, "sort_key": order_by, - "show_deleted": show_deleted, } if post_status: - if post_status not in ["flagged", "unanswered", "unread", "unresponded"]: - raise ValidationError( - { - "status": [ - f"Invalid value. '{post_status}' must be 'flagged', 'unanswered', 'unread' or 'unresponded" - ] - } - ) + if post_status not in ['flagged', 'unanswered', 'unread', 'unresponded']: + raise ValidationError({ + "status": [ + f"Invalid value. '{post_status}' must be 'flagged', 'unanswered', 'unread' or 'unresponded" + ] + }) query_params[post_status] = True return get_learner_active_thread_list(request, course_key, query_params) @@ -1017,12 +968,8 @@ class CommentViewSet(DeveloperErrorViewMixin, ViewSet): No content is returned for a DELETE request """ - lookup_field = "comment_id" - parser_classes = ( - JSONParser, - MergePatchParser, - ) + parser_classes = (JSONParser, MergePatchParser,) def list(self, request): """ @@ -1063,8 +1010,7 @@ def list_by_thread(self, request): form.cleaned_data["page_size"], form.cleaned_data["flagged"], form.cleaned_data["requested_fields"], - form.cleaned_data["merge_question_type_responses"], - form.cleaned_data["show_deleted"], + form.cleaned_data["merge_question_type_responses"] ) def list_by_user(self, request): @@ -1111,28 +1057,21 @@ class docstring. course_key = CourseKey.from_string(course_key_str) if is_content_creation_rate_limited(request, course_key=course_key): - return Response( - "Too many requests", status=status.HTTP_429_TOO_MANY_REQUESTS - ) + return Response("Too many requests", status=status.HTTP_429_TOO_MANY_REQUESTS) if is_captcha_enabled(course_key) and is_only_student(course_key, request.user): - captcha_token = request.data.get("captcha_token") + captcha_token = request.data.get('captcha_token') if not captcha_token: - raise ValidationError({"captcha_token": "This field is required."}) + raise ValidationError({'captcha_token': 'This field is required.'}) if not verify_recaptcha_token(captcha_token): - return Response({"error": "CAPTCHA verification failed."}, status=400) - - if ( - ONLY_VERIFIED_USERS_CAN_POST.is_enabled(course_key) - and not request.user.is_active - ): - raise ValidationError( - {"detail": "Only verified users can post in discussions."} - ) + return Response({'error': 'CAPTCHA verification failed.'}, status=400) + + if ONLY_VERIFIED_USERS_CAN_POST.is_enabled(course_key) and not request.user.is_active: + raise ValidationError({"detail": "Only verified users can post in discussions."}) data = request.data.copy() - data.pop("captcha_token", None) + data.pop('captcha_token', None) return Response(create_comment(request, data)) def destroy(self, request, comment_id): @@ -1208,11 +1147,8 @@ def post(self, request, course_id): unique_file_name = f"{course_id}/{thread_key}/{uuid.uuid4()}" try: file_storage, stored_file_name = store_uploaded_file( - request, - "uploaded_file", - cc_settings.ALLOWED_UPLOAD_FILE_TYPES, - unique_file_name, - max_file_size=cc_settings.MAX_UPLOAD_FILE_SIZE, + request, "uploaded_file", cc_settings.ALLOWED_UPLOAD_FILE_TYPES, + unique_file_name, max_file_size=cc_settings.MAX_UPLOAD_FILE_SIZE, ) except ValueError as err: raise BadRequest("no `uploaded_file` was provided") from err @@ -1253,12 +1189,10 @@ def post(self, request): """ Implements the retirement endpoint. """ - username = request.data["username"] + username = request.data['username'] try: - retirement = UserRetirementStatus.get_retirement_for_retirement_action( - username - ) + retirement = UserRetirementStatus.get_retirement_for_retirement_action(username) cc_user = comment_client.User.from_django_user(retirement.user) # Send the retired username to the forums service, as the service cannot generate @@ -1313,9 +1247,7 @@ def post(self, request): for username_pair in username_mappings: current_username = list(username_pair.keys())[0] new_username = list(username_pair.values())[0] - successfully_replaced = self._replace_username( - current_username, new_username - ) + successfully_replaced = self._replace_username(current_username, new_username) if successfully_replaced: successful_replacements.append({current_username: new_username}) else: @@ -1325,8 +1257,8 @@ def post(self, request): status=status.HTTP_200_OK, data={ "successful_replacements": successful_replacements, - "failed_replacements": failed_replacements, - }, + "failed_replacements": failed_replacements + } ) def _replace_username(self, current_username, new_username): @@ -1372,7 +1304,7 @@ def _replace_username(self, current_username, new_username): return True def _has_valid_schema(self, post_data): - """Verifies the data is a list of objects with a single key:value pair""" + """ Verifies the data is a list of objects with a single key:value pair """ if not isinstance(post_data, list): return False for obj in post_data: @@ -1432,16 +1364,12 @@ class CourseDiscussionSettingsAPIView(DeveloperErrorViewMixin, APIView): * available_division_schemes: A list of available division schemes for the course. """ - authentication_classes = ( JwtAuthentication, BearerAuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser, ) - parser_classes = ( - JSONParser, - MergePatchParser, - ) + parser_classes = (JSONParser, MergePatchParser,) permission_classes = (permissions.IsAuthenticated, IsStaffOrAdmin) def _get_request_kwargs(self, course_id): @@ -1457,14 +1385,14 @@ def get(self, request, course_id): if not form.is_valid(): raise ValidationError(form.errors) - course_key = form.cleaned_data["course_key"] - course = form.cleaned_data["course"] + course_key = form.cleaned_data['course_key'] + course = form.cleaned_data['course'] discussion_settings = CourseDiscussionSettings.get(course_key) serializer = DiscussionSettingsSerializer( discussion_settings, context={ - "course": course, - "settings": discussion_settings, + 'course': course, + 'settings': discussion_settings, }, partial=True, ) @@ -1483,15 +1411,15 @@ def patch(self, request, course_id): if not form.is_valid(): raise ValidationError(form.errors) - course = form.cleaned_data["course"] - course_key = form.cleaned_data["course_key"] + course = form.cleaned_data['course'] + course_key = form.cleaned_data['course_key'] discussion_settings = CourseDiscussionSettings.get(course_key) serializer = DiscussionSettingsSerializer( discussion_settings, context={ - "course": course, - "settings": discussion_settings, + 'course': course, + 'settings': discussion_settings, }, data=request.data, partial=True, @@ -1560,7 +1488,6 @@ class CourseDiscussionRolesAPIView(DeveloperErrorViewMixin, APIView): * division_scheme: The division scheme used by the course. """ - authentication_classes = ( JwtAuthentication, BearerAuthenticationAllowInactiveUser, @@ -1581,13 +1508,11 @@ def get(self, request, course_id, rolename): if not form.is_valid(): raise ValidationError(form.errors) - course_id = form.cleaned_data["course_key"] - role = form.cleaned_data["role"] + course_id = form.cleaned_data['course_key'] + role = form.cleaned_data['role'] - data = {"course_id": course_id, "users": role.users.all()} - context = { - "course_discussion_settings": CourseDiscussionSettings.get(course_id) - } + data = {'course_id': course_id, 'users': role.users.all()} + context = {'course_discussion_settings': CourseDiscussionSettings.get(course_id)} serializer = DiscussionRolesListSerializer(data, context=context) return Response(serializer.data) @@ -1601,25 +1526,23 @@ def post(self, request, course_id, rolename): if not form.is_valid(): raise ValidationError(form.errors) - course_id = form.cleaned_data["course_key"] - rolename = form.cleaned_data["rolename"] + course_id = form.cleaned_data['course_key'] + rolename = form.cleaned_data['rolename'] serializer = DiscussionRolesSerializer(data=request.data) if not serializer.is_valid(): raise ValidationError(serializer.errors) - action = serializer.validated_data["action"] - user = serializer.validated_data["user"] + action = serializer.validated_data['action'] + user = serializer.validated_data['user'] try: update_forum_role(course_id, user, rolename, action) except Role.DoesNotExist as err: raise ValidationError(f"Role '{rolename}' does not exist") from err - role = form.cleaned_data["role"] - data = {"course_id": course_id, "users": role.users.all()} - context = { - "course_discussion_settings": CourseDiscussionSettings.get(course_id) - } + role = form.cleaned_data['role'] + data = {'course_id': course_id, 'users': role.users.all()} + context = {'course_discussion_settings': CourseDiscussionSettings.get(course_id)} serializer = DiscussionRolesListSerializer(data, context=context) return Response(serializer.data) @@ -1643,9 +1566,7 @@ class BulkDeleteUserPosts(DeveloperErrorViewMixin, APIView): """ authentication_classes = ( - JwtAuthentication, - BearerAuthentication, - SessionAuthentication, + JwtAuthentication, BearerAuthentication, SessionAuthentication, ) permission_classes = (permissions.IsAuthenticated, IsAllowedToBulkDelete) @@ -1666,26 +1587,23 @@ def post(self, request, course_id): course_ids = [course_id] if course_or_org == "org": org_id = CourseKey.from_string(course_id).org - enrollments = CourseEnrollment.objects.filter( - user=request.user - ).values_list("course_id", flat=True) - course_ids.extend([str(c_id) for c_id in enrollments if c_id.org == org_id]) + enrollments = CourseEnrollment.objects.filter(user=request.user).values_list('course_id', flat=True) + course_ids.extend([ + str(c_id) + for c_id in enrollments + if c_id.org == org_id + ]) course_ids = list(set(course_ids)) log.info(f"<> {username} enrolled in {enrollments}") - log.info( - f"<> Posts for {username} in {course_ids} - for {course_or_org} {course_id}" - ) + log.info(f"<> Posts for {username} in {course_ids} - for {course_or_org} {course_id}") comment_count = Comment.get_user_comment_count(user.id, course_ids) thread_count = Thread.get_user_threads_count(user.id, course_ids) - log.info( - f"<> {username} in {course_ids} - Count thread {thread_count}, comment {comment_count}" - ) + log.info(f"<> {username} in {course_ids} - Count thread {thread_count}, comment {comment_count}") if execute_task: event_data = { "triggered_by": request.user.username, - "triggered_by_user_id": str(request.user.id), "username": username, "course_or_org": course_or_org, "course_key": course_id, @@ -1695,256 +1613,5 @@ def post(self, request, course_id): ) return Response( {"comment_count": comment_count, "thread_count": thread_count}, - status=status.HTTP_202_ACCEPTED, - ) - - -class RestoreContent(DeveloperErrorViewMixin, APIView): - """ - **Use Cases** - A privileged user that can restore individual soft-deleted threads, comments, or responses. - - **Example Requests**: - POST /api/discussion/v1/restore_content - Request Body: - { - "content_type": "thread", // "thread", "comment", or "response" - "content_id": "thread_id_or_comment_id", - "course_id": "course-v1:edX+DemoX+Demo_Course" - } - - **Example Response**: - {"success": true, "message": "Content restored successfully"} - """ - - authentication_classes = ( - JwtAuthentication, - BearerAuthentication, - SessionAuthentication, - ) - permission_classes = (permissions.IsAuthenticated, IsAllowedToBulkDelete) - - def post(self, request): - """ - Implements the restore individual content endpoint. - """ - content_type = request.data.get("content_type") - content_id = request.data.get("content_id") - course_id = request.data.get("course_id") - - if not all([content_type, content_id, course_id]): - raise BadRequest("content_type, content_id, and course_id are required.") - - if content_type not in ["thread", "comment", "response"]: - raise BadRequest("content_type must be 'thread', 'comment', or 'response'.") - - restored_by_user_id = str(request.user.id) - - try: - if content_type == "thread": - success = Thread.restore_thread( - content_id, course_id=course_id, restored_by=restored_by_user_id - ) - else: # comment or response (both are comments in the backend) - success = Comment.restore_comment( - content_id, course_id=course_id, restored_by=restored_by_user_id - ) - - if success: - return Response( - { - "success": True, - "message": f"{content_type.capitalize()} restored successfully", - }, - status=status.HTTP_200_OK, - ) - else: - return Response( - { - "success": False, - "message": f"{content_type.capitalize()} not found or already restored", - }, - status=status.HTTP_404_NOT_FOUND, - ) - except Exception as e: # pylint: disable=broad-exception-caught - log.error("Error restoring %s %s: %s", content_type, content_id, str(e)) - return Response( - { - "success": False, - "message": f"Error restoring {content_type}: {str(e)}", - }, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - - -class BulkRestoreUserPosts(DeveloperErrorViewMixin, APIView): - """ - **Use Cases** - A privileged user that can restore all soft-deleted posts and comments made by a user. - It returns expected number of comments and threads that will be restored - - **Example Requests**: - POST /api/discussion/v1/bulk_restore_user_posts/{course_id} - Query Parameters: - username: The username of the user whose posts are to be restored - course_id: Course id for which posts are to be restored - execute: If True, runs restoration task - course_or_org: If 'course', restores posts in the course, if 'org', restores posts in all courses of the org - - **Example Response**: - {"comment_count": 5, "thread_count": 3} - """ - - authentication_classes = ( - JwtAuthentication, - BearerAuthentication, - SessionAuthentication, - ) - permission_classes = (permissions.IsAuthenticated, IsAllowedToBulkDelete) - - def post(self, request, course_id): - """ - Implements the restore user posts endpoint. - """ - username = request.GET.get("username", None) - execute_task = request.GET.get("execute", "false").lower() == "true" - if (not username) or (not course_id): - raise BadRequest("username and course_id are required.") - course_or_org = request.GET.get("course_or_org", "course") - if course_or_org not in ["course", "org"]: - raise BadRequest("course_or_org must be either 'course' or 'org'.") - - user = get_object_or_404(User, username=username) - course_ids = [course_id] - if course_or_org == "org": - org_id = CourseKey.from_string(course_id).org - enrollments = CourseEnrollment.objects.filter( - user=request.user - ).values_list("course_id", flat=True) - course_ids.extend([str(c_id) for c_id in enrollments if c_id.org == org_id]) - course_ids = list(set(course_ids)) - log.info("<> %s enrolled in %s", username, enrollments) - log.info( - "<> Posts for %s in %s - for %s %s", - username, - course_ids, - course_or_org, - course_id, - ) - - comment_count = Comment.get_user_deleted_comment_count(user.id, course_ids) - thread_count = Thread.get_user_deleted_threads_count(user.id, course_ids) - log.info( - "<> %s in %s - Count thread %s, comment %s", - username, - course_ids, - thread_count, - comment_count, - ) - - if execute_task: - event_data = { - "triggered_by": request.user.username, - "triggered_by_user_id": str(request.user.id), - "username": username, - "course_or_org": course_or_org, - "course_key": course_id, - } - restore_course_post_for_user.apply_async( - args=(user.id, username, course_ids, event_data), - ) - return Response( - {"comment_count": comment_count, "thread_count": thread_count}, - status=status.HTTP_202_ACCEPTED, + status=status.HTTP_202_ACCEPTED ) - - -class DeletedContentView(DeveloperErrorViewMixin, APIView): - """ - **Use Cases** - Retrieve all deleted content (threads, comments, responses) for a course. - This endpoint allows privileged users to fetch deleted discussion content. - - **Example Requests**: - GET /api/discussion/v1/deleted_content/course-v1:edX+DemoX+Demo_Course - GET /api/discussion/v1/deleted_content/course-v1:edX+DemoX+Demo_Course?content_type=thread - GET /api/discussion/v1/deleted_content/course-v1:edX+DemoX+Demo_Course?page=1&per_page=20 - - **Example Response**: - { - "results": [ - { - "id": "thread_id", - "type": "thread", - "title": "Deleted Thread Title", - "body": "Thread content...", - "course_id": "course-v1:edX+DemoX+Demo_Course", - "author_id": "user_123", - "deleted_at": "2023-11-19T10:30:00Z", - "deleted_by": "moderator_456" - } - ], - "pagination": { - "page": 1, - "per_page": 20, - "total_count": 50, - "num_pages": 3 - } - } - """ - - authentication_classes = ( - JwtAuthentication, - BearerAuthentication, - SessionAuthentication, - ) - permission_classes = (permissions.IsAuthenticated, IsAllowedToBulkDelete) - - def get(self, request, course_id): - """ - Retrieve all deleted content for a course. - """ - try: - course_key = CourseKey.from_string(course_id) - except Exception as e: - raise BadRequest("Invalid course_id") from e - - # Get query parameters - content_type = request.GET.get( - "content_type", None - ) # 'thread', 'comment', or None for all - page = int(request.GET.get("page", 1)) - per_page = int(request.GET.get("per_page", 20)) - author_id = request.GET.get("author_id", None) - - # Validate parameters - if content_type and content_type not in ["thread", "comment"]: - raise BadRequest("content_type must be 'thread' or 'comment'") - - per_page = min(per_page, 100) # Limit to prevent excessive load - - try: - # Import here to avoid circular imports - from lms.djangoapps.discussion.rest_api.api import ( - get_deleted_content_for_course, - ) - - results = get_deleted_content_for_course( - request=request, - course_id=str(course_key), - content_type=content_type, - page=page, - per_page=per_page, - author_id=author_id, - ) - - return Response(results, status=status.HTTP_200_OK) - - except Exception as e: # pylint: disable=broad-exception-caught - logging.exception( - "Error retrieving deleted content for course %s: %s", course_id, e - ) - return Response( - {"error": "Failed to retrieve deleted content"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) 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 dae2088594ae..8905679a45db 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py @@ -4,17 +4,13 @@ from bs4 import BeautifulSoup -from forum import api as forum_api -from forum.backends.mongodb.comments import ( - Comment as ForumComment, -) # pylint: disable=import-error -from openedx.core.djangoapps.django_comment_common.comment_client import ( - models, - settings, -) +from openedx.core.djangoapps.django_comment_common.comment_client import models, settings from .thread import Thread from .utils import CommentClientRequestError, get_course_key +from forum import api as forum_api +from forum.backends.mongodb.comments import Comment as ForumComment + log = logging.getLogger(__name__) @@ -22,56 +18,26 @@ class Comment(models.Model): accessible_fields = [ - "id", - "body", - "anonymous", - "anonymous_to_peers", - "course_id", - "endorsed", - "parent_id", - "thread_id", - "username", - "votes", - "user_id", - "closed", - "created_at", - "updated_at", - "depth", - "at_position_list", - "type", - "commentable_id", - "abuse_flaggers", - "endorsement", - "child_count", - "edit_history", - "is_spam", - "ai_moderation_reason", - "abuse_flagged", - "is_deleted", - "deleted_at", - "deleted_by", + 'id', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', + 'endorsed', 'parent_id', 'thread_id', 'username', 'votes', 'user_id', + 'closed', 'created_at', 'updated_at', 'depth', 'at_position_list', + 'type', 'commentable_id', 'abuse_flaggers', 'endorsement', + 'child_count', 'edit_history', + 'is_spam', 'ai_moderation_reason', 'abuse_flagged', ] updatable_fields = [ - "body", - "anonymous", - "anonymous_to_peers", - "course_id", - "closed", - "user_id", - "endorsed", - "endorsement_user_id", - "edit_reason_code", - "closing_user_id", - "editing_user_id", + 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'closed', + 'user_id', 'endorsed', 'endorsement_user_id', 'edit_reason_code', + 'closing_user_id', 'editing_user_id', ] initializable_fields = updatable_fields - metrics_tag_fields = ["course_id", "endorsed", "closed"] + metrics_tag_fields = ['course_id', 'endorsed', 'closed'] base_url = f"{settings.PREFIX}/comments" - type = "comment" + type = 'comment' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -80,7 +46,7 @@ def __init__(self, *args, **kwargs): @property def thread(self): if not self._cached_thread: - self._cached_thread = Thread(id=self.thread_id, type="thread") + self._cached_thread = Thread(id=self.thread_id, type='thread') return self._cached_thread @property @@ -90,22 +56,22 @@ def context(self): @classmethod def url_for_comments(cls, params=None): - if params and params.get("parent_id"): - return _url_for_comment(params["parent_id"]) + if params and params.get('parent_id'): + return _url_for_comment(params['parent_id']) else: - return _url_for_thread_comments(params["thread_id"]) + return _url_for_thread_comments(params['thread_id']) @classmethod def url(cls, action, params=None): if params is None: params = {} - if action in ["post"]: + if action in ['post']: return cls.url_for_comments(params) else: return super().url(action, params) def flagAbuse(self, user, voteable, course_id=None): - if voteable.type != "comment": + if voteable.type != 'comment': raise CommentClientRequestError("Can only flag comments") course_key = get_course_key(self.attributes.get("course_id") or course_id) @@ -118,7 +84,7 @@ def flagAbuse(self, user, voteable, course_id=None): voteable._update_from_response(response) def unFlagAbuse(self, user, voteable, removeAll, course_id=None): - if voteable.type != "comment": + if voteable.type != 'comment': raise CommentClientRequestError("Can only unflag comments") course_key = get_course_key(self.attributes.get("course_id") or course_id) @@ -136,7 +102,7 @@ def body_text(self): """ Return the text content of the comment html body. """ - soup = BeautifulSoup(self.body, "html.parser") + soup = BeautifulSoup(self.body, 'html.parser') return soup.get_text() @classmethod @@ -148,15 +114,12 @@ def get_user_comment_count(cls, user_id, course_ids): query_params = { "course_id": {"$in": course_ids}, "author_id": str(user_id), - "is_deleted": {"$ne": True}, - "_type": "Comment", + "_type": "Comment" } - return ForumComment()._collection.count_documents( - query_params - ) # pylint: disable=protected-access + return ForumComment()._collection.count_documents(query_params) # pylint: disable=protected-access @classmethod - def delete_user_comments(cls, user_id, course_ids, deleted_by=None): + def delete_user_comments(cls, user_id, course_ids): """ Deletes comments and responses of user in the given course_ids. TODO: Add support for MySQL backend as well @@ -165,66 +128,21 @@ def delete_user_comments(cls, user_id, course_ids, deleted_by=None): query_params = { "course_id": {"$in": course_ids}, "author_id": str(user_id), - "is_deleted": {"$ne": True}, } comments_deleted = 0 comments = ForumComment().get_list(**query_params) - log.info( - f"<> Fetched comments for user {user_id} in {time.time() - start_time} seconds" - ) + log.info(f"<> Fetched comments for user {user_id} in {time.time() - start_time} seconds") for comment in comments: start_time = time.time() comment_id = comment.get("_id") course_id = comment.get("course_id") if comment_id: - # Use forum_api.delete_comment which supports deleted_by parameter - forum_api.delete_comment( # pylint: disable=unexpected-keyword-arg - comment_id, course_id=course_id, deleted_by=deleted_by - ) + forum_api.delete_comment(comment_id, course_id=course_id) comments_deleted += 1 - log.info( - f"<> Deleted comment {comment_id} in {time.time() - start_time} seconds." - f" Comment Found: {comment_id is not None}" - ) + log.info(f"<> Deleted comment {comment_id} in {time.time() - start_time} seconds." + f" Comment Found: {comment_id is not None}") return comments_deleted - @classmethod - def get_user_deleted_comment_count(cls, user_id, course_ids): - """ - Returns count of deleted comments for user in the given course_ids. - """ - query_params = { - "course_id": {"$in": course_ids}, - "author_id": str(user_id), - "_type": "Comment", - "is_deleted": True, - } - return ForumComment()._collection.count_documents( - query_params - ) # pylint: disable=protected-access - - @classmethod - def restore_user_deleted_comments(cls, user_id, course_ids, restored_by=None): - """ - Restores (undeletes) comments of user in the given course_ids by setting is_deleted=False. - """ - return forum_api.restore_user_deleted_comments( - user_id=str(user_id), - course_ids=course_ids, - course_id=course_ids[0] if course_ids else None, - restored_by=restored_by, - ) - - @classmethod - def restore_comment(cls, comment_id, course_id=None, restored_by=None): - """ - Restores an individual soft-deleted comment by setting is_deleted=False - Public method for individual comment restoration - """ - return forum_api.restore_comment( - comment_id=comment_id, course_id=course_id, restored_by=restored_by - ) - def _url_for_thread_comments(thread_id): return f"{settings.PREFIX}/threads/{thread_id}/comments" diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/models.py b/openedx/core/djangoapps/django_comment_common/comment_client/models.py index ddfcc37cc524..4544a463ed80 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/models.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/models.py @@ -4,28 +4,24 @@ import logging import typing as t +from .utils import CommentClientRequestError, extract, perform_request, get_course_key from forum import api as forum_api -from openedx.core.djangoapps.discussions.config.waffle import ( - is_forum_v2_disabled_globally, - is_forum_v2_enabled, -) - -from .utils import CommentClientRequestError, extract, get_course_key, perform_request +from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled, is_forum_v2_disabled_globally log = logging.getLogger(__name__) class Model: - accessible_fields = ["id"] - updatable_fields = ["id"] - initializable_fields = ["id"] + accessible_fields = ['id'] + updatable_fields = ['id'] + initializable_fields = ['id'] base_url = None default_retrieve_params = {} metric_tag_fields = [] - DEFAULT_ACTIONS_WITH_ID = ["get", "put", "delete"] - DEFAULT_ACTIONS_WITHOUT_ID = ["get_all", "post"] + DEFAULT_ACTIONS_WITH_ID = ['get', 'put', 'delete'] + DEFAULT_ACTIONS_WITHOUT_ID = ['get_all', 'post'] DEFAULT_ACTIONS = DEFAULT_ACTIONS_WITH_ID + DEFAULT_ACTIONS_WITHOUT_ID def __init__(self, *args, **kwargs): @@ -33,21 +29,18 @@ def __init__(self, *args, **kwargs): self.retrieved = False def __getattr__(self, name): - if name == "id": - return self.attributes.get("id", None) + if name == 'id': + return self.attributes.get('id', None) try: return self.attributes[name] - except KeyError as e: + except KeyError: if self.retrieved or self.id is None: - raise AttributeError(f"Field {name} does not exist") from e + raise AttributeError(f"Field {name} does not exist") # lint-amnesty, pylint: disable=raise-missing-from self.retrieve() return self.__getattr__(name) def __setattr__(self, name, value): - if ( - name == "attributes" - or name not in self.accessible_fields + self.updatable_fields - ): + if name == 'attributes' or name not in self.accessible_fields + self.updatable_fields: super().__setattr__(name, value) else: self.attributes[name] = value @@ -83,9 +76,7 @@ def _retrieve(self, *args, **kwargs): if not course_id: _, course_id = is_forum_v2_enabled_for_comment(self.id) if self.type == "comment": - response = forum_api.get_parent_comment( - comment_id=self.attributes["id"], course_id=course_id - ) + response = forum_api.get_parent_comment(comment_id=self.attributes["id"], course_id=course_id) else: raise CommentClientRequestError("Forum v2 API call is missing") self._update_from_response(response) @@ -100,11 +91,11 @@ def _metric_tags(self): record the class name of the model. """ tags = [ - f"{self.__class__.__name__}.{attr}:{self[attr]}" + f'{self.__class__.__name__}.{attr}:{self[attr]}' for attr in self.metric_tag_fields if attr in self.attributes ] - tags.append(f"model_class:{self.__class__.__name__}") + tags.append(f'model_class:{self.__class__.__name__}') return tags @classmethod @@ -123,11 +114,11 @@ def retrieve_all(cls, params=None): The parsed JSON response from the backend. """ return perform_request( - "get", - cls.url(action="get_all"), + 'get', + cls.url(action='get_all'), params, - metric_tags=[f"model_class:{cls.__name__}"], - metric_action="model.retrieve_all", + metric_tags=[f'model_class:{cls.__name__}'], + metric_action='model.retrieve_all', ) def _update_from_response(self, response_data): @@ -137,7 +128,8 @@ def _update_from_response(self, response_data): else: log.warning( "Unexpected field {field_name} in model {model_name}".format( - field_name=k, model_name=self.__class__.__name__ + field_name=k, + model_name=self.__class__.__name__ ) ) @@ -160,7 +152,7 @@ def save(self, params=None): Invokes Forum's POST/PUT service to create/update thread """ self.before_save(self) - if self.id: # if we have id already, treat this as an update + if self.id: # if we have id already, treat this as an update response = self.handle_update(params) else: # otherwise, treat this as an insert response = self.handle_create(params) @@ -168,25 +160,13 @@ def save(self, params=None): self._update_from_response(response) self.after_save(self) - def delete(self, course_id=None, deleted_by=None): + def delete(self, course_id=None): course_key = get_course_key(self.attributes.get("course_id") or course_id) response = None if self.type == "comment": - response = ( - forum_api.delete_comment( # pylint: disable=unexpected-keyword-arg - comment_id=self.attributes["id"], - course_id=str(course_key), - deleted_by=deleted_by, - ) - ) + response = forum_api.delete_comment(comment_id=self.attributes["id"], course_id=str(course_key)) elif self.type == "thread": - response = ( - forum_api.delete_thread( # pylint: disable=unexpected-keyword-arg - thread_id=self.attributes["id"], - course_id=str(course_key), - deleted_by=deleted_by, - ) - ) + response = forum_api.delete_thread(thread_id=self.attributes["id"], course_id=str(course_key)) if response is None: raise CommentClientRequestError("Forum v2 API call is missing") self.retrieved = True @@ -196,7 +176,7 @@ def delete(self, course_id=None, deleted_by=None): def url_with_id(cls, params=None): if params is None: params = {} - return cls.base_url + "/" + str(params["id"]) + return cls.base_url + '/' + str(params['id']) @classmethod def url_without_id(cls, params=None): @@ -207,21 +187,17 @@ def url(cls, action, params=None): if params is None: params = {} if cls.base_url is None: - raise CommentClientRequestError( - "Must provide base_url when using default url function" - ) - if action not in cls.DEFAULT_ACTIONS: + raise CommentClientRequestError("Must provide base_url when using default url function") + if action not in cls.DEFAULT_ACTIONS: # lint-amnesty, pylint: disable=no-else-raise raise ValueError( f"Invalid action {action}. The supported action must be in {str(cls.DEFAULT_ACTIONS)}" ) - if action in cls.DEFAULT_ACTIONS_WITH_ID: + elif action in cls.DEFAULT_ACTIONS_WITH_ID: try: return cls.url_with_id(params) - except KeyError as e: - raise CommentClientRequestError( - f"Cannot perform action {action} without id" - ) from e - else: # action must be in DEFAULT_ACTIONS_WITHOUT_ID now + except KeyError: + raise CommentClientRequestError(f"Cannot perform action {action} without id") # lint-amnesty, pylint: disable=raise-missing-from + else: # action must be in DEFAULT_ACTIONS_WITHOUT_ID now return cls.url_without_id() def handle_update(self, params=None): @@ -330,8 +306,8 @@ def handle_create(self, params=None): try: return handlers[self.type](course_key) - except KeyError as e: - raise CommentClientRequestError(f"Unsupported type: {self.type}") from e + except KeyError as exc: + raise CommentClientRequestError(f"Unsupported type: {self.type}") from exc def handle_create_comment(self, course_id): request_data = self.initializable_attributes() @@ -343,8 +319,8 @@ def handle_create_comment(self, course_id): "anonymous": request_data.get("anonymous", False), "anonymous_to_peers": request_data.get("anonymous_to_peers", False), } - if "endorsed" in request_data: - params["endorsed"] = request_data["endorsed"] + if 'endorsed' in request_data: + params['endorsed'] = request_data['endorsed'] if parent_id := self.attributes.get("parent_id"): params["parent_comment_id"] = parent_id response = forum_api.create_child_comment(**params) diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py index 754fe0065f00..34ccd7bf2ce6 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py @@ -5,104 +5,50 @@ import time import typing as t -from django.core.exceptions import ObjectDoesNotExist from eventtracking import tracker -from rest_framework.serializers import ValidationError +from django.core.exceptions import ObjectDoesNotExist from forum import api as forum_api -from forum.api.threads import ( - prepare_thread_api_response, -) # pylint: disable=import-error -from forum.backend import get_backend # pylint: disable=import-error -from forum.backends.mongodb.threads import CommentThread # pylint: disable=import-error -from forum.utils import ForumV2RequestError # pylint: disable=import-error -from openedx.core.djangoapps.discussions.config.waffle import ( - is_forum_v2_disabled_globally, - is_forum_v2_enabled, -) +from forum.api.threads import prepare_thread_api_response +from forum.backend import get_backend +from forum.backends.mongodb.threads import CommentThread +from forum.utils import ForumV2RequestError +from rest_framework.serializers import ValidationError +from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled, is_forum_v2_disabled_globally from . import models, settings, utils + log = logging.getLogger(__name__) class Thread(models.Model): # accessible_fields can be set and retrieved on the model accessible_fields = [ - "id", - "title", - "body", - "anonymous", - "anonymous_to_peers", - "course_id", - "closed", - "tags", - "votes", - "commentable_id", - "username", - "user_id", - "created_at", - "updated_at", - "comments_count", - "unread_comments_count", - "at_position_list", - "children", - "type", - "highlighted_title", - "highlighted_body", - "endorsed", - "read", - "group_id", - "group_name", - "pinned", - "abuse_flaggers", - "resp_skip", - "resp_limit", - "resp_total", - "thread_type", - "endorsed_responses", - "non_endorsed_responses", - "non_endorsed_resp_total", - "context", - "last_activity_at", - "closed_by", - "close_reason_code", - "edit_history", - "is_spam", - "ai_moderation_reason", - "abuse_flagged", - "is_deleted", - "deleted_at", - "deleted_by", + 'id', 'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', + 'closed', 'tags', 'votes', 'commentable_id', 'username', 'user_id', + 'created_at', 'updated_at', 'comments_count', 'unread_comments_count', + 'at_position_list', 'children', 'type', 'highlighted_title', + 'highlighted_body', 'endorsed', 'read', 'group_id', 'group_name', 'pinned', + 'abuse_flaggers', 'resp_skip', 'resp_limit', 'resp_total', 'thread_type', + 'endorsed_responses', 'non_endorsed_responses', 'non_endorsed_resp_total', + 'context', 'last_activity_at', 'closed_by', 'close_reason_code', 'edit_history', + 'is_spam', 'ai_moderation_reason', 'abuse_flagged', ] # updateable_fields are sent in PUT requests updatable_fields = [ - "title", - "body", - "anonymous", - "anonymous_to_peers", - "course_id", - "read", - "closed", - "user_id", - "commentable_id", - "group_id", - "group_name", - "pinned", - "thread_type", - "close_reason_code", - "edit_reason_code", - "closing_user_id", - "editing_user_id", + 'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'read', + 'closed', 'user_id', 'commentable_id', 'group_id', 'group_name', 'pinned', 'thread_type', + 'close_reason_code', 'edit_reason_code', 'closing_user_id', 'editing_user_id', ] # initializable_fields are sent in POST requests - initializable_fields = updatable_fields + ["thread_type", "context"] + initializable_fields = updatable_fields + ['thread_type', 'context'] base_url = f"{settings.PREFIX}/threads" - default_retrieve_params = {"recursive": False} - type = "thread" + default_retrieve_params = {'recursive': False} + type = 'thread' @classmethod def search(cls, query_params): @@ -112,83 +58,82 @@ def search(cls, query_params): # with_responses=False internally in the comment service, so no additional # optimization is required. params = { - "page": 1, - "per_page": 20, - "course_id": query_params["course_id"], + 'page': 1, + 'per_page': 20, + 'course_id': query_params['course_id'], } - params.update(utils.strip_blank(utils.strip_none(query_params))) + params.update( + utils.strip_blank(utils.strip_none(query_params)) + ) # Convert user_id and author_id to strings if present - for field in ["user_id", "author_id"]: + for field in ['user_id', 'author_id']: if value := params.get(field): params[field] = str(value) # Handle commentable_ids/commentable_id conversion - if commentable_ids := params.get("commentable_ids"): - params["commentable_ids"] = commentable_ids.split(",") - elif commentable_id := params.get("commentable_id"): - params["commentable_ids"] = [commentable_id] - params.pop("commentable_id", None) - if query_params.get("show_deleted", False): - params["is_deleted"] = True + if commentable_ids := params.get('commentable_ids'): + params['commentable_ids'] = commentable_ids.split(',') + elif commentable_id := params.get('commentable_id'): + params['commentable_ids'] = [commentable_id] + params.pop('commentable_id', None) + params = utils.clean_forum_params(params) - if query_params.get("text"): # Handle group_ids/group_id conversion - if group_ids := params.get("group_ids"): - params["group_ids"] = [ - int(group_id) for group_id in group_ids.split(",") - ] - elif group_id := params.get("group_id"): - params["group_ids"] = [int(group_id)] - params.pop("group_id", None) + if query_params.get('text'): # Handle group_ids/group_id conversion + if group_ids := params.get('group_ids'): + params['group_ids'] = [int(group_id) for group_id in group_ids.split(',')] + elif group_id := params.get('group_id'): + params['group_ids'] = [int(group_id)] + params.pop('group_id', None) response = forum_api.search_threads(**params) else: response = forum_api.get_user_threads(**params) - if query_params.get("text"): - search_query = query_params["text"] - course_id = query_params["course_id"] - group_id = query_params["group_id"] if "group_id" in query_params else None - requested_page = params["page"] - total_results = response.get("total_results") - corrected_text = response.get("corrected_text") + if query_params.get('text'): + search_query = query_params['text'] + course_id = query_params['course_id'] + group_id = query_params['group_id'] if 'group_id' in query_params else None + requested_page = params['page'] + total_results = response.get('total_results') + corrected_text = response.get('corrected_text') # Record search result metric to allow search quality analysis. # course_id is already included in the context for the event tracker tracker.emit( - "edx.forum.searched", + 'edx.forum.searched', { - "query": search_query, - "search_type": "Content", - "corrected_text": corrected_text, - "group_id": group_id, - "page": requested_page, - "total_results": total_results, - }, + 'query': search_query, + 'search_type': 'Content', + 'corrected_text': corrected_text, + 'group_id': group_id, + 'page': requested_page, + 'total_results': total_results, + } ) log.info( 'forum_text_search query="{search_query}" corrected_text="{corrected_text}" course_id={course_id} ' - "group_id={group_id} page={requested_page} total_results={total_results}".format( + 'group_id={group_id} page={requested_page} total_results={total_results}'.format( search_query=search_query, corrected_text=corrected_text, course_id=course_id, group_id=group_id, requested_page=requested_page, - total_results=total_results, + total_results=total_results ) ) return utils.CommentClientPaginatedResult( - collection=response.get("collection", []), - page=response.get("page", 1), - num_pages=response.get("num_pages", 1), - thread_count=response.get("thread_count", 0), - corrected_text=response.get("corrected_text", None), + collection=response.get('collection', []), + page=response.get('page', 1), + num_pages=response.get('num_pages', 1), + thread_count=response.get('thread_count', 0), + corrected_text=response.get('corrected_text', None) ) @classmethod def url_for_threads(cls, params=None): - if params and params.get("commentable_id"): + if params and params.get('commentable_id'): return "{prefix}/{commentable_id}/threads".format( prefix=settings.PREFIX, - commentable_id=params["commentable_id"], + commentable_id=params['commentable_id'], ) else: return f"{settings.PREFIX}/threads" @@ -201,9 +146,9 @@ def url_for_search_threads(cls): def url(cls, action, params=None): if params is None: params = {} - if action in ["get_all", "post"]: + if action in ['get_all', 'post']: return cls.url_for_threads(params) - elif action == "search": + elif action == 'search': return cls.url_for_search_threads() else: return super().url(action, params) @@ -213,23 +158,21 @@ def url(cls, action, params=None): # that subclasses don't need to override for this. def _retrieve(self, *args, **kwargs): request_params = { - "recursive": kwargs.get("recursive"), - "with_responses": kwargs.get("with_responses", False), - "user_id": kwargs.get("user_id"), - "mark_as_read": kwargs.get("mark_as_read", True), - "resp_skip": kwargs.get("response_skip"), - "resp_limit": kwargs.get("response_limit"), - "reverse_order": kwargs.get("reverse_order", False), - "merge_question_type_responses": kwargs.get( - "merge_question_type_responses", False - ), + 'recursive': kwargs.get('recursive'), + 'with_responses': kwargs.get('with_responses', False), + 'user_id': kwargs.get('user_id'), + 'mark_as_read': kwargs.get('mark_as_read', True), + 'resp_skip': kwargs.get('response_skip'), + 'resp_limit': kwargs.get('response_limit'), + 'reverse_order': kwargs.get('reverse_order', False), + 'merge_question_type_responses': kwargs.get('merge_question_type_responses', False) } request_params = utils.clean_forum_params(request_params) course_id = kwargs.get("course_id") if not course_id: _, course_id = is_forum_v2_enabled_for_thread(self.id) - if user_id := request_params.get("user_id"): - request_params["user_id"] = str(user_id) + if user_id := request_params.get('user_id'): + request_params['user_id'] = str(user_id) response = forum_api.get_thread( thread_id=self.id, params=request_params, @@ -238,7 +181,7 @@ def _retrieve(self, *args, **kwargs): self._update_from_response(response) def flagAbuse(self, user, voteable, course_id=None): - if voteable.type != "thread": + if voteable.type != 'thread': raise utils.CommentClientRequestError("Can only flag threads") course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) @@ -246,12 +189,12 @@ def flagAbuse(self, user, voteable, course_id=None): thread_id=voteable.id, action="flag", user_id=str(user.id), - course_id=str(course_key), + course_id=str(course_key) ) voteable._update_from_response(response) def unFlagAbuse(self, user, voteable, removeAll, course_id=None): - if voteable.type != "thread": + if voteable.type != 'thread': raise utils.CommentClientRequestError("Can only unflag threads") course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) @@ -260,7 +203,7 @@ def unFlagAbuse(self, user, voteable, removeAll, course_id=None): action="unflag", user_id=user.id, update_all=bool(removeAll), - course_id=str(course_key), + course_id=str(course_key) ) voteable._update_from_response(response) @@ -268,14 +211,18 @@ def unFlagAbuse(self, user, voteable, removeAll, course_id=None): def pin(self, user, thread_id, course_id=None): course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) response = forum_api.pin_thread( - user_id=user.id, thread_id=thread_id, course_id=str(course_key) + user_id=user.id, + thread_id=thread_id, + course_id=str(course_key) ) self._update_from_response(response) def un_pin(self, user, thread_id, course_id=None): course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) response = forum_api.unpin_thread( - user_id=user.id, thread_id=thread_id, course_id=str(course_key) + user_id=user.id, + thread_id=thread_id, + course_id=str(course_key) ) self._update_from_response(response) @@ -288,15 +235,12 @@ def get_user_threads_count(cls, user_id, course_ids): query_params = { "course_id": {"$in": course_ids}, "author_id": str(user_id), - "is_deleted": {"$ne": True}, - "_type": "CommentThread", + "_type": "CommentThread" } - return CommentThread()._collection.count_documents( - query_params - ) # pylint: disable=protected-access + return CommentThread()._collection.count_documents(query_params) # pylint: disable=protected-access @classmethod - def _delete_thread(cls, thread_id, course_id=None, deleted_by=None): + def _delete_thread(cls, thread_id, course_id=None): """ Deletes a thread """ @@ -313,53 +257,34 @@ def _delete_thread(cls, thread_id, course_id=None, deleted_by=None): ) from exc start_time = time.perf_counter() - # backend.delete_comments_of_a_thread(thread_id) - count_of_response_deleted, count_of_replies_deleted = ( - backend.soft_delete_comments_of_a_thread(thread_id, deleted_by) - ) - log.info( - f"{prefix} Delete comments of thread {time.perf_counter() - start_time} sec" - ) + backend.delete_comments_of_a_thread(thread_id) + log.info(f"{prefix} Delete comments of thread {time.perf_counter() - start_time} sec") try: start_time = time.perf_counter() serialized_data = prepare_thread_api_response(thread, backend) - log.info( - f"{prefix} Prepare response {time.perf_counter() - start_time} sec" - ) + log.info(f"{prefix} Prepare response {time.perf_counter() - start_time} sec") except ValidationError as error: log.error(f"Validation error in get_thread: {error}") - raise ForumV2RequestError( - "Failed to prepare thread API response" - ) from error + raise ForumV2RequestError("Failed to prepare thread API response") from error start_time = time.perf_counter() backend.delete_subscriptions_of_a_thread(thread_id) - log.info( - f"{prefix} Delete subscriptions {time.perf_counter() - start_time} sec" - ) + log.info(f"{prefix} Delete subscriptions {time.perf_counter() - start_time} sec") start_time = time.perf_counter() - # result = backend.delete_thread(thread_id) - result = backend.soft_delete_thread(thread_id, deleted_by) + result = backend.delete_thread(thread_id) log.info(f"{prefix} Delete thread {time.perf_counter() - start_time} sec") if result and not (thread["anonymous"] or thread["anonymous_to_peers"]): start_time = time.perf_counter() backend.update_stats_for_course( - thread["author_id"], - thread["course_id"], - threads=-1, - responses=-count_of_response_deleted, - replies=-count_of_replies_deleted, - deleted_threads=1, - deleted_responses=count_of_response_deleted, - deleted_replies=count_of_replies_deleted, + thread["author_id"], thread["course_id"], threads=-1 ) log.info(f"{prefix} Update stats {time.perf_counter() - start_time} sec") return serialized_data @classmethod - def delete_user_threads(cls, user_id, course_ids, deleted_by=None): + def delete_user_threads(cls, user_id, course_ids): """ Deletes threads of user in the given course_ids. TODO: Add support for MySQL backend as well @@ -368,65 +293,21 @@ def delete_user_threads(cls, user_id, course_ids, deleted_by=None): query_params = { "course_id": {"$in": course_ids}, "author_id": str(user_id), - "is_deleted": {"$ne": True}, } threads_deleted = 0 threads = CommentThread().get_list(**query_params) - log.info( - f"<> Fetched threads for user {user_id} in {time.time() - start_time} seconds" - ) + log.info(f"<> Fetched threads for user {user_id} in {time.time() - start_time} seconds") for thread in threads: start_time = time.time() thread_id = thread.get("_id") course_id = thread.get("course_id") if thread_id: - cls._delete_thread( - thread_id, course_id=course_id, deleted_by=deleted_by - ) + cls._delete_thread(thread_id, course_id=course_id) threads_deleted += 1 - log.info( - f"<> Deleted thread {thread_id} in {time.time() - start_time} seconds." - f" Thread Found: {thread_id is not None}" - ) + log.info(f"<> Deleted thread {thread_id} in {time.time() - start_time} seconds." + f" Thread Found: {thread_id is not None}") return threads_deleted - @classmethod - def get_user_deleted_threads_count(cls, user_id, course_ids): - """ - Returns count of deleted threads for user in the given course_ids. - """ - query_params = { - "course_id": {"$in": course_ids}, - "author_id": str(user_id), - "_type": "CommentThread", - "is_deleted": True, - } - return CommentThread()._collection.count_documents( - query_params - ) # pylint: disable=protected-access - - @classmethod - def restore_user_deleted_threads(cls, user_id, course_ids, restored_by=None): - """ - Restores (undeletes) threads of user in the given course_ids by setting is_deleted=False. - """ - return forum_api.restore_user_deleted_threads( - user_id=str(user_id), - course_ids=course_ids, - course_id=course_ids[0] if course_ids else None, - restored_by=restored_by, - ) - - @classmethod - def restore_thread(cls, thread_id, course_id=None, restored_by=None): - """ - Restores an individual soft-deleted thread by setting is_deleted=False - Public method for individual thread restoration - """ - return forum_api.restore_thread( - thread_id=thread_id, course_id=course_id, restored_by=restored_by - ) - def _url_for_flag_abuse_thread(thread_id): return f"{settings.PREFIX}/threads/{thread_id}/abuse_flag" From a0133462cee96cd7c608485f6f79f4b969dc552b Mon Sep 17 00:00:00 2001 From: Akanshu Aich Date: Wed, 7 Jan 2026 21:48:35 +0530 Subject: [PATCH 48/54] fix: improve logging for user retirement errors with exception details (#76) Ticket: https://2u-internal.atlassian.net/browse/BOMS-290 --- openedx/core/djangoapps/user_api/accounts/views.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index f338fd510177..5180c1adb0ec 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -1108,7 +1108,7 @@ def post(self, request): user=retirement.user, ) except UserRetirementStatus.DoesNotExist: - log.error( + log.exception( 'UserRetirementStatus not found for retirement action' ) record_exception() @@ -1118,7 +1118,7 @@ def post(self, request): user_id = retirement.user.id except AttributeError: user_id = 'unknown' - log.error( + log.exception( 'RetirementStateError during user retirement: user_id=%s, error=%s', user_id, str(exc) ) @@ -1129,9 +1129,10 @@ def post(self, request): user_id = retirement.user.id except AttributeError: user_id = 'unknown' - log.error( - 'Unexpected error during user retirement: user_id=%s, error=%s', - user_id, str(exc) + exception_type = type(exc).__name__ + log.exception( + 'Unexpected error during user retirement: user_id=%s, exception_type=%s, error=%s', + user_id, exception_type, str(exc) ) record_exception() return Response(str(exc), status=status.HTTP_500_INTERNAL_SERVER_ERROR) From 597a2a0cc3e9937c26de43411bf4b117bb03c57f Mon Sep 17 00:00:00 2001 From: Devasia Joseph Date: Thu, 8 Jan 2026 19:11:50 +0530 Subject: [PATCH 49/54] fix: improve beta label contrast in gamesxblock (#80) * fix: improve beta label contrast in gamesxblock * fix: address copilot comment Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cms/static/sass/elements/_modules.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cms/static/sass/elements/_modules.scss b/cms/static/sass/elements/_modules.scss index 1e9f52691fc8..f1c871ba1d95 100644 --- a/cms/static/sass/elements/_modules.scss +++ b/cms/static/sass/elements/_modules.scss @@ -163,8 +163,8 @@ display: inline-block; color: $uxpl-green-base; - background-color: theme-color("inverse"); - border-color: theme-color("inverse"); + background-color: $white; + border-color: $white; border-radius: 3px; font-size: 90%; From f2e024cc9e6a8550621afb0709c0badcfc2f97ab Mon Sep 17 00:00:00 2001 From: jajjibhai008 <86868918+jajjibhai008@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:23:07 +0000 Subject: [PATCH 50/54] feat: Upgrade Python dependency edx-enterprise Commit generated by workflow `edx/edx-platform/.github/workflows/upgrade-one-python-dependency.yml@refs/heads/release-ulmo` --- requirements/common_constraints.txt | 7 ------- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 16 ++++------------ requirements/edx/development.txt | 12 ++---------- requirements/edx/doc.txt | 11 ++--------- requirements/edx/testing.txt | 11 ++--------- requirements/pip.txt | 4 +--- 7 files changed, 12 insertions(+), 51 deletions(-) diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt index 1f3e81f50334..748858b7015a 100644 --- a/requirements/common_constraints.txt +++ b/requirements/common_constraints.txt @@ -22,10 +22,3 @@ Django<6.0 # elastic search changelog: https://www.elastic.co/guide/en/enterprise-search/master/release-notes-7.14.0.html # See https://github.com/openedx/edx-platform/issues/35126 for more info elasticsearch<7.14.0 - -# pip 25.3 is incompatible with pip-tools hence causing failures during the build process -# Make upgrade command and all requirements upgrade jobs are broken due to this. -# See issue https://github.com/openedx/public-engineering/issues/440 for details regarding the ongoing fix. -# The constraint can be removed once a release (pip-tools > 7.5.1) is available with support for pip 25.3 -# Issue to track this dependency and unpin later on: https://github.com/openedx/edx-lint/issues/503 -pip<25.3 diff --git a/requirements/constraints.txt b/requirements/constraints.txt index e4ecb8e51cde..92b9d9a5c5ec 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -42,7 +42,7 @@ django-stubs<6 # 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==6.5.7 +edx-enterprise==6.6.1 # Date: 2023-07-26 # Our legacy Sass code is incompatible with anything except this ancient libsass version. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index ac4608f0eca1..42c7d55b301b 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -9,9 +9,7 @@ acid-xblock==0.4.1 aiohappyeyeballs==2.6.1 # via aiohttp aiohttp==3.13.0 - # via - # geoip2 - # openai + # via geoip2 aiosignal==1.4.0 # via aiohttp amqp==5.3.1 @@ -476,7 +474,7 @@ edx-drf-extensions==10.6.0 # edxval # enterprise-integrated-channels # openedx-learning -edx-enterprise==6.5.7 +edx-enterprise==6.6.1 # via # -c requirements/constraints.txt # -r requirements/edx/kernel.in @@ -807,13 +805,10 @@ oauthlib==3.3.1 # xblocks-contrib olxcleaner==0.3.0 # via -r requirements/edx/kernel.in -openai==0.28.1 - # via - # -c requirements/constraints.txt - # edx-enterprise openedx-atlas==0.7.0 # via # -r requirements/edx/kernel.in + # edx-enterprise # enterprise-integrated-channels # openedx-forum openedx-calc==4.0.2 @@ -1052,7 +1047,6 @@ requests==2.32.5 # google-cloud-storage # mailsnake # meilisearch - # openai # openedx-forum # optimizely-sdk # pylti1p3 @@ -1172,9 +1166,7 @@ tomlkit==0.13.3 # openedx-learning # snowflake-connector-python tqdm==4.67.1 - # via - # nltk - # openai + # via nltk typing-extensions==4.15.0 # via # aiosignal diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index f3ae39f02e4d..cddac634325a 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -22,7 +22,6 @@ aiohttp==3.13.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # geoip2 - # openai aiosignal==1.4.0 # via # -r requirements/edx/doc.txt @@ -749,7 +748,7 @@ edx-drf-extensions==10.6.0 # edxval # enterprise-integrated-channels # openedx-learning -edx-enterprise==6.5.7 +edx-enterprise==6.6.1 # via # -c requirements/constraints.txt # -r requirements/edx/doc.txt @@ -1345,16 +1344,11 @@ olxcleaner==0.3.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openai==0.28.1 - # via - # -c requirements/constraints.txt - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt - # edx-enterprise openedx-atlas==0.7.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt + # edx-enterprise # enterprise-integrated-channels # openedx-forum openedx-calc==4.0.2 @@ -1826,7 +1820,6 @@ requests==2.32.5 # google-cloud-storage # mailsnake # meilisearch - # openai # openedx-forum # optimizely-sdk # pact-python @@ -2098,7 +2091,6 @@ tqdm==4.67.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # nltk - # openai types-pyyaml==6.0.12.20250915 # via # django-stubs diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 4ffb83fdf8ca..1d5ef3e5d59a 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -16,7 +16,6 @@ aiohttp==3.13.0 # via # -r requirements/edx/base.txt # geoip2 - # openai aiosignal==1.4.0 # via # -r requirements/edx/base.txt @@ -559,7 +558,7 @@ edx-drf-extensions==10.6.0 # edxval # enterprise-integrated-channels # openedx-learning -edx-enterprise==6.5.7 +edx-enterprise==6.6.1 # via # -c requirements/constraints.txt # -r requirements/edx/base.txt @@ -979,14 +978,10 @@ oauthlib==3.3.1 # xblocks-contrib olxcleaner==0.3.0 # via -r requirements/edx/base.txt -openai==0.28.1 - # via - # -c requirements/constraints.txt - # -r requirements/edx/base.txt - # edx-enterprise openedx-atlas==0.7.0 # via # -r requirements/edx/base.txt + # edx-enterprise # enterprise-integrated-channels # openedx-forum openedx-calc==4.0.2 @@ -1282,7 +1277,6 @@ requests==2.32.5 # google-cloud-storage # mailsnake # meilisearch - # openai # openedx-forum # optimizely-sdk # pylti1p3 @@ -1485,7 +1479,6 @@ tqdm==4.67.1 # via # -r requirements/edx/base.txt # nltk - # openai typing-extensions==4.15.0 # via # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index c23542e20e49..ce7040f87793 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -14,7 +14,6 @@ aiohttp==3.13.0 # via # -r requirements/edx/base.txt # geoip2 - # openai aiosignal==1.4.0 # via # -r requirements/edx/base.txt @@ -580,7 +579,7 @@ edx-drf-extensions==10.6.0 # edxval # enterprise-integrated-channels # openedx-learning -edx-enterprise==6.5.7 +edx-enterprise==6.6.1 # via # -c requirements/constraints.txt # -r requirements/edx/base.txt @@ -1024,14 +1023,10 @@ oauthlib==3.3.1 # xblocks-contrib olxcleaner==0.3.0 # via -r requirements/edx/base.txt -openai==0.28.1 - # via - # -c requirements/constraints.txt - # -r requirements/edx/base.txt - # edx-enterprise openedx-atlas==0.7.0 # via # -r requirements/edx/base.txt + # edx-enterprise # enterprise-integrated-channels # openedx-forum openedx-calc==4.0.2 @@ -1393,7 +1388,6 @@ requests==2.32.5 # google-cloud-storage # mailsnake # meilisearch - # openai # openedx-forum # optimizely-sdk # pact-python @@ -1559,7 +1553,6 @@ tqdm==4.67.1 # via # -r requirements/edx/base.txt # nltk - # openai typing-extensions==4.15.0 # via # -r requirements/edx/base.txt diff --git a/requirements/pip.txt b/requirements/pip.txt index c6158d38e981..dec15874f740 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -9,8 +9,6 @@ wheel==0.45.1 # The following packages are considered to be unsafe in a requirements file: pip==25.2 - # via - # -c requirements/common_constraints.txt - # -r requirements/pip.in + # via -r requirements/pip.in setuptools==80.9.0 # via -r requirements/pip.in From c71aca8903a6109f1621b201cd3d8ad7017267b5 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 9 Jan 2026 17:21:05 +0530 Subject: [PATCH 51/54] feat: add new API to retrieve all components in a unit (#70) * feat: add new API to retrieve all components in a unit * feat: add waffle flag to enable unit expanded view * fix: test cases --- .../v1/serializers/course_waffle_flags.py | 9 ++ .../contentstore/rest_api/v1/urls.py | 6 + .../rest_api/v1/views/__init__.py | 7 +- .../views/tests/test_course_waffle_flags.py | 1 + .../rest_api/v1/views/unit_handler.py | 135 ++++++++++++++++++ cms/djangoapps/contentstore/toggles.py | 20 +++ 6 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 cms/djangoapps/contentstore/rest_api/v1/views/unit_handler.py diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py index 3efb7b6226d4..fde90163f803 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py @@ -11,6 +11,7 @@ class CourseWaffleFlagsSerializer(serializers.Serializer): """ Serializer for course waffle flags """ + use_new_home_page = serializers.SerializerMethodField() use_new_custom_pages = serializers.SerializerMethodField() use_new_schedule_details_page = serializers.SerializerMethodField() @@ -31,6 +32,7 @@ class CourseWaffleFlagsSerializer(serializers.Serializer): use_react_markdown_editor = serializers.SerializerMethodField() use_video_gallery_flow = serializers.SerializerMethodField() enable_course_optimizer_check_prev_run_links = serializers.SerializerMethodField() + enable_unit_expanded_view = serializers.SerializerMethodField() def get_course_key(self): """ @@ -175,3 +177,10 @@ def get_enable_course_optimizer_check_prev_run_links(self, obj): """ course_key = self.get_course_key() return toggles.enable_course_optimizer_check_prev_run_links(course_key) + + def get_enable_unit_expanded_view(self, obj): + """ + Method to get the enable_unit_expanded_view waffle flag + """ + course_key = self.get_course_key() + return toggles.enable_unit_expanded_view(course_key) diff --git a/cms/djangoapps/contentstore/rest_api/v1/urls.py b/cms/djangoapps/contentstore/rest_api/v1/urls.py index 685a81d778ce..8a94f0b0e040 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v1/urls.py @@ -25,6 +25,7 @@ HomePageView, ProctoredExamSettingsView, ProctoringErrorsView, + UnitComponentsView, VideoDownloadView, VideoUsageView, vertical_container_children_redirect_view, @@ -144,6 +145,11 @@ CourseWaffleFlagsView.as_view(), name="course_waffle_flags" ), + re_path( + fr'^unit_handler/{settings.USAGE_KEY_PATTERN}$', + UnitComponentsView.as_view(), + name="unit_components" + ), # Authoring API # Do not use under v1 yet (Nov. 23). The Authoring API is still experimental and the v0 versions should be used diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py index d4fcfd5f2e3f..25c0157904e9 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py @@ -14,9 +14,6 @@ from .proctoring import ProctoredExamSettingsView, ProctoringErrorsView from .settings import CourseSettingsView from .textbooks import CourseTextbooksView +from .unit_handler import UnitComponentsView from .vertical_block import ContainerHandlerView, vertical_container_children_redirect_view -from .videos import ( - CourseVideosView, - VideoDownloadView, - VideoUsageView, -) +from .videos import CourseVideosView, VideoDownloadView, VideoUsageView diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py index f45cc48810d6..a788ce4af3b5 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py @@ -38,6 +38,7 @@ class CourseWaffleFlagsViewTest(CourseTestCase): "use_react_markdown_editor": False, "use_video_gallery_flow": False, "enable_course_optimizer_check_prev_run_links": False, + "enable_unit_expanded_view": False, } def setUp(self): diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/unit_handler.py b/cms/djangoapps/contentstore/rest_api/v1/views/unit_handler.py new file mode 100644 index 000000000000..18a2376b3922 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/unit_handler.py @@ -0,0 +1,135 @@ +"""API Views for unit components handler""" + +import logging + +import edx_api_doc_tools as apidocs +from django.http import HttpResponseBadRequest, HttpResponseForbidden +from opaque_keys.edx.keys import UsageKey +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from cms.djangoapps.contentstore.rest_api.v1.mixins import ContainerHandlerMixin +from cms.djangoapps.contentstore.toggles import enable_unit_expanded_view +from openedx.core.lib.api.view_utils import view_auth_classes +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError + +log = logging.getLogger(__name__) + + +@view_auth_classes(is_authenticated=True) +class UnitComponentsView(APIView, ContainerHandlerMixin): + """ + View to get all components in a unit by usage key. + """ + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + "usage_key_string", + apidocs.ParameterLocation.PATH, + description="Unit usage key", + ), + ], + responses={ + 200: "List of components in the unit", + 400: "Invalid usage key or unit not found.", + 401: "The requester is not authenticated.", + 404: "The requested unit does not exist.", + }, + ) + def get(self, request: Request, usage_key_string: str): + """ + Get all components in a unit. + + **Example Request** + + GET /api/contentstore/v1/unit_handler/{usage_key_string} + + **Response Values** + + If the request is successful, an HTTP 200 "OK" response is returned. + + The HTTP 200 response contains a dict with a list of all components + in the unit, including their display names, block types, and block IDs. + + **Example Response** + + ```json + { + "unit_id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_id", + "display_name": "My Unit", + "components": [ + { + "block_id": "block-v1:edX+DemoX+Demo_Course+type@video+block@video_id", + "block_type": "video", + "display_name": "Introduction Video" + }, + { + "block_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@html_id", + "block_type": "html", + "display_name": "Text Content" + }, + { + "block_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@problem_id", + "block_type": "problem", + "display_name": "Practice Problem" + } + ] + } + ``` + """ + try: + usage_key = UsageKey.from_string(usage_key_string) + except Exception as e: # pylint: disable=broad-exception-caught + log.error(f"Invalid usage key: {usage_key_string}, error: {str(e)}") + return HttpResponseBadRequest("Invalid usage key format") + + try: + # Get the unit xblock + unit_xblock = modulestore().get_item(usage_key) + + # Verify it's a vertical (unit) + if unit_xblock.category != "vertical": + return HttpResponseBadRequest( + "The provided usage key is not a unit (vertical)" + ) + + if not enable_unit_expanded_view(unit_xblock.location.course_key): + return HttpResponseForbidden( + "Unit expanded view is disabled for this course" + ) + + components = [] + + # Get all children (components) of the unit + if unit_xblock.has_children: + for child_usage_key in unit_xblock.children: + try: + child_xblock = modulestore().get_item(child_usage_key) + components.append( + { + "block_id": str(child_xblock.location), + "block_type": child_xblock.category, + "display_name": child_xblock.display_name_with_default, + } + ) + except ItemNotFoundError: + log.warning(f"Child block not found: {child_usage_key}") + continue + + response_data = { + "unit_id": str(usage_key), + "display_name": unit_xblock.display_name_with_default, + "components": components, + } + + return Response(response_data) + + except ItemNotFoundError: + log.error(f"Unit not found: {usage_key_string}") + return HttpResponseBadRequest("Unit not found") + except Exception as e: # pylint: disable=broad-exception-caught + log.error(f"Error retrieving unit components: {str(e)}") + return HttpResponseBadRequest(f"Error retrieving unit components: {str(e)}") diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py index c287f8c4dbec..c3dba4a6f4a4 100644 --- a/cms/djangoapps/contentstore/toggles.py +++ b/cms/djangoapps/contentstore/toggles.py @@ -682,3 +682,23 @@ def enable_course_optimizer_check_prev_run_links(course_key): Returns a boolean if previous run course optimizer feature is enabled for the given course. """ return ENABLE_COURSE_OPTIMIZER_CHECK_PREV_RUN_LINKS.is_enabled(course_key) + + +# .. toggle_name: contentstore.enable_unit_expanded_view +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: When enabled, the Unit Expanded View feature in the Course Outline is activated. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2026-01-01 +# .. toggle_target_removal_date: 2026-06-01 +# .. toggle_tickets: TNL2-473 +ENABLE_UNIT_EXPANDED_VIEW = CourseWaffleFlag( + f"{CONTENTSTORE_NAMESPACE}.enable_unit_expanded_view", __name__ +) + + +def enable_unit_expanded_view(course_key): + """ + Returns a boolean if the Unit Expanded View feature is enabled for the given course. + """ + return ENABLE_UNIT_EXPANDED_VIEW.is_enabled(course_key) From afd869cfc0ba6e4419fec2aca9ee909e8aa3b2d3 Mon Sep 17 00:00:00 2001 From: Nathan Sprenkle Date: Fri, 9 Jan 2026 12:22:38 -0500 Subject: [PATCH 52/54] feat: add toggle for unifying site & translation language (#81) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lms/djangoapps/courseware/toggles.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/toggles.py b/lms/djangoapps/courseware/toggles.py index f9f083cad42e..3ce78c554dd0 100644 --- a/lms/djangoapps/courseware/toggles.py +++ b/lms/djangoapps/courseware/toggles.py @@ -2,7 +2,7 @@ Toggles for courseware in-course experience. """ -from edx_toggles.toggles import SettingToggle, WaffleSwitch +from edx_toggles.toggles import SettingToggle, WaffleFlag, WaffleSwitch from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag @@ -168,6 +168,19 @@ f'{WAFFLE_FLAG_NAMESPACE}.discovery_default_language_filter', __name__ ) +# .. toggle_name: courseware.unify_site_and_translation_language +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Update LMS to use site language for xpert unit translations and enable new header site language switcher. +# .. toggle_use_cases: opt_in +# .. toggle_creation_date: 2026-01-08 +# .. toggle_target_removal_date: None +# .. toggle_warning: None. +# .. toggle_tickets: https://github.com/edx/edx-platform/pull/81 +ENABLE_UNIFIED_SITE_AND_TRANSLATION_LANGUAGE = WaffleFlag( + f'{WAFFLE_FLAG_NAMESPACE}.unify_site_and_translation_language', __name__ +) + def course_exit_page_is_active(course_key): return COURSEWARE_MICROFRONTEND_COURSE_EXIT_PAGE.is_enabled(course_key) From 1c1e08ffe0e835cce7691bc6d23e82aa05220798 Mon Sep 17 00:00:00 2001 From: Akanshu Aich Date: Mon, 12 Jan 2026 21:14:30 +0530 Subject: [PATCH 53/54] fix: handle CourseOverview.DoesNotExist exception in optout creation (#77) Ticket: https://2u-internal.atlassian.net/browse/BOMS-370 --- lms/djangoapps/bulk_email/signals.py | 10 ++++- .../bulk_email/tests/test_signals.py | 40 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/bulk_email/signals.py b/lms/djangoapps/bulk_email/signals.py index 7402dca75482..da18a459aeaa 100644 --- a/lms/djangoapps/bulk_email/signals.py +++ b/lms/djangoapps/bulk_email/signals.py @@ -7,6 +7,7 @@ from eventtracking import tracker from common.djangoapps.student.models import CourseEnrollment +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_MAILINGS from edx_ace.signals import ACE_MESSAGE_SENT @@ -27,7 +28,14 @@ def force_optout_all(sender, **kwargs): # lint-amnesty, pylint: disable=unused- raise TypeError('Expected a User type, but received None.') for enrollment in CourseEnrollment.objects.filter(user=user): - Optout.objects.get_or_create(user=user, course_id=enrollment.course.id) + try: + Optout.objects.get_or_create(user=user, course_id=enrollment.course.id) + except CourseOverview.DoesNotExist: + log.warning( + f"CourseOverview not found for enrollment {enrollment.id} (user: {user.id}), " + f"skipping optout creation. This may mean the course was deleted." + ) + continue @receiver(ACE_MESSAGE_SENT) diff --git a/lms/djangoapps/bulk_email/tests/test_signals.py b/lms/djangoapps/bulk_email/tests/test_signals.py index 1a3715284b12..01ad9312da4c 100644 --- a/lms/djangoapps/bulk_email/tests/test_signals.py +++ b/lms/djangoapps/bulk_email/tests/test_signals.py @@ -10,9 +10,11 @@ from django.core.management import call_command from django.urls import reverse +from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import AdminFactory, CourseEnrollmentFactory, UserFactory from lms.djangoapps.bulk_email.models import BulkEmailFlag, Optout from lms.djangoapps.bulk_email.signals import force_optout_all +from opaque_keys.edx.keys import CourseKey from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order @@ -85,3 +87,41 @@ def test_optout_course(self): assert len(mail.outbox) == 1 assert len(mail.outbox[0].to) == 1 assert mail.outbox[0].to[0] == self.instructor.email + + @patch('lms.djangoapps.bulk_email.signals.log.warning') + def test_optout_handles_missing_course_overview(self, mock_log_warning): + """ + Test that force_optout_all gracefully handles CourseEnrollments + with missing CourseOverview records + """ + # Create a course key for a course that doesn't exist in CourseOverview + nonexistent_course_key = CourseKey.from_string('course-v1:TestX+Missing+2023') + + # Create an enrollment with a course_id that doesn't have a CourseOverview + CourseEnrollment.objects.create( + user=self.student, + course_id=nonexistent_course_key, + mode='honor' + ) + + # Verify the orphaned enrollment exists + assert CourseEnrollment.objects.filter( + user=self.student, + course_id=nonexistent_course_key + ).exists() + + force_optout_all(sender=self.__class__, user=self.student) + + # Verify that a warning was logged for the missing CourseOverview + mock_log_warning.assert_called() + call_args = mock_log_warning.call_args[0][0] + assert "CourseOverview not found for enrollment" in call_args + assert f"user: {self.student.id}" in call_args + assert "skipping optout creation" in call_args + + # Verify that optouts were created for valid courses only + valid_course_optouts = Optout.objects.filter(user=self.student, course_id=self.course.id) + missing_course_optouts = Optout.objects.filter(user=self.student, course_id=nonexistent_course_key) + + assert valid_course_optouts.count() == 1 + assert missing_course_optouts.count() == 0 From daea19275f62fd553b690777ff752a8a33cbece9 Mon Sep 17 00:00:00 2001 From: irfanuddinahmad Date: Tue, 13 Jan 2026 19:37:18 +0500 Subject: [PATCH 54/54] chore: updated version for enterprise-integrated-channels --- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 42c7d55b301b..0f6673c95291 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -564,7 +564,7 @@ enmerkar==0.7.1 # via enmerkar-underscore enmerkar-underscore==2.4.0 # via -r requirements/edx/kernel.in -enterprise-integrated-channels==0.1.25 +enterprise-integrated-channels==0.1.28 # via -r requirements/edx/bundled.in event-tracking==3.3.0 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index cddac634325a..242a4ebb9dde 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -876,7 +876,7 @@ enmerkar-underscore==2.4.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -enterprise-integrated-channels==0.1.25 +enterprise-integrated-channels==0.1.28 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 1d5ef3e5d59a..b24e3874e01c 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -654,7 +654,7 @@ enmerkar==0.7.1 # enmerkar-underscore enmerkar-underscore==2.4.0 # via -r requirements/edx/base.txt -enterprise-integrated-channels==0.1.25 +enterprise-integrated-channels==0.1.28 # via -r requirements/edx/base.txt event-tracking==3.3.0 # via diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index ce7040f87793..3e8df08b7a63 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -677,7 +677,7 @@ enmerkar==0.7.1 # enmerkar-underscore enmerkar-underscore==2.4.0 # via -r requirements/edx/base.txt -enterprise-integrated-channels==0.1.25 +enterprise-integrated-channels==0.1.28 # via -r requirements/edx/base.txt event-tracking==3.3.0 # via