diff --git a/.gcloudignore b/.gcloudignore index 916ca69d78bd3..59d4e10c55364 100644 --- a/.gcloudignore +++ b/.gcloudignore @@ -119,8 +119,8 @@ third_party/static/ckeditor-bootstrapck-1.0.0/skins/ckbuilder.jar/ third_party/static/ckeditor-bootstrapck-1.0.0/skins/bootstrapck/sample/ third_party/static/ckeditor-bootstrapck-1.0.0/skins/bootstrapck/scss/ third_party/static/fontawesome-free-5.9.0-web/ -third_party/static/guppy-f509e1/site/ -third_party/static/guppy-f509e1/test/ +third_party/static/guppy-c1ef610/site/ +third_party/static/guppy-c1ef610/test/ third_party/static/MathJax-2.7.5/docs/ third_party/static/MathJax-2.7.5/fonts/HTML-CSS/Gyre-Pagella/ third_party/static/MathJax-2.7.5/fonts/HTML-CSS/Gyre-Termes/ diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 347fa367b97a1..1785fa1d775ba 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -55,7 +55,6 @@ /core/templates/modules/ @oppia/lace-frontend-reviewers /core/templates/pages/common-imports.ts @oppia/lace-frontend-reviewers /core/templates/pages/oppia-root/ @oppia/lace-frontend-reviewers -/core/templates/pages/lightweight-oppia-root/ @oppia/lace-frontend-reviewers /core/templates/i18n/ @oppia/lace-frontend-reviewers /core/templates/services/contextual/logger.service.ts @oppia/lace-frontend-reviewers /core/templates/services/contextual/logger.service.spec.ts @oppia/lace-frontend-reviewers @@ -93,7 +92,7 @@ /core/templates/services/assets-backend-api.service*.ts @oppia/lace-frontend-reviewers /core/templates/services/entity-translations.services.ts @oppia/lace-frontend-reviewers /core/templates/services/entity-voiceovers.services.ts @oppia/lace-frontend-reviewers -/core/templates/services/voiceover-regeneration-task-mapping-service.ts @oppia/lace-frontend-reviewers +/core/templates/services/voiceover-regeneration-job-service.ts @oppia/lace-frontend-reviewers /core/templates/services/voiceover-language-management-service.ts @oppia/lace-frontend-reviewers /core/templates/services/automatic-voiceover-highlight-service.ts @oppia/lace-frontend-reviewers @@ -304,6 +303,10 @@ /core/templates/domain/promo_bar/ @oppia/lace-frontend-reviewers /core/templates/filters/ @oppia/lace-frontend-reviewers /core/templates/services/attribution.service*.ts @oppia/lace-frontend-reviewers +/core/templates/components/campaign-banner/campaign-banner.component*.ts @oppia/lace-frontend-reviewers +/core/templates/components/campaign-banner/campaign-banner.component.html @oppia/lace-frontend-reviewers +/core/templates/components/campaign-banner/campaign-banner.component.css @oppia/lace-frontend-reviewers +/core/templates/components/campaign-banner/campaign-banner-module.ts @oppia/lace-frontend-reviewers # Global frontend services diff --git a/.github/ISSUE_TEMPLATE/3_e2e_acceptance_error_template.yml b/.github/ISSUE_TEMPLATE/3_e2e_acceptance_error_template.yml index 9aab0392e6a20..42806d07b344b 100644 --- a/.github/ISSUE_TEMPLATE/3_e2e_acceptance_error_template.yml +++ b/.github/ISSUE_TEMPLATE/3_e2e_acceptance_error_template.yml @@ -14,6 +14,19 @@ body: [this wiki page](https://github.com/oppia/oppia/wiki/If-CI-checks-fail-on-your-PR) and are filing this issue as part of the process described there. Thanks! + - type: checkboxes + id: read-procedure + attributes: + label: I have read the procedure + description: > + Please confirm that you have read the note above by selecting the option + that best describes your situation. + options: + - label: I reproduced the flake on the develop branch. + - label: I observed this flake directly on the develop branch. + - label: The changes in my PR are unrelated to the failure. + validations: + required: true - type: dropdown id: ci-test-type attributes: diff --git a/.github/actions/generate-build-files/action.yml b/.github/actions/generate-build-files/action.yml index 0cf1ff7be28c0..62d985d9c4aa6 100644 --- a/.github/actions/generate-build-files/action.yml +++ b/.github/actions/generate-build-files/action.yml @@ -3,55 +3,20 @@ description: 'Generate build files' runs: using: composite steps: - - name: Attempt to download build files - id: download_artifact - uses: actions/download-artifact@v4 - continue-on-error: true + - name: Restore build files from cache + id: restore_build_files_cache + uses: actions/cache@v4 with: - name: cached_build_files - path: /home/runner/work/oppia - - name: Unzip build files - if: steps.download_artifact.outcome != 'failure' - run: | - echo "Successfully downloaded build files" - pwd - ls -la - unzip build_files.zip - rm build_files.zip - echo "Files in ./:" - ls -la . - echo "Files in oppia_tools:" - ls -la oppia_tools - echo "Files in oppia:" - ls -la oppia - echo "Files in build:" - ls -la oppia/build - echo "Files in third_party:" - ls -la oppia/third_party - echo "Contents of requirements_dev.txt:" - cat ./oppia/requirements_dev.txt - echo "Contents of requirements.txt:" - cat ./oppia/requirements.txt - working-directory: /home/runner/work/oppia - shell: bash + key: ${{ runner.os }}-build-files-${{ github.sha }} + path: | + /home/runner/work/oppia/oppia/build + /home/runner/work/oppia/oppia/webpack_bundles + /home/runner/work/oppia/oppia/app.yaml + /home/runner/work/oppia/oppia/assets/hashes.json + /home/runner/work/oppia/oppia/backend_prod_files + /home/runner/work/oppia/oppia/dist + /home/runner/work/oppia/oppia/third_party/generated - name: Build Webpack - if: steps.download_artifact.outcome == 'failure' - run: | - echo "Failed to download build files. Regenerating." - python -m scripts.build --prod_env - shell: bash - - name: Zip build files - # We avoid using ../ or absolute paths because unzip treats these as - # security issues and will refuse to follow them. - run: | - zip -rqy build_files.zip oppia/build oppia/webpack_bundles oppia/app.yaml oppia/assets/hashes.json oppia/backend_prod_files oppia/dist oppia/third_party/generated - working-directory: /home/runner/work/oppia + if: steps.restore_build_files_cache.outputs.cache-hit != 'true' + run: python -m scripts.build --prod_env shell: bash - - name: Upload build files artifact - if: steps.download_artifact.outcome == 'failure' - uses: actions/upload-artifact@v4 - with: - name: cached_build_files - path: /home/runner/work/oppia/build_files.zip - retention-days: 7 - overwrite: true diff --git a/.github/actions/merge-develop-and-set-up-dependencies/action.yml b/.github/actions/merge-develop-and-set-up-dependencies/action.yml index 5c80fd06e468d..cf1dbeeb3030a 100644 --- a/.github/actions/merge-develop-and-set-up-dependencies/action.yml +++ b/.github/actions/merge-develop-and-set-up-dependencies/action.yml @@ -1,10 +1,17 @@ name: Merge develop and set up dependencies description: 'Merge develop into current branch, and set up dependencies' +inputs: + merge_sha: + description: 'Specific commit SHA from develop to merge. If empty, falls back to latest develop.' + required: false + default: '' runs: using: composite steps: - name: Merge develop branch into the current branch uses: ./.github/actions/merge + with: + merge_sha: ${{ inputs.merge_sha }} - name: Setup Python 3.10.16 uses: actions/setup-python@v5 id: setup_python diff --git a/.github/actions/merge/action.yml b/.github/actions/merge/action.yml index 96a1c641c8ff7..75430feaf27b1 100644 --- a/.github/actions/merge/action.yml +++ b/.github/actions/merge/action.yml @@ -1,5 +1,10 @@ name: 'Merge Source Branch into Base Branch' description: 'Merge the PR source branch into its base branch, leaving the merge commit checked-out' +inputs: + merge_sha: + description: 'Specific commit SHA from develop to merge. If empty, falls back to latest develop.' + required: false + default: '' runs: using: 'composite' steps: @@ -20,7 +25,12 @@ runs: git remote add source "https://github.com/${{ github.event.pull_request.head.repo.full_name }}.git" git remote add base "https://github.com/${{ github.repository }}.git" git fetch source $GITHUB_HEAD_REF - git fetch base ${{ github.base_ref }} - git checkout base/${{ github.base_ref }} + if [ -n "${{ inputs.merge_sha }}" ]; then + git fetch base ${{ inputs.merge_sha }} + git checkout ${{ inputs.merge_sha }} + else + git fetch base ${{ github.base_ref }} + git checkout base/${{ github.base_ref }} + fi git merge source/$GITHUB_HEAD_REF shell: bash diff --git a/.github/workflows/all_lint_checks.yml b/.github/workflows/all_lint_checks.yml index 885a14986db22..738c6f2db840e 100644 --- a/.github/workflows/all_lint_checks.yml +++ b/.github/workflows/all_lint_checks.yml @@ -25,8 +25,20 @@ concurrency: || github.ref || github.run_id }} cancel-in-progress: true jobs: + setup_merge_sha: + name: Capture develop SHA + runs-on: ubuntu-22.04 + outputs: + develop_sha: ${{ steps.get_sha.outputs.sha }} + steps: + - name: Get latest develop SHA + id: get_sha + run: | + SHA=$(git ls-remote https://github.com/oppia/oppia.git refs/heads/develop | awk '{print $1}') + echo "sha=$SHA" >> "$GITHUB_OUTPUT" backend_lint: name: Backend + needs: setup_merge_sha runs-on: ubuntu-22.04 # Skip the job if we were only launched to cancel running jobs via the concurrency key above. if: ${{ ! ( @@ -38,9 +50,14 @@ jobs: uses: actions/checkout@v4 - name: Merge develop and set up dependencies uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Third Party Size Check if: startsWith(github.head_ref, 'update-changelog-for-release') == false run: python -m scripts.third_party_size_check + - name: Check Unused I18N Keys + if: startsWith(github.head_ref, 'update-changelog-for-release') == false + run: python -m scripts.check_unused_i18n_keys - name: Run Lint Checks if: startsWith(github.head_ref, 'update-changelog-for-release') == false run: python -m scripts.linters.run_lint_checks --shard other --verbose @@ -52,6 +69,7 @@ jobs: webhook-url: ${{ secrets.BUILD_FAILURE_ROOM_WEBHOOK_URL }} frontend_lint: name: Custom ESLint checks + needs: setup_merge_sha runs-on: ubuntu-22.04 # Skip the job if we were only launched to cancel running jobs via the concurrency key above. if: ${{ ! ( @@ -63,6 +81,8 @@ jobs: uses: actions/checkout@v4 - name: Merge develop and set up dependencies uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Run ESLint Tests if: startsWith(github.head_ref, 'update-changelog-for-release') == false run: python -m scripts.run_custom_eslint_tests @@ -74,6 +94,7 @@ jobs: webhook-url: ${{ secrets.BUILD_FAILURE_ROOM_WEBHOOK_URL }} frontend_formatter: name: Frontend formatting with prettier + needs: setup_merge_sha runs-on: ubuntu-22.04 # Skip the job if we were only launched to cancel running jobs via the concurrency key above. if: ${{ ! ( @@ -85,6 +106,8 @@ jobs: uses: actions/checkout@v4 - name: Merge develop and set up dependencies uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Prettify code run: npx prettier --check . - name: Explain how to fix the issue @@ -98,6 +121,7 @@ jobs: webhook-url: ${{ secrets.BUILD_FAILURE_ROOM_WEBHOOK_URL }} black_formatter: name: Frontend formatting with prettier + needs: setup_merge_sha runs-on: ubuntu-22.04 # Skip the job if we were only launched to cancel running jobs via the concurrency key above. if: ${{ ! ( @@ -109,6 +133,8 @@ jobs: uses: actions/checkout@v4 - name: Merge develop and set up dependencies uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Black formatting run: black --check . - name: Explain how to fix the issue diff --git a/.github/workflows/all_type_checks.yml b/.github/workflows/all_type_checks.yml index 9a040de300ccf..aea048004b1cd 100644 --- a/.github/workflows/all_type_checks.yml +++ b/.github/workflows/all_type_checks.yml @@ -25,8 +25,20 @@ concurrency: || github.ref || github.run_id }} cancel-in-progress: true jobs: + setup_merge_sha: + name: Capture develop SHA + runs-on: ubuntu-22.04 + outputs: + develop_sha: ${{ steps.get_sha.outputs.sha }} + steps: + - name: Get latest develop SHA + id: get_sha + run: | + SHA=$(git ls-remote https://github.com/oppia/oppia.git refs/heads/develop | awk '{print $1}') + echo "sha=$SHA" >> "$GITHUB_OUTPUT" backend_type_checks: name: Backend + needs: setup_merge_sha runs-on: ubuntu-22.04 # Skip the job if we were only launched to cancel running jobs via the concurrency key above. if: ${{ ! ( @@ -38,6 +50,8 @@ jobs: uses: actions/checkout@v4 - name: Merge develop and set up dependencies uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Run Mypy type checks run: python -m scripts.run_mypy_checks - name: Report failure if failed on oppia/oppia develop branch @@ -48,6 +62,7 @@ jobs: webhook-url: ${{ secrets.BUILD_FAILURE_ROOM_WEBHOOK_URL }} frontend_type_checks: name: Frontend + needs: setup_merge_sha runs-on: ubuntu-22.04 # Skip the job if we were only launched to cancel running jobs via the concurrency key above. if: ${{ ! ( @@ -59,6 +74,8 @@ jobs: uses: actions/checkout@v4 - name: Merge develop and set up dependencies uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Run typescript tests run: python -m scripts.run_typescript_checks - name: Run typescript tests in strict mode diff --git a/.github/workflows/backend_unit_tests.yml b/.github/workflows/backend_unit_tests.yml index 4a587249ee8b8..e05850d9b8850 100644 --- a/.github/workflows/backend_unit_tests.yml +++ b/.github/workflows/backend_unit_tests.yml @@ -25,8 +25,20 @@ concurrency: || github.ref || github.run_id }} cancel-in-progress: true jobs: + setup_merge_sha: + name: Capture develop SHA + runs-on: ubuntu-22.04 + outputs: + develop_sha: ${{ steps.get_sha.outputs.sha }} + steps: + - name: Get latest develop SHA + id: get_sha + run: | + SHA=$(git ls-remote https://github.com/oppia/oppia.git refs/heads/develop | awk '{print $1}') + echo "sha=$SHA" >> "$GITHUB_OUTPUT" run_backend_associated_test_file_checks: name: Verify associated test files + needs: setup_merge_sha runs-on: ubuntu-22.04 # Skip the job if we were only launched to cancel running jobs via the concurrency key above. if: ${{ ! ( @@ -38,7 +50,10 @@ jobs: uses: actions/checkout@v4 - name: Merge develop and set up dependencies uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Run backend associated test file check + run: python -m scripts.check_backend_associated_test_file - name: Report failure if failed on oppia/oppia develop branch if: ${{ failure() && github.event_name == 'push' && github.repository == 'oppia/oppia' && github.ref == 'refs/heads/develop'}} @@ -48,6 +63,7 @@ jobs: webhook-url: ${{ secrets.BUILD_FAILURE_ROOM_WEBHOOK_URL }} run_tests: name: Shard ${{ matrix.shard }} + needs: setup_merge_sha runs-on: ubuntu-22.04 # Skip the job if we were only launched to cancel running jobs via the concurrency key above. if: ${{ ! ( @@ -63,7 +79,10 @@ jobs: uses: actions/checkout@v4 - name: Merge develop and set up dependencies uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Run backend test shard + id: run_backend_test_shard if: startsWith(github.head_ref, 'update-changelog-for-release') == false run: python -m scripts.run_backend_tests --generate_coverage_report --generate_time_report --ignore_coverage --exclude_load_tests --test_shard ${{ matrix.shard }} @@ -89,13 +108,15 @@ jobs: retention-days: 1 check_combined_coverage: name: Check coverage - needs: run_tests + needs: [run_tests, setup_merge_sha] runs-on: ubuntu-22.04 steps: - name: Checkout repository so that local actions can be used uses: actions/checkout@v4 - name: Merge develop and set up dependencies uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Download coverage report for shard 1 if: startsWith(github.head_ref, 'update-changelog-for-release') == false uses: actions/download-artifact@v4 @@ -150,13 +171,15 @@ jobs: pull-requests: write check_backend_test_times: name: Check test times - needs: run_tests + needs: [run_tests, setup_merge_sha] runs-on: ubuntu-22.04 steps: - name: Checkout repository so that local actions can be used uses: actions/checkout@v4 - name: Merge develop and set up dependencies uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Download time report for shard 1 uses: actions/download-artifact@v4 with: diff --git a/.github/workflows/frontend_unit_tests.yml b/.github/workflows/frontend_unit_tests.yml index b57540b09f2e8..41c53862c5c6b 100644 --- a/.github/workflows/frontend_unit_tests.yml +++ b/.github/workflows/frontend_unit_tests.yml @@ -25,8 +25,20 @@ concurrency: || github.ref || github.run_id }} cancel-in-progress: true jobs: + setup_merge_sha: + name: Capture develop SHA + runs-on: ubuntu-22.04 + outputs: + develop_sha: ${{ steps.get_sha.outputs.sha }} + steps: + - name: Get latest develop SHA + id: get_sha + run: | + SHA=$(git ls-remote https://github.com/oppia/oppia.git refs/heads/develop | awk '{print $1}') + echo "sha=$SHA" >> "$GITHUB_OUTPUT" generate-job-strategy-matrix: name: Generate job strategy matrix + needs: setup_merge_sha runs-on: ubuntu-22.04 # Skip the job if we were only launched to cancel running jobs via the concurrency key above. if: ${{ ! ( @@ -52,7 +64,7 @@ jobs: fi frontend-karma-tests: name: Run all tests - needs: generate-job-strategy-matrix + needs: [generate-job-strategy-matrix, setup_merge_sha] runs-on: ubuntu-22.04 strategy: max-parallel: 25 @@ -64,6 +76,8 @@ jobs: uses: actions/checkout@v4 - name: Merge develop and set up dependencies uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Describe filesystem run: | pwd diff --git a/.github/workflows/full_stack_tests.yml b/.github/workflows/full_stack_tests.yml index 6edc09b5bc0ba..c407ac1d334b3 100644 --- a/.github/workflows/full_stack_tests.yml +++ b/.github/workflows/full_stack_tests.yml @@ -25,8 +25,20 @@ concurrency: || github.ref || github.run_id }} cancel-in-progress: true jobs: + setup_merge_sha: + name: Capture develop SHA + runs-on: ubuntu-22.04 + outputs: + develop_sha: ${{ steps.get_sha.outputs.sha }} + steps: + - name: Get latest develop SHA + id: get_sha + run: | + SHA=$(git ls-remote https://github.com/oppia/oppia.git refs/heads/develop | awk '{print $1}') + echo "sha=$SHA" >> "$GITHUB_OUTPUT" e2e_and_acceptance_coverage: name: Verify all e2e/acceptance tests are included + needs: setup_merge_sha runs-on: ubuntu-22.04 # Skip the job if we were only launched to cancel running jobs via the concurrency key above. if: ${{ ! ( @@ -38,7 +50,10 @@ jobs: uses: actions/checkout@v4 - name: Merge develop and set up dependencies uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Check that all e2e and acceptance test files are captured in wdio.conf.js and core/tests/ci-test-suite-configs + run: python -m scripts.check_tests_are_captured_in_ci - name: Report failure if failed on oppia/oppia develop branch if: ${{ failure() && github.event_name == 'push' && github.repository == 'oppia/oppia' && github.ref == 'refs/heads/develop'}} @@ -48,6 +63,7 @@ jobs: webhook-url: ${{ secrets.BUILD_FAILURE_ROOM_WEBHOOK_URL }} check_test_suites_to_run: name: Compute which tests to run + needs: setup_merge_sha runs-on: ubuntu-22.04 # Skip the job if we were only launched to cancel running jobs via the concurrency key above. if: ${{ ! ( @@ -65,13 +81,15 @@ jobs: fetch-depth: 0 - name: Merge develop and set up dependencies uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - id: compute_test_suites name: Check test suites to run env: SHOULD_OUTPUT_ALL_TESTS: ${{ github.event_name != 'pull_request' || vars.RUN_SUITES_ON_CHANGED_FILES == 'false' }} # Note that the script also writes the output to $GITHUB_OUTPUT. run: | - TEST_SUITES_TO_RUN=$(python -m scripts.check_ci_test_suites_to_run --github_head_ref="HEAD" --github_base_ref="origin/${{ github.event.pull_request.base.ref }}" ${{ env.SHOULD_OUTPUT_ALL_TESTS == 'true' && '--output_all_test_suites' || '' }}) + TEST_SUITES_TO_RUN=$(python -m scripts.check_ci_test_suites_to_run --github_head_ref="HEAD" --github_base_ref="${{ needs.setup_merge_sha.outputs.develop_sha }}" ${{ env.SHOULD_OUTPUT_ALL_TESTS == 'true' && '--output_all_test_suites' || '' }}) - name: Upload root files mapping as a GitHub artifact uses: actions/upload-artifact@v4 with: @@ -79,7 +97,7 @@ jobs: path: root-files-mapping.json build: name: Build the app, and store build files as an artifact - needs: [check_test_suites_to_run] + needs: [check_test_suites_to_run, setup_merge_sha] runs-on: ubuntu-22.04 if: ${{ fromJSON(needs.check_test_suites_to_run.outputs.TEST_SUITES_TO_RUN).e2e.count > 0 || fromJSON(needs.check_test_suites_to_run.outputs.TEST_SUITES_TO_RUN).lighthouse_performance.count > 0 || @@ -89,10 +107,12 @@ jobs: uses: actions/checkout@v4 - name: Merge develop and set up dependencies uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Generate build files uses: ./.github/actions/generate-build-files e2e_test: - needs: [check_test_suites_to_run, build] + needs: [check_test_suites_to_run, build, setup_merge_sha] runs-on: ubuntu-22.04 if: ${{ fromJSON(needs.check_test_suites_to_run.outputs.TEST_SUITES_TO_RUN).e2e.count > 0 @@ -120,6 +140,8 @@ jobs: uses: actions/checkout@v4 - name: Merge develop and set up dependencies uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Generate build files uses: ./.github/actions/generate-build-files - name: Install Chrome @@ -139,7 +161,7 @@ jobs: if: ${{ steps.check_skip.outputs.SKIP_SUITE != 'true' }} run: > set -o pipefail; - VIDEO_RECORDING_IS_ENABLED=0 + VIDEO_RECORDING_IS_ENABLED=1 xvfb-run -a --server-args="-screen 0, 1285x1000x24" python -m scripts.run_e2e_tests --skip_install --skip_build --suite=${{ matrix.suite.name }} --prod_env --server_log_level=info @@ -190,7 +212,7 @@ jobs: message: "An E2E test failed on the upstream develop branch." webhook-url: ${{ secrets.BUILD_FAILURE_ROOM_WEBHOOK_URL }} acceptance_test: - needs: [check_test_suites_to_run, build] + needs: [check_test_suites_to_run, build, setup_merge_sha] runs-on: ubuntu-22.04 if: ${{ fromJSON(needs.check_test_suites_to_run.outputs.TEST_SUITES_TO_RUN).acceptance.count > 0 @@ -221,6 +243,8 @@ jobs: uses: actions/checkout@v4 - name: Merge develop and set up dependencies uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Generate build files uses: ./.github/actions/generate-build-files @@ -333,6 +357,8 @@ jobs: uses: actions/checkout@v4 - name: Merge develop and set up dependencies uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Install Chrome if: startsWith(github.head_ref, 'update-changelog-for-release') == false uses: ./.github/actions/install-chrome @@ -349,7 +375,7 @@ jobs: message: "A Lighthouse test failed on the upstream develop branch." webhook-url: ${{ secrets.BUILD_FAILURE_ROOM_WEBHOOK_URL }} lighthouse_performance_test: - needs: [check_test_suites_to_run, build] + needs: [check_test_suites_to_run, build, setup_merge_sha] runs-on: ubuntu-22.04 if: ${{ fromJSON(needs.check_test_suites_to_run.outputs.TEST_SUITES_TO_RUN).lighthouse_performance.count > 0 @@ -365,6 +391,8 @@ jobs: uses: actions/checkout@v4 - name: Merge develop and set up dependencies uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Generate build files uses: ./.github/actions/generate-build-files - name: Install Chrome @@ -403,6 +431,7 @@ jobs: - acceptance_test - lighthouse_accessibility_test - lighthouse_performance_test + - setup_merge_sha if: always() runs-on: ubuntu-22.04 steps: @@ -412,6 +441,8 @@ jobs: - name: Merge develop and set up dependencies if: github.event_name != 'merge_group' uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Check workflow status if: github.event_name != 'merge_group' uses: ./.github/actions/check-workflow-status diff --git a/.github/workflows/stress_test_acceptance_test.yml b/.github/workflows/stress_test_acceptance_test.yml index 198d01372f0e1..c9b49fc5bb902 100644 --- a/.github/workflows/stress_test_acceptance_test.yml +++ b/.github/workflows/stress_test_acceptance_test.yml @@ -34,8 +34,20 @@ concurrency: cancel-in-progress: true jobs: + setup_merge_sha: + name: Capture develop SHA + runs-on: ubuntu-22.04 + outputs: + develop_sha: ${{ steps.get_sha.outputs.sha }} + steps: + - name: Get latest develop SHA + id: get_sha + run: | + SHA=$(git ls-remote https://github.com/oppia/oppia.git refs/heads/develop | awk '{print $1}') + echo "sha=$SHA" >> "$GITHUB_OUTPUT" generate-matrix: runs-on: ubuntu-latest + needs: setup_merge_sha outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} env: @@ -50,17 +62,20 @@ jobs: build: name: Build the app, and store build files as an artifact + needs: setup_merge_sha runs-on: ubuntu-22.04 steps: - name: Checkout repository so that local actions can be used uses: actions/checkout@v4 - name: Merge develop and set up dependencies uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Generate build files uses: ./.github/actions/generate-build-files run_acceptance_test: - needs: [build, generate-matrix] + needs: [build, generate-matrix, setup_merge_sha] runs-on: ubuntu-22.04 strategy: fail-fast: false @@ -83,6 +98,8 @@ jobs: uses: actions/checkout@v4 - name: Merge develop and set up dependencies uses: ./.github/actions/merge-develop-and-set-up-dependencies + with: + merge_sha: ${{ needs.setup_merge_sha.outputs.develop_sha }} - name: Generate build files uses: ./.github/actions/generate-build-files - name: Generate modified suite name for artifacts diff --git a/angular.json b/angular.json index 5c80a74289b4f..904d1a509a710 100644 --- a/angular.json +++ b/angular.json @@ -35,6 +35,16 @@ "glob": "**/*", "input": "./node_modules/midi/examples/soundfont/", "output": "./midi/examples/soundfont/" + }, + { + "glob": "**/*", + "input": "node_modules/ckeditor4", + "output": "/third_party/ckeditor" + }, + { + "glob": "**/*", + "input": "node_modules/ckeditor4-bootstrapck", + "output": "/third_party/ckeditor-bootstrapck" } ], "styles": [ diff --git a/app_dev.yaml b/app_dev.yaml index b1bbcadc44921..c0fcead1aac67 100644 --- a/app_dev.yaml +++ b/app_dev.yaml @@ -42,6 +42,15 @@ handlers: static_dir: dist/ secure: always expiration: "0" +- url: /third_party/ckeditor + static_dir: dist/oppia-angular/third_party/ckeditor + secure: always + http_headers: + # This is replaced by a specific origin when doing a deployment. + Access-Control-Allow-Origin: "*" + expiration: "0" +- url: /third_party/ckeditor-bootstrapck + static_dir: dist/oppia-angular/third_party/ckeditor-bootstrapck - url: /assets/mathjax static_dir: dist/oppia-angular/assets/mathjax secure: always diff --git a/data/voiceovers/autogeneratable_language_accent_list.json b/assets/autogeneratable_language_accent_list.json similarity index 99% rename from data/voiceovers/autogeneratable_language_accent_list.json rename to assets/autogeneratable_language_accent_list.json index 9cbd13c953780..2b40c1a50599e 100644 --- a/data/voiceovers/autogeneratable_language_accent_list.json +++ b/assets/autogeneratable_language_accent_list.json @@ -43,7 +43,7 @@ "en-SG": {"service": "Azure", "voice_code": "en-SG-LunaNeural"}, "en-TZ": {"service": "Azure", "voice_code": "en-TZ-ImaniNeural"}, "en-US": { - "service": "Azure", "voice_code": "en-US-JennyMultilingualNeural" + "service": "Azure", "voice_code": "en-US-AvaMultilingualNeural" }, "en-ZA": {"service": "Azure", "voice_code": "en-ZA-LeahNeural"}, "es-AR": {"service": "Azure", "voice_code": "es-AR-ElenaNeural"}, diff --git a/assets/constants.ts b/assets/constants.ts index 9c8cbd26777e0..b7e804f7960ee 100644 --- a/assets/constants.ts +++ b/assets/constants.ts @@ -65,12 +65,12 @@ export default { "explanation": "For learners in Nigeria." }], - "RTE_COMPONENT_CONFIGS": { - "ALL_COMPONENTS": ["tabs", "skillreview", "collapsible", "math", "image", "link", "video"], - "BLOG_COMPONENTS": ["image", "link", "video"], - "SKILL_AND_STUDY_GUIDE_EDITOR_COMPONENTS": ["skillreview", "math", "image", "workedexample"], - "CURATED_LESSON_COMPONENTS": ["image", "math", "skillreview"] - }, + "RTE_COMPONENT_CONFIGS": { + "ALL_COMPONENTS": ["tabs", "skillreview", "collapsible", "math", "image", "link", "video"], + "BLOG_COMPONENTS": ["image", "link", "video"], + "SKILL_AND_STUDY_GUIDE_EDITOR_COMPONENTS": ["skillreview", "math", "image", "workedexample"], + "CURATED_LESSON_COMPONENTS": ["image", "math", "skillreview"] + }, "LIST_OF_DEFAULT_TAGS_FOR_BLOG_POST": [ "News", "International", "Educators", "Learners", "Community", @@ -6262,6 +6262,7 @@ export default { "MAX_CHARS_IN_STORY_TITLE": 39, "MAX_CHARS_IN_STORY_DESCRIPTION": 1000, "MAX_CHARS_IN_EXPLORATION_TITLE": 36, + "MAX_CHARS_IN_SET_INPUT_BUTTON_TEXT": 50, "MAX_CHARS_IN_CHAPTER_DESCRIPTION": 152, "MAX_CHARS_IN_MISCONCEPTION_NAME": 100, "MAX_CHARS_IN_BLOG_POST_TITLE": 65, diff --git a/assets/i18n/ar.json b/assets/i18n/ar.json index 79c6f4ebb16f6..053bb5bbf2444 100644 --- a/assets/i18n/ar.json +++ b/assets/i18n/ar.json @@ -210,7 +210,6 @@ "I18N_BLOG_HOME_PAGE_TITLE": "مدونة أوبيا | أوبيا", "I18N_BLOG_HOME_PAGE_WELCOME_HEADING": "مرحبا بكم في مدونة Oppia!", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_HEADING": "عرض نتائج البحث", - "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_DISPLAY": "عرض <[startingNumber]> - <[endingNumber]> من إجمالي نتائج البحث.", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_OUT_OF_TOTAL_DISPLAY": "عرض <[startingNumber]> - <[endingNumber]> من <[totalNumber]> منشورات", "I18N_BLOG_POST_EDITOR_ADD_THUMBNAIL_TEXT": "اضف صورة للواجهة", "I18N_BLOG_POST_EDITOR_BODY_HEADING": "جسم", @@ -832,7 +831,6 @@ "I18N_LEARNER_DASHBOARD_GOALS_SECTION_INSTRUCTIONS": "اختر ما يصل إلى 5 مواضيع تهمك، ثم أكمل جميع الفصول المتعلقة بالموضوعات المحددة لتحقيق أهداف التعلم الخاصة بك.", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_NODE_TITLE": "الفصل <[number]>: <[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_PROGRESS": "<[current]> من <[total]> من الفصول المكتملة", - "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE": "<[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE_NEW": "<[topic]>:<[story]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_CANCEL": "إلغاء", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_HEADING": "إضافة هدف أو تعديله", diff --git a/assets/i18n/el.json b/assets/i18n/el.json index a19a1471bc62e..1501b95a1c6bb 100644 --- a/assets/i18n/el.json +++ b/assets/i18n/el.json @@ -146,7 +146,6 @@ "I18N_BLOG_HOME_PAGE_TITLE": "Ιστολόγιο Oppia | Oppia", "I18N_BLOG_HOME_PAGE_WELCOME_HEADING": "Καλώς ήρθατε στο ιστολόγιο της Oppia!", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_HEADING": "Αποτελέσματα της Αναζήτησης", - "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_DISPLAY": "Εμφάνιση <[startingNumber]> - <[endingNumber]> των συνολικών αποτελεσμάτων αναζήτησης.", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_OUT_OF_TOTAL_DISPLAY": "Εμφάνιση αναρτήσεων <[startingNumber]> - <[endingNumber]> από <[totalNumber]>.", "I18N_BLOG_POST_EDITOR_ADD_THUMBNAIL_TEXT": "Προσθήκη μικρογραφίας εικόνας", "I18N_BLOG_POST_EDITOR_BODY_HEADING": "Κύριο μέρος", @@ -755,7 +754,6 @@ "I18N_LEARNER_DASHBOARD_GOALS_SECTION_HEADING": "Στόχοι χρήστη <[username]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_NODE_TITLE": "Κεφάλαιο <[number]>: <[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_PROGRESS": "<[current]> ΑΠΟ <[total]> ΚΕΦΑΛΑΙΑ ΟΛΟΚΛΗΡΩΘΗΚΑΝ", - "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE": "<[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_CANCEL": "Άκυρο", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_SAVE": "Αποθήκευση", "I18N_LEARNER_DASHBOARD_GOLD_BADGE": "Χρυσό", diff --git a/assets/i18n/en.json b/assets/i18n/en.json index d777422c1ecde..d9321ae40bd15 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -210,7 +210,6 @@ "I18N_BLOG_HOME_PAGE_TITLE": "Oppia Blog | Oppia", "I18N_BLOG_HOME_PAGE_WELCOME_HEADING": "Welcome to the Oppia Blog!", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_HEADING": "Showing Search Results", - "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_DISPLAY": "Displaying <[startingNumber]> - <[endingNumber]> of total search results.", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_OUT_OF_TOTAL_DISPLAY": "Displaying <[startingNumber]> - <[endingNumber]> of <[totalNumber]> posts.", "I18N_BLOG_POST_EDITOR_ADD_THUMBNAIL_TEXT": "Add Thumbnail Image", "I18N_BLOG_POST_EDITOR_BODY_HEADING": "Body", @@ -832,7 +831,6 @@ "I18N_LEARNER_DASHBOARD_GOALS_SECTION_INSTRUCTIONS": "Choose up to 5 topics of your interest, and then complete all chapters on the selected topics to achieve your learning goals.", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_NODE_TITLE": "Chapter <[number]>: <[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_PROGRESS": "<[current]> OF <[total]> CHAPTERS COMPLETED", - "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE": "<[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE_NEW": "<[topic]>: <[story]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_CANCEL": "Cancel", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_HEADING": "Add or edit a goal", diff --git a/assets/i18n/es.json b/assets/i18n/es.json index 8c0ffa4986c11..f5807e4dd73ab 100644 --- a/assets/i18n/es.json +++ b/assets/i18n/es.json @@ -210,7 +210,6 @@ "I18N_BLOG_HOME_PAGE_TITLE": "Blog de Oppia | Oppia", "I18N_BLOG_HOME_PAGE_WELCOME_HEADING": "¡Bienvenidos al Blog de Oppia!", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_HEADING": "Mostrando resultados de búsqueda", - "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_DISPLAY": "Mostrando <[startingNumber]> - <[endingNumber]> de los resultados totales de la búsqueda", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_OUT_OF_TOTAL_DISPLAY": "Mostrando <[startingNumber]> - <[endingNumber]> de <[totalNumber]> entradas.", "I18N_BLOG_POST_EDITOR_ADD_THUMBNAIL_TEXT": "Añadir imagen miniatura", "I18N_BLOG_POST_EDITOR_BODY_HEADING": "Cuerpo", @@ -831,7 +830,6 @@ "I18N_LEARNER_DASHBOARD_GOALS_SECTION_INSTRUCTIONS": "Elija hasta 5 temas de tu interés y luego completa todos los capítulos sobre los temas seleccionados para lograr sus objetivos de aprendizaje.", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_NODE_TITLE": "Capítulo <[number]>: <[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_PROGRESS": "<[current]> DE <[total]> CAPÍTULOS COMPLETADOS", - "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE": "<[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_CANCEL": "Cancelar", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_HEADING": "Agregar o editar un objetivo", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_INSTRUCTIONS": "Puedes seleccionar hasta 5 objetivos a la vez", diff --git a/assets/i18n/fi.json b/assets/i18n/fi.json index 6667b0f8f73d9..6b1077a25c740 100644 --- a/assets/i18n/fi.json +++ b/assets/i18n/fi.json @@ -62,7 +62,6 @@ "I18N_BLOG_HOME_PAGE_TITLE": "Oppia-blogi | Oppia", "I18N_BLOG_HOME_PAGE_WELCOME_HEADING": "Tervetuloa Oppia-blogiin!", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_HEADING": "Näytetään hakutulokset", - "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_DISPLAY": "Näytetään <[startingNumber]> - <[endingNumber]> kaikista hakutuloksista.", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_OUT_OF_TOTAL_DISPLAY": "Näytetään <[startingNumber]> - <[endingNumber]> / <[totalNumber]> blogikirjoitusta.", "I18N_BLOG_POST_EDITOR_ADD_THUMBNAIL_TEXT": "Lisää pienoiskuva", "I18N_BLOG_POST_EDITOR_BODY_HEADING": "Sisältö", diff --git a/assets/i18n/fr.json b/assets/i18n/fr.json index ec41670316d0b..2dc54c4c4381e 100644 --- a/assets/i18n/fr.json +++ b/assets/i18n/fr.json @@ -210,7 +210,6 @@ "I18N_BLOG_HOME_PAGE_TITLE": "Blogue Oppia | Oppia", "I18N_BLOG_HOME_PAGE_WELCOME_HEADING": "Bienvenue sur le blogue d’Oppia !", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_HEADING": "Affichage des résultats de la recherche", - "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_DISPLAY": "Affichage partiel des résultats de recherche de <[startingNumber]> à <[endingNumber]>.", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_OUT_OF_TOTAL_DISPLAY": "Affichage des billets de <[startingNumber]> à <[endingNumber]> sur <[totalNumber]>.", "I18N_BLOG_POST_EDITOR_ADD_THUMBNAIL_TEXT": "Ajouter l’image de vignette", "I18N_BLOG_POST_EDITOR_BODY_HEADING": "Corps", @@ -830,7 +829,6 @@ "I18N_LEARNER_DASHBOARD_GOALS_SECTION_INSTRUCTIONS": "Choisissez jusqu'à 5 sujets qui vous intéressent puis complétez tous les chapitres sur les sujets sélectionnés pour atteindre vos objectifs d'apprentissage.", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_NODE_TITLE": "Chapitre <[number]> : <[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_PROGRESS": "<[current]> / <[total]> CHAPITRE(S) TERMINÉ(S)", - "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE": "<[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE_NEW": "<[topic]> : <[story]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_CANCEL": "Annuler", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_HEADING": "Ajouter ou modifier un objectif", diff --git a/assets/i18n/hi.json b/assets/i18n/hi.json index 065d8819fd2df..46dacd8917a66 100644 --- a/assets/i18n/hi.json +++ b/assets/i18n/hi.json @@ -210,7 +210,6 @@ "I18N_BLOG_HOME_PAGE_TITLE": "ओपिया ब्लॉग | ओपिया", "I18N_BLOG_HOME_PAGE_WELCOME_HEADING": "ओपिया ब्लॉग में आपका स्वागत है!", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_HEADING": "खोज परिणाम दिखा रहे हैं", - "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_DISPLAY": "कुल खोज परिणामों का <[startingNumber]> - <[endingNumber]> प्रदर्शित करना।", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_OUT_OF_TOTAL_DISPLAY": "<[startingNumber]> - <[endingNumber]> में से <[totalNumber]> पोस्ट प्रदर्शित हो रही हैं।", "I18N_BLOG_POST_EDITOR_ADD_THUMBNAIL_TEXT": "थंबनेल छवि जोड़ें", "I18N_BLOG_POST_EDITOR_BODY_HEADING": "मुख्यभाग", @@ -832,7 +831,6 @@ "I18N_LEARNER_DASHBOARD_GOALS_SECTION_INSTRUCTIONS": "अपनी रुचि के अधिकतम 5 विषय चुनें, और फिर अपने शिक्षण लक्ष्य को प्राप्त करने के लिए चयनित विषयों पर सभी अध्यायों को पूरा करें।", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_NODE_TITLE": "अध्याय <[number]>:<[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_PROGRESS": "<[current]> OF <[total]> अध्याय समाप्त", - "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE": "<[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE_NEW": "<[topic]>: <[story]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_CANCEL": "रद्द करें", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_HEADING": "लक्ष्य जोड़ें या संपादित करें", diff --git a/assets/i18n/id.json b/assets/i18n/id.json index a9dede793f342..ee16921e26c6e 100644 --- a/assets/i18n/id.json +++ b/assets/i18n/id.json @@ -208,7 +208,6 @@ "I18N_BLOG_HOME_PAGE_TITLE": "Blog Oppia | Oppia", "I18N_BLOG_HOME_PAGE_WELCOME_HEADING": "Selamat datang di Blog Oppia!", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_HEADING": "Menampilkan Hasil Pencarian", - "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_DISPLAY": "Menampilkan <[startingNumber]> - <[endingNumber]> dari total hasil pencarian.", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_OUT_OF_TOTAL_DISPLAY": "Menampilkan <[startingNumber]> - <[endingNumber]> dari <[totalNumber]> postingan", "I18N_BLOG_POST_EDITOR_ADD_THUMBNAIL_TEXT": "Tambahkan Gambar Thumbnail", "I18N_BLOG_POST_EDITOR_BODY_HEADING": "Isi", @@ -818,7 +817,6 @@ "I18N_LEARNER_DASHBOARD_GOALS_SECTION_INSTRUCTIONS": "Pilih hingga 5 topik yang Anda minati, lalu selesaikan semua bab pada topik yang dipilih untuk mencapai sasaran pembelajaran Anda.", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_NODE_TITLE": "Bab <[number]>: <[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_PROGRESS": "<[current]> DARI <[total]> BAB YANG SELESAI", - "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE": "<[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_CANCEL": "Batal", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_HEADING": "Tambahkan atau ubah sasaran", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_INSTRUCTIONS": "Anda dapat memilih hingga 5 sasaran dalam satu waktu", diff --git a/assets/i18n/ja.json b/assets/i18n/ja.json index d777f2263a03f..a64f8f4e09f2a 100644 --- a/assets/i18n/ja.json +++ b/assets/i18n/ja.json @@ -62,7 +62,6 @@ "I18N_BLOG_HOME_PAGE_TITLE": "オピアブログ | オピア", "I18N_BLOG_HOME_PAGE_WELCOME_HEADING": "オピアブログへようこそ!", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_HEADING": "検索結果の表示", - "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_DISPLAY": "<[startingNumber]>件目から<[endingNumber]>件目までの検索結果を表示しています。", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_OUT_OF_TOTAL_DISPLAY": "<[totalNumber]>件中<[startingNumber]>件目から<[endingNumber]>件目までの投稿を表示しています。", "I18N_BLOG_POST_EDITOR_BODY_HEADING": "本文", "I18N_BLOG_POST_EDITOR_CANCEL_BUTTON_TEXT": "キャンセル", diff --git a/assets/i18n/lb.json b/assets/i18n/lb.json index 1d5150ee24da0..6a43092440769 100644 --- a/assets/i18n/lb.json +++ b/assets/i18n/lb.json @@ -259,7 +259,6 @@ "I18N_LEARNER_DASHBOARD_GOALS_SECTION": "Ziler", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_ADD_BUTTON": "En Zil derbäisetzen", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_NODE_TITLE": "Kapitel <[number]>: <[title]>", - "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE": "<[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE_NEW": "<[topic]>: <[story]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_CANCEL": "Ofbriechen", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_SAVE": "Späicheren", diff --git a/assets/i18n/lt.json b/assets/i18n/lt.json index a1e9fd1b30e9a..6236b33c9eda0 100644 --- a/assets/i18n/lt.json +++ b/assets/i18n/lt.json @@ -13,7 +13,6 @@ "I18N_BLOG_HOME_PAGE_TITLE": "\"Oppia\" tinklarašis | \"Oppia\"", "I18N_BLOG_HOME_PAGE_WELCOME_HEADING": "Sveiki atvykę į tinklaraštį \"Oppia\"!", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_HEADING": "Rodomi paieškos rezultatai", - "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_DISPLAY": "Rodomi visų paieškos rezultatų <[startingNumber]> - <[endingNumber]>.", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_OUT_OF_TOTAL_DISPLAY": "Rodomas <[totalNumber]> įrašų <[startingNumber]> - <[endingNumber]> .", "I18N_BLOG_POST_PAGE_RECOMMENDATION_SECTON_HEADING": "Siūloma Jums", "I18N_BLOG_POST_PAGE_TAGS_HEADING": "Žymos", diff --git a/assets/i18n/nl.json b/assets/i18n/nl.json index 4e4db95a25e96..571e753841c9b 100644 --- a/assets/i18n/nl.json +++ b/assets/i18n/nl.json @@ -210,7 +210,6 @@ "I18N_BLOG_HOME_PAGE_TITLE": "Oppia-blog | Oppia", "I18N_BLOG_HOME_PAGE_WELCOME_HEADING": "Welkom op de Oppia-blog!", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_HEADING": "Zoekresultaten weergeven", - "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_DISPLAY": "Weergave van <[startingNumber]> - <[endingNumber]> uit het totale aantal zoekresultaten.", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_OUT_OF_TOTAL_DISPLAY": "Weergave van <[startingNumber]> - <[endingNumber]> uit <[totalNumber]> berichten.", "I18N_BLOG_POST_EDITOR_ADD_THUMBNAIL_TEXT": "Miniatuurafbeelding toevoegen", "I18N_BLOG_POST_EDITOR_BODY_HEADING": "Tekst", @@ -831,7 +830,6 @@ "I18N_LEARNER_DASHBOARD_GOALS_SECTION_INSTRUCTIONS": "Kies maximaal 5 onderwerpen die u interesseren en voltooi vervolgens alle hoofdstukken over de gekozen onderwerpen om uw leerdoelen te bereiken.", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_NODE_TITLE": "Hoofdstuk <[number]>: <[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_PROGRESS": "<[current]> VAN <[total]> HOOFDSTUKKEN VOLTOOID", - "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE": "<[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE_NEW": "<[topic]>: <[story]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_CANCEL": "Annuleren", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_HEADING": "Een doel toevoegen of bewerken", diff --git a/assets/i18n/pcm.json b/assets/i18n/pcm.json index 3b182327a3771..8faba33feb348 100644 --- a/assets/i18n/pcm.json +++ b/assets/i18n/pcm.json @@ -210,7 +210,6 @@ "I18N_BLOG_HOME_PAGE_TITLE": "Oppia Blog | Oppia", "I18N_BLOG_HOME_PAGE_WELCOME_HEADING": "Welcome to Oppia Blog!", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_HEADING": "Search results dey show", - "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_DISPLAY": "E dey show <[startingNumber]> - <[endingNumber]> of di total results wey dem search for.", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_OUT_OF_TOTAL_DISPLAY": "E dey show <[startingNumber]> - <[endingNumber]> of <[totalNumber]> tori.", "I18N_BLOG_POST_EDITOR_ADD_THUMBNAIL_TEXT": "Add small picture for hia", "I18N_BLOG_POST_EDITOR_BODY_HEADING": "Body", @@ -832,7 +831,6 @@ "I18N_LEARNER_DASHBOARD_GOALS_SECTION_INSTRUCTIONS": "Choose to reach five topic wey sweet your bodi, then finish all di chapter dem for di topic wey you choose so you go fit reach di aim for your learning.", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_NODE_TITLE": "Di part <[number]>: <[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_PROGRESS": "<[current]> OF <[total]> PARTS WEY DON DEY COMPLETE", - "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE": "<[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE_NEW": "<[topic]>: <[story]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_CANCEL": "Cancel am", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_HEADING": "Put or change one goal", diff --git a/assets/i18n/pt-br.json b/assets/i18n/pt-br.json index f1b310a09b737..c9ff5f3ffd76e 100644 --- a/assets/i18n/pt-br.json +++ b/assets/i18n/pt-br.json @@ -210,7 +210,6 @@ "I18N_BLOG_HOME_PAGE_TITLE": "Blog da Oppia | Oppia", "I18N_BLOG_HOME_PAGE_WELCOME_HEADING": "Bem-vindo(a) ao Blog da Oppia!", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_HEADING": "Exibindo resultados da pesquisa", - "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_DISPLAY": "Mostrando <[startingNumber]> - <[endingNumber]> do total de resultados da pesquisa.", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_OUT_OF_TOTAL_DISPLAY": "Mostrando <[startingNumber]> - <[endingNumber]> de <[totalNumber]> postagens.", "I18N_BLOG_POST_EDITOR_ADD_THUMBNAIL_TEXT": "Adicionar Imagem Miniatura", "I18N_BLOG_POST_EDITOR_BODY_HEADING": "Corpo", diff --git a/assets/i18n/qqq.json b/assets/i18n/qqq.json index f40a83ac5f1ae..9a78ecda9af92 100644 --- a/assets/i18n/qqq.json +++ b/assets/i18n/qqq.json @@ -210,7 +210,6 @@ "I18N_BLOG_HOME_PAGE_TITLE": "Title displayed on the browser tab when on the blog home page.", "I18N_BLOG_HOME_PAGE_WELCOME_HEADING": "Text displayed in the blog home page - Heading on Blog Homepage-Welcome to the Oppia Blog!", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_HEADING": "Text displayed in the blog home page afer search- Heading above search results being shown on blog homepage search results page.", - "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_DISPLAY": "Text displayed in the blog home page after search for blog posts is performed - Text that displays the number of blog posts being displayed on the search results page.", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_OUT_OF_TOTAL_DISPLAY": "Text displayed in the blog home page after search for blog posts is performed - Text that displays the number of blog posts being displayed on the search results page out of the total search results.", "I18N_BLOG_POST_EDITOR_ADD_THUMBNAIL_TEXT": "Text displayed in the blog post editor page. - Text of the button that allows the user to upload thumbnail image for the blog post.", "I18N_BLOG_POST_EDITOR_BODY_HEADING": "Text displayed in the blog post editor page. -Heading Text before the input field to enter blog post content.", @@ -832,7 +831,6 @@ "I18N_LEARNER_DASHBOARD_GOALS_SECTION_INSTRUCTIONS": "Instruction text on how to add goals for the goals section in the learner dashboard", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_NODE_TITLE": "Node title for story dropdown in goals tab of learner dashboard", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_PROGRESS": "Status of chapters completed for story in goals tab of learner dashboard", - "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE": "Story title for story dropdown in goals tab of learner dashboard", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE_NEW": "Topic title and Story title for story dropdown in goals tab of learner dashboard", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_CANCEL": "Text for cancel button in modal to add goals for the goals section in the learner dashboard", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_HEADING": "Heading text in modal to add goals for the goals section in the learner dashboard", diff --git a/assets/i18n/ru.json b/assets/i18n/ru.json index 8def23ca72ddd..5d722d2c1ecc4 100644 --- a/assets/i18n/ru.json +++ b/assets/i18n/ru.json @@ -68,7 +68,6 @@ "I18N_BLOG_HOME_PAGE_TITLE": "Блог Oppia | Oppia", "I18N_BLOG_HOME_PAGE_WELCOME_HEADING": "Добро пожаловать в блог Oppia!", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_HEADING": "Результаты поиска", - "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_DISPLAY": "Показаны результаты поиска с <[startingNumber]> по <[endingNumber]>", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_OUT_OF_TOTAL_DISPLAY": "Показаны записи с <[startingNumber]> по <[endingNumber]> из <[totalNumber]>.", "I18N_BLOG_POST_EDITOR_CANCEL_BUTTON_TEXT": "Отмена", "I18N_BLOG_POST_EDITOR_DELETE_BUTTON": "Удалить", diff --git a/assets/i18n/skr-arab.json b/assets/i18n/skr-arab.json index e01ce66da0f8a..ab0d8b6951bb8 100644 --- a/assets/i18n/skr-arab.json +++ b/assets/i18n/skr-arab.json @@ -277,7 +277,6 @@ "I18N_LEARNER_DASHBOARD_EVENING_GREETING": "شام دا سلام", "I18N_LEARNER_DASHBOARD_GOALS_SECTION": "مقاصد", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_ADD_BUTTON": "مقصد شامل کرو", - "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE": "<[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_CANCEL": "منسوخ", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_SAVE": "سانبھو", "I18N_LEARNER_DASHBOARD_GOLD_BADGE": "سونا", diff --git a/assets/i18n/sw.json b/assets/i18n/sw.json index 4ea42aa6ec635..2853d974890d9 100644 --- a/assets/i18n/sw.json +++ b/assets/i18n/sw.json @@ -74,7 +74,6 @@ "I18N_BLOG_HOME_PAGE_TITLE": "Blogu ya Oppia | Opia", "I18N_BLOG_HOME_PAGE_WELCOME_HEADING": "Karibu kwenye blogu ya Oppia", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_HEADING": "Inaonyesha matokeo ya utafutaji", - "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_DISPLAY": "Inaonyesha <[startingNumber]> - <[endingNumber]> ya jumla ya matokeo ya utafutaji.", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_OUT_OF_TOTAL_DISPLAY": "Inaonyesha <[startingNumber]> - <[endingNumber]> ya <[totalNumber]> machapisho.", "I18N_BLOG_POST_EDITOR_ADD_THUMBNAIL_TEXT": "Ongeza picha ndogo ya Picha", "I18N_BLOG_POST_EDITOR_BODY_HEADING": "Mwili", diff --git a/assets/i18n/zh-hant.json b/assets/i18n/zh-hant.json index f73dafd0fcaf5..26c4ccc4b56c9 100644 --- a/assets/i18n/zh-hant.json +++ b/assets/i18n/zh-hant.json @@ -210,7 +210,6 @@ "I18N_BLOG_HOME_PAGE_TITLE": "Oppia 部落格| Oppia", "I18N_BLOG_HOME_PAGE_WELCOME_HEADING": "歡迎來到 Oppia 部落格!", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_HEADING": "顯示搜尋結果", - "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_DISPLAY": "顯示全部搜尋結果的 <[startingNumber]> - <[endingNumber]> 個結果。", "I18N_BLOG_HOME_SEARCH_PAGE_POSTS_NUMBER_OUT_OF_TOTAL_DISPLAY": "顯示 <[totalNumber]> 篇裡的 <[startingNumber]> - <[endingNumber]> 篇文章。", "I18N_BLOG_POST_EDITOR_ADD_THUMBNAIL_TEXT": "增加縮圖圖片", "I18N_BLOG_POST_EDITOR_BODY_HEADING": "正文", @@ -832,7 +831,6 @@ "I18N_LEARNER_DASHBOARD_GOALS_SECTION_INSTRUCTIONS": "選擇最多 5 個您感興趣的主題,然後完成所選主題的所有章節,以達成您的學習目標。", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_NODE_TITLE": "第 <[number]> 章:<[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_PROGRESS": "已完成 <[current]> 章,共 <[total]> 章", - "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE": "<[title]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_LIST_STORY_TITLE_NEW": "<[topic]>:<[story]>", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_CANCEL": "取消", "I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_HEADING": "新增或編輯目標", diff --git a/assets/images/donate/financial-literacy-campaign.webp b/assets/images/donate/financial-literacy-campaign.webp new file mode 100644 index 0000000000000..5b40ededf7575 Binary files /dev/null and b/assets/images/donate/financial-literacy-campaign.webp differ diff --git a/data/voiceovers/language_accent_master_list.json b/assets/language_accent_master_list.json similarity index 100% rename from data/voiceovers/language_accent_master_list.json rename to assets/language_accent_master_list.json diff --git a/assets/sample-autogenerated-voiceovers-for-dev/empty.mp3 b/assets/sample-autogenerated-voiceovers-for-dev/empty.mp3 new file mode 100644 index 0000000000000..72ec6390f5a41 Binary files /dev/null and b/assets/sample-autogenerated-voiceovers-for-dev/empty.mp3 differ diff --git a/core/constants.py b/core/constants.py index 57b72dcd0e03a..6a28d43545ee9 100644 --- a/core/constants.py +++ b/core/constants.py @@ -170,3 +170,23 @@ def __setstate__(self, d: Dict[str, Any]) -> None: release_constants = Constants( # pylint:disable=invalid-name json.loads(get_package_file_contents('assets', 'release_constants.json')) ) + +autogeneratable_language_accent_constants = ( + Constants( # pylint:disable=invalid-name + json.loads( + get_package_file_contents( + 'assets', 'autogeneratable_language_accent_list.json' + ) + ) + ) +) + +language_accent_master_list_constants = ( + Constants( # pylint:disable=invalid-name + json.loads( + get_package_file_contents( + 'assets', 'language_accent_master_list.json' + ) + ) + ) +) diff --git a/core/controllers/acl_decorators_test.py b/core/controllers/acl_decorators_test.py index 3478dd351e9d3..d59b5ffb8dfa0 100644 --- a/core/controllers/acl_decorators_test.py +++ b/core/controllers/acl_decorators_test.py @@ -51,7 +51,7 @@ import webapp2 import webtest -from typing import Dict, Final, List, Union +from typing import Any, Dict, Final, List, Union MYPY = False if MYPY: # pragma: no cover @@ -59,6 +59,7 @@ datastore_services = models.Registry.import_datastore_services() secrets_services = models.Registry.import_secrets_services() +(suggestion_models,) = models.Registry.import_models([models.Names.SUGGESTION]) class OpenAccessDecoratorTests(test_utils.GenericTestBase): @@ -5556,6 +5557,21 @@ def test_user_redirect_to_lowercase_story_url_fragment(self) -> None: response.headers['location'], ) + def test_user_cannot_access_story_with_no_topic(self) -> None: + # Save a story with no topic ID. + story_id = story_services.get_new_story_id() + self.save_new_story( + story_id, + self.admin_id, + '', + url_fragment='story-no-topic', + ) + with self.swap(self, 'testapp', self.mock_testapp): + self.get_json( + '/mock_story_data/staging/topic/story-no-topic', + expected_status_int=404, + ) + class StoryViewerTests(test_utils.GenericTestBase): """Tests for decorator can_access_story_viewer_page.""" @@ -5754,6 +5770,21 @@ def test_redirect_lowercase_story_url_fragment(self) -> None: response.headers['location'], ) + def test_cannot_access_story_with_no_topic(self) -> None: + # Save a story with no topic ID. + story_id = story_services.get_new_story_id() + self.save_new_story( + story_id, + self.admin_id, + '', + url_fragment='story-no-topic-guest', + ) + with self.swap(self, 'testapp', self.mock_testapp): + self.get_json( + '/mock_story_data/staging/topic/story-no-topic-guest', + expected_status_int=404, + ) + class SubtopicViewerTests(test_utils.GenericTestBase): """Tests for decorator can_access_subtopic_viewer_page.""" @@ -7899,6 +7930,106 @@ def test_authors_cannot_update_suggestion_that_they_created(self) -> None: ) self.logout() + def test_user_without_review_rights_cannot_update_add_question_suggestion( + self, + ) -> None: + content_id_generator = translation_domain.ContentIdGenerator() + # Here we use type Any because add_question_change_dict is a + # complex dictionary with mixed types that are not easily + # captured by a more specific type hint without being overly + # verbose. + add_question_change_dict: Dict[str, Any] = { + 'cmd': question_domain.CMD_CREATE_NEW_FULLY_SPECIFIED_QUESTION, + 'question_dict': { + 'question_state_data': self._create_valid_question_data( + 'default_state', content_id_generator + ).to_dict(), + 'language_code': 'en', + 'question_state_data_schema_version': ( + feconf.CURRENT_STATE_SCHEMA_VERSION + ), + 'linked_skill_ids': ['skill_1'], + 'inapplicable_skill_misconception_ids': ['skillid12345-1'], + 'next_content_id_index': ( + content_id_generator.next_content_id_index + ), + 'version': 44, + 'id': '', + }, + 'skill_id': 'skill_123', + 'skill_difficulty': 0.3, + } + suggestion_services.create_suggestion( + feconf.SUGGESTION_TYPE_ADD_QUESTION, + feconf.ENTITY_TYPE_SKILL, + 'skill_123', + feconf.CURRENT_STATE_SCHEMA_VERSION, + self.author_id, + add_question_change_dict, + 'description', + ) + suggestion_id = '%s.%s.1' % ( + feconf.ENTITY_TYPE_SKILL, + 'skill_123', + ) + with self.swap( + suggestion_models.GeneralSuggestionModel, + 'get_by_id', + lambda _: suggestion_models.GeneralSuggestionModel( + id=suggestion_id, + suggestion_type=feconf.SUGGESTION_TYPE_ADD_QUESTION, + target_type=feconf.ENTITY_TYPE_SKILL, + target_id='skill_123', + target_version_at_submission=feconf.CURRENT_STATE_SCHEMA_VERSION, + status=suggestion_models.STATUS_IN_REVIEW, + author_id=self.author_id, + change_cmd=add_question_change_dict, + score_category='category', + language_code='en', + ), + ): + self.login(self.user_email) + with self.swap(self, 'testapp', self.mock_testapp): + response = self.get_json( + '/mock/%s' % suggestion_id, expected_status_int=401 + ) + self.assertEqual( + response['error'], + 'You are not allowed to update the suggestion.', + ) + self.logout() + + def test_user_without_review_rights_cannot_update_translation_suggestion( + self, + ) -> None: + suggestion_id = self.translation_suggestion_id + with self.swap( + suggestion_models.GeneralSuggestionModel, + 'get_by_id', + lambda _: suggestion_models.GeneralSuggestionModel( + id=suggestion_id, + suggestion_type=feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT, + target_type=feconf.ENTITY_TYPE_EXPLORATION, + target_id=self.exploration_id, + target_version_at_submission=1, + status=suggestion_models.STATUS_IN_REVIEW, + author_id=self.author_id, + change_cmd=self.change_dict, + score_category='category', + language_code='en', + ), + ): + self.login(self.user_email) + with self.swap(self, 'testapp', self.mock_testapp): + response = self.get_json( + '/mock/%s' % suggestion_id, expected_status_int=401 + ) + self.assertEqual( + response['error'], + 'You are not allowed to update the suggestion.', + ) + self.logout() + def test_admin_can_update_any_given_translation_suggestion(self) -> None: self.login(self.curriculum_admin_email) with self.swap(self, 'testapp', self.mock_testapp): diff --git a/core/controllers/admin.py b/core/controllers/admin.py index a8bfdac5ad14b..ed5f0213a249d 100644 --- a/core/controllers/admin.py +++ b/core/controllers/admin.py @@ -2292,7 +2292,7 @@ def _generate_dummy_chapters( # Link the generated nodes and old nodes if they exist. graph_change_list = [] old_dest_ids: List[str] = [] - updated_story = story_fetchers.get_story_by_id('story_id') + updated_story = story_fetchers.get_story_by_id(story_id) existing_node_ids = [ node.id for node in updated_story.story_contents.nodes diff --git a/core/controllers/base_test.py b/core/controllers/base_test.py index 3d3c3f49cf087..cb24757cadcbc 100644 --- a/core/controllers/base_test.py +++ b/core/controllers/base_test.py @@ -27,6 +27,8 @@ import os import re import types +from unittest import mock +from xml.sax import handler import main from core import feconf, handler_schema_constants, utils @@ -45,7 +47,7 @@ import webapp2 import webtest -from typing import Dict, Final, FrozenSet, List, Optional, TypedDict, cast +from typing import Any, Dict, Final, FrozenSet, List, Optional, TypedDict, cast from webapp2_extras import routes MYPY = False @@ -143,6 +145,15 @@ def get(self) -> None: """Handles GET requests.""" pass + class MockPostHandler(base.BaseHandler[Dict[str, str], Dict[str, str]]): + URL_PATH_ARGS_SCHEMAS = {} + HANDLER_ARGS_SCHEMAS = { + 'POST': {'custom_key': {'schema': {'type': 'unicode'}}} + } + + def post(self, *args: Any) -> None: + pass + def setUp(self) -> None: super(BaseHandlerTests, self).setUp() self.signup('user@example.com', 'user') @@ -565,7 +576,7 @@ def test_signup_attempt_on_wrong_page_fails(self) -> None: ) response = self.get_html_response('/', expected_status_int=200) self.assertIn( - b'', + b'', response.body, ) @@ -597,6 +608,27 @@ def test_user_without_email_id_raises_exception(self) -> None: logs, ['No email address was found for the user.'] ) + def test_validate_and_normalize_args_with_empty_payload_string( + self, + ) -> None: + """Test argument normalization when payload is naturally None.""" + mock_request = mock.Mock() + mock_request.environ = {'REQUEST_METHOD': 'POST'} + mock_request.route_kwargs = {} + mock_request.arguments.return_value = ['payload', 'custom_key'] + + def mock_get_side_effect(arg_name: str) -> Optional[str]: + if arg_name == 'payload': + return '' + if arg_name == 'custom_key': + return 'some_value' + return None + + mock_request.get.side_effect = mock_get_side_effect + handler = self.MockPostHandler(mock_request, mock.Mock()) + + handler.validate_and_normalize_args() + def test_logs_request_with_invalid_payload(self) -> None: with contextlib.ExitStack() as exit_stack: logs = exit_stack.enter_context( diff --git a/core/controllers/blog_dashboard.py b/core/controllers/blog_dashboard.py index 9069cb2e954aa..46367e364efd0 100644 --- a/core/controllers/blog_dashboard.py +++ b/core/controllers/blog_dashboard.py @@ -334,7 +334,7 @@ def put(self, blog_post_id: str) -> None: blog_post_id ).to_dict() - self.values.update({'blog_post': blog_post_dict}) + self.values.update({'blog_post_dict': blog_post_dict}) self.render_json(self.values) @acl_decorators.can_edit_blog_post diff --git a/core/controllers/blog_dashboard_test.py b/core/controllers/blog_dashboard_test.py index c1f637a9922ff..d96eca0bf41c8 100644 --- a/core/controllers/blog_dashboard_test.py +++ b/core/controllers/blog_dashboard_test.py @@ -431,7 +431,9 @@ def test_put_blog_post_data(self) -> None: csrf_token=csrf_token, ) - self.assertEqual(json_response['blog_post']['title'], 'Sample Title') + self.assertEqual( + json_response['blog_post_dict']['title'], 'Sample Title' + ) blog_post = blog_services.get_blog_post_by_id(self.blog_post.id) self.assertEqual(blog_post.thumbnail_filename, 'file.svg') diff --git a/core/controllers/blog_homepage.py b/core/controllers/blog_homepage.py index 986c6c60c9b81..bb3b2445598dd 100644 --- a/core/controllers/blog_homepage.py +++ b/core/controllers/blog_homepage.py @@ -199,32 +199,20 @@ def get(self) -> None: published_post_summaries ) ) - # Total number of published blog posts is calculated only when we load - # the blog home page for the first time (search offset will be 0). - # It is not required to load other subsequent pages as the value is - # already loaded in the frontend. - if offset != 0: - self.values.update( - { - 'blog_post_summary_dicts': published_post_summary_dicts, - } - ) - self.render_json(self.values) - else: - number_of_published_blog_post_summaries = ( - blog_services.get_total_number_of_published_blog_post_summaries() - ) - list_of_default_tags = constants.LIST_OF_DEFAULT_TAGS_FOR_BLOG_POST - self.values.update( - { - 'no_of_blog_post_summaries': ( - number_of_published_blog_post_summaries - ), - 'blog_post_summary_dicts': published_post_summary_dicts, - 'list_of_default_tags': list_of_default_tags, - } - ) - self.render_json(self.values) + number_of_published_blog_post_summaries = ( + blog_services.get_total_number_of_published_blog_post_summaries() + ) + list_of_default_tags = constants.LIST_OF_DEFAULT_TAGS_FOR_BLOG_POST + self.values.update( + { + 'no_of_blog_post_summaries': ( + number_of_published_blog_post_summaries + ), + 'blog_post_summary_dicts': published_post_summary_dicts, + 'list_of_default_tags': list_of_default_tags, + } + ) + self.render_json(self.values) class BlogPostDataHandler(base.BaseHandler[Dict[str, str], Dict[str, str]]): @@ -484,11 +472,18 @@ def get(self) -> None: ) list_of_default_tags = constants.LIST_OF_DEFAULT_TAGS_FOR_BLOG_POST + total_matching_blog_posts = ( + blog_services.get_total_number_of_matching_blog_posts( + query_string, tags + ) + ) + self.values.update( { 'blog_post_summaries_list': blog_post_summary_dicts, 'search_offset': new_search_offset, 'list_of_default_tags': list_of_default_tags, + 'total_matching_blog_posts': total_matching_blog_posts, } ) diff --git a/core/controllers/collection_editor.py b/core/controllers/collection_editor.py index dc3a4f1acdd93..1dd79ebc469da 100644 --- a/core/controllers/collection_editor.py +++ b/core/controllers/collection_editor.py @@ -112,7 +112,7 @@ def get(self, collection_id: str) -> None: collection_id, self.user, allow_invalid_explorations=True ) - self.values.update({'collection': collection_dict}) + self.values.update({'collection_dict': collection_dict}) self.render_json(self.values) @@ -144,7 +144,7 @@ def put(self, collection_id: str) -> None: ) # Send the updated collection back to the frontend. - self.values.update({'collection': collection_dict}) + self.values.update({'collection_dict': collection_dict}) self.render_json(self.values) diff --git a/core/controllers/collection_editor_test.py b/core/controllers/collection_editor_test.py index 80e3c1a8d9915..747f9a42025c7 100644 --- a/core/controllers/collection_editor_test.py +++ b/core/controllers/collection_editor_test.py @@ -100,7 +100,9 @@ def test_editable_collection_handler_get(self) -> None: '%s/%s' % (feconf.COLLECTION_EDITOR_DATA_URL_PREFIX, self.COLLECTION_ID) ) - self.assertEqual(self.COLLECTION_ID, json_response['collection']['id']) + self.assertEqual( + self.COLLECTION_ID, json_response['collection_dict']['id'] + ) self.logout() def test_editable_collection_handler_put_with_invalid_payload_version( @@ -224,8 +226,10 @@ def test_editable_collection_handler_put_can_access(self) -> None: csrf_token=csrf_token, ) - self.assertEqual(self.COLLECTION_ID, json_response['collection']['id']) - self.assertEqual(2, json_response['collection']['version']) + self.assertEqual( + self.COLLECTION_ID, json_response['collection_dict']['id'] + ) + self.assertEqual(2, json_response['collection_dict']['version']) self.logout() diff --git a/core/controllers/collection_viewer.py b/core/controllers/collection_viewer.py index d0a24a173eb0f..694a356c61503 100644 --- a/core/controllers/collection_viewer.py +++ b/core/controllers/collection_viewer.py @@ -51,7 +51,7 @@ def get(self, collection_id: str) -> None: 'can_edit': rights_manager.check_can_edit_activity( self.user, collection_rights ), - 'collection': collection_dict, + 'collection_dict': collection_dict, 'is_logged_in': bool(self.user_id), 'session_id': utils.generate_new_session_id(), 'meta_name': collection_dict['title'], diff --git a/core/controllers/collection_viewer_test.py b/core/controllers/collection_viewer_test.py index cb8d701ad35c6..33a9b1b8ad607 100644 --- a/core/controllers/collection_viewer_test.py +++ b/core/controllers/collection_viewer_test.py @@ -46,7 +46,7 @@ def test_welcome_collection(self) -> None: response_dict = self.get_json( '%s/0' % feconf.COLLECTION_DATA_URL_PREFIX ) - collection_dict = response_dict['collection'] + collection_dict = response_dict['collection_dict'] # Verify the collection was properly loaded. self.assertEqual( @@ -75,7 +75,7 @@ def test_welcome_collection(self) -> None: response_dict = self.get_json( '%s/0' % feconf.COLLECTION_DATA_URL_PREFIX ) - collection_dict = response_dict['collection'] + collection_dict = response_dict['collection_dict'] playthrough_dict = collection_dict['playthrough_dict'] self.assertEqual(playthrough_dict['next_exploration_id'], '20') @@ -88,7 +88,7 @@ def test_welcome_collection(self) -> None: response_dict = self.get_json( '%s/0' % feconf.COLLECTION_DATA_URL_PREFIX ) - collection_dict = response_dict['collection'] + collection_dict = response_dict['collection_dict'] playthrough_dict = collection_dict['playthrough_dict'] self.assertEqual(playthrough_dict['next_exploration_id'], '21') @@ -104,7 +104,7 @@ def test_welcome_collection(self) -> None: response_dict = self.get_json( '%s/0' % feconf.COLLECTION_DATA_URL_PREFIX ) - collection_dict = response_dict['collection'] + collection_dict = response_dict['collection_dict'] playthrough_dict = collection_dict['playthrough_dict'] self.assertEqual(playthrough_dict['next_exploration_id'], '0') @@ -119,7 +119,7 @@ def test_welcome_collection(self) -> None: response_dict = self.get_json( '%s/0' % feconf.COLLECTION_DATA_URL_PREFIX ) - collection_dict = response_dict['collection'] + collection_dict = response_dict['collection_dict'] playthrough_dict = collection_dict['playthrough_dict'] self.assertEqual(playthrough_dict['next_exploration_id'], None) diff --git a/core/controllers/contributor_dashboard.py b/core/controllers/contributor_dashboard.py index a48bf3ab1583c..4c5ec8587f31e 100644 --- a/core/controllers/contributor_dashboard.py +++ b/core/controllers/contributor_dashboard.py @@ -414,6 +414,7 @@ def _get_reviewable_exploration_opportunity_summaries( for item in exp_opp_summaries.values(): if item is not None: ordered_exp_opp_summaries[item.id] = item + return list(ordered_exp_opp_summaries.values()) diff --git a/core/controllers/contributor_dashboard_test.py b/core/controllers/contributor_dashboard_test.py index 0eabc965c05fb..4d5e982e49bfe 100644 --- a/core/controllers/contributor_dashboard_test.py +++ b/core/controllers/contributor_dashboard_test.py @@ -152,6 +152,7 @@ def setUp(self) -> None: 'content_count': 2, 'translation_counts': {}, 'translation_in_review_counts': {}, + 'reviewer_only_content_count': 0, 'is_pinned': False, } self.expected_opportunity_dict_2 = { @@ -162,6 +163,7 @@ def setUp(self) -> None: 'content_count': 2, 'translation_counts': {}, 'translation_in_review_counts': {}, + 'reviewer_only_content_count': 0, 'is_pinned': False, } self.expected_opportunity_dict_3 = { @@ -172,6 +174,7 @@ def setUp(self) -> None: 'content_count': 2, 'translation_counts': {}, 'translation_in_review_counts': {}, + 'reviewer_only_content_count': 0, 'is_pinned': False, } @@ -677,6 +680,7 @@ def test_get_reviewable_translation_opportunities_with_some_opportunities_set_to 'content_count': 2, 'translation_counts': {}, 'translation_in_review_counts': {}, + 'reviewer_only_content_count': 0, 'is_pinned': False, } expected_opp_dict_2 = { @@ -687,6 +691,7 @@ def test_get_reviewable_translation_opportunities_with_some_opportunities_set_to 'content_count': 2, 'translation_counts': {}, 'translation_in_review_counts': {}, + 'reviewer_only_content_count': 0, 'is_pinned': False, } @@ -779,6 +784,7 @@ def test_get_reviewable_translation_opportunities_with_pinned_opportunity( # py 'content_count': 2, 'translation_counts': {}, 'translation_in_review_counts': {}, + 'reviewer_only_content_count': 0, 'is_pinned': True, } expected_opp_dict_2 = { @@ -789,6 +795,7 @@ def test_get_reviewable_translation_opportunities_with_pinned_opportunity( # py 'content_count': 2, 'translation_counts': {}, 'translation_in_review_counts': {}, + 'reviewer_only_content_count': 0, 'is_pinned': False, } @@ -1027,6 +1034,7 @@ def test_get_reviewable_translation_opportunities_when_state_is_removed( 'content_count': 4, 'translation_counts': {}, 'translation_in_review_counts': {}, + 'reviewer_only_content_count': 0, 'is_pinned': False, } ], @@ -1145,6 +1153,7 @@ def test_get_reviewable_translation_opportunities_when_original_content_is_remov 'content_count': 4, 'translation_counts': {}, 'translation_in_review_counts': {}, + 'reviewer_only_content_count': 0, 'is_pinned': False, } ], @@ -2767,6 +2776,7 @@ def test_get_contributor_certificate(self) -> None: 'from_date': from_date.strftime('%d %b %Y'), 'to_date': to_date.strftime('%d %b %Y'), 'contribution_hours': '0.01', + 'contribution_word_count': 3, 'team_lead': feconf.TRANSLATION_TEAM_LEAD, 'language': 'Hindi', }, diff --git a/core/controllers/creator_dashboard.py b/core/controllers/creator_dashboard.py index 990c41b394baf..e7d09541e641e 100644 --- a/core/controllers/creator_dashboard.py +++ b/core/controllers/creator_dashboard.py @@ -250,12 +250,12 @@ def _round_average_ratings(rating: float) -> float: feedback_thread_analytics ), } - if dashboard_stats: - average_ratings = dashboard_stats_dict.get('average_ratings') - if average_ratings: - dashboard_stats_dict['average_ratings'] = ( - _round_average_ratings(average_ratings) - ) + + average_ratings = dashboard_stats_dict.get('average_ratings') + if average_ratings: + dashboard_stats_dict['average_ratings'] = _round_average_ratings( + average_ratings + ) last_week_stats = user_services.get_last_week_dashboard_stats( self.user_id diff --git a/core/controllers/creator_dashboard_test.py b/core/controllers/creator_dashboard_test.py index b988e8c4a792d..0507e9f4be68c 100644 --- a/core/controllers/creator_dashboard_test.py +++ b/core/controllers/creator_dashboard_test.py @@ -90,7 +90,7 @@ def test_logged_out_homepage(self) -> None: """Test the logged-out version of the home page.""" response = self.get_html_response('/') self.assertEqual(response.status_int, 200) - self.assertIn('', response) + self.assertIn('', response) class CreatorDashboardHandlerTests(test_utils.GenericTestBase): @@ -442,6 +442,38 @@ def test_last_week_stats_produce_exception(self) -> None: }, ) + def test_last_week_stats_without_average_ratings(self) -> None: + """Tests the last week stats without the average rating .""" + self.login(self.OWNER_EMAIL, is_super_admin=True) + + get_last_week_dashboard_stats_swap = self.swap( + user_services, + 'get_last_week_dashboard_stats', + lambda _: { + 'key_2': { + 'num_ratings': 0, + 'average_ratings': None, + 'total_plays': 10, + } + }, + ) + + with get_last_week_dashboard_stats_swap: + last_week_stats = self.get_json(feconf.CREATOR_DASHBOARD_DATA_URL)[ + 'last_week_stats' + ] + + self.assertEqual( + last_week_stats, + { + 'key_2': { + 'num_ratings': 0, + 'average_ratings': None, + 'total_plays': 10, + } + }, + ) + def test_broken_last_week_stats_produce_exception(self) -> None: self.login(self.OWNER_EMAIL, is_super_admin=True) diff --git a/core/controllers/cron.py b/core/controllers/cron.py index ed67fd1074776..fbeb0f6bb3cd8 100644 --- a/core/controllers/cron.py +++ b/core/controllers/cron.py @@ -34,6 +34,7 @@ ) from core.jobs.batch_jobs import ( blog_post_search_indexing_jobs, + cloud_task_run_migration_jobs, exp_recommendation_computation_jobs, exp_search_indexing_jobs, user_stats_computation_jobs, @@ -418,3 +419,37 @@ def get(self) -> None: admin_ids, chapter_notifications_stories_list ) return self.render_json({}) + + +class CronMarkStaleCloudTaskRunModelsAsFailedHandler( + base.BaseHandler[Dict[str, str], Dict[str, str]] +): + """Handler for marking stale CloudTaskRunModels as failed.""" + + GET_HANDLER_ERROR_RETURN_TYPE = feconf.HANDLER_TYPE_JSON + URL_PATH_ARGS_SCHEMAS: Dict[str, str] = {} + HANDLER_ARGS_SCHEMAS: Dict[str, Dict[str, str]] = {'GET': {}} + + @acl_decorators.can_perform_cron_tasks + def get(self) -> None: + """Handles GET requests.""" + beam_job_services.run_beam_job( + job_class=cloud_task_run_migration_jobs.MarkStaleCloudTaskRunModelsAsFailedJob + ) + + +class CronMarkStaleVoiceoverRegenerationContentAsFailedHandler( + base.BaseHandler[Dict[str, str], Dict[str, str]] +): + """Handler for marking stale voiceover regeneration content as failed.""" + + GET_HANDLER_ERROR_RETURN_TYPE = feconf.HANDLER_TYPE_JSON + URL_PATH_ARGS_SCHEMAS: Dict[str, str] = {} + HANDLER_ARGS_SCHEMAS: Dict[str, Dict[str, str]] = {'GET': {}} + + @acl_decorators.can_perform_cron_tasks + def get(self) -> None: + """Handles GET requests.""" + beam_job_services.run_beam_job( + job_class=cloud_task_run_migration_jobs.MarkStaleVoiceoverRegenerationJobModelsAsFailedJob + ) diff --git a/core/controllers/cron_test.py b/core/controllers/cron_test.py index 0b0559962eeca..a6af9f223b44e 100644 --- a/core/controllers/cron_test.py +++ b/core/controllers/cron_test.py @@ -41,6 +41,7 @@ ) from core.jobs.batch_jobs import ( blog_post_search_indexing_jobs, + cloud_task_run_migration_jobs, exp_recommendation_computation_jobs, exp_search_indexing_jobs, user_stats_computation_jobs, @@ -1410,3 +1411,63 @@ def test_email_sent_if_sending_emails_is_enabled(self) -> None: ) self.logout() + + +class CronMarkStaleCloudTaskRunModelsAsFailedHandlerTests( + test_utils.GenericTestBase +): + """Tests for CronMarkStaleCloudTaskRunModelsAsFailedHandler.""" + + def test_cron_mark_stale_cloud_task_run_models_as_failed_handler( + self, + ) -> None: + testapp_swap = self.swap( + self, 'testapp', webtest.TestApp(main.app_without_context) + ) + self.login(self.CURRICULUM_ADMIN_EMAIL, is_super_admin=True) + swap_with_checks = self.swap_with_checks( + beam_job_services, + 'run_beam_job', + lambda **_: None, + expected_kwargs=[ + { + 'job_class': ( + cloud_task_run_migration_jobs.MarkStaleCloudTaskRunModelsAsFailedJob + ), + } + ], + ) + with swap_with_checks, testapp_swap: + self.get_html_response( + '/cron/cloud_task/mark_stale_cloud_task_run_as_failed' + ) + + +class CronMarkStaleVoiceoverRegenerationContentAsFailedHandlerTests( + test_utils.GenericTestBase +): + """Tests for CronMarkStaleVoiceoverRegenerationContentAsFailedHandler.""" + + def test_cron_mark_stale_voiceover_regeneration_content_as_failed_handler( + self, + ) -> None: + testapp_swap = self.swap( + self, 'testapp', webtest.TestApp(main.app_without_context) + ) + self.login(self.CURRICULUM_ADMIN_EMAIL, is_super_admin=True) + swap_with_checks = self.swap_with_checks( + beam_job_services, + 'run_beam_job', + lambda **_: None, + expected_kwargs=[ + { + 'job_class': ( + cloud_task_run_migration_jobs.MarkStaleVoiceoverRegenerationJobModelsAsFailedJob + ), + } + ], + ) + with swap_with_checks, testapp_swap: + self.get_html_response( + '/cron/cloud_task/mark_stale_voiceover_regeneration_content_as_failed' + ) diff --git a/core/controllers/editor.py b/core/controllers/editor.py index a538891a07925..973a9c63d6c8c 100644 --- a/core/controllers/editor.py +++ b/core/controllers/editor.py @@ -768,7 +768,7 @@ def put(self, exploration_id: str) -> None: self.render_json( { - 'rights': exp_rights.to_dict(), + 'rights_dict': exp_rights.to_dict(), } ) @@ -839,7 +839,7 @@ def put(self, exploration_id: str) -> None: ) ) self.render_json( - {'email_preferences': exploration_email_preferences.to_dict()} + {'email_preferences_dict': exploration_email_preferences.to_dict()} ) diff --git a/core/controllers/feedback.py b/core/controllers/feedback.py index 481f732d1459a..79d1bb2884bc9 100644 --- a/core/controllers/feedback.py +++ b/core/controllers/feedback.py @@ -380,7 +380,7 @@ def get(self, thread_id: str) -> None: self.values.update( { 'messages': update_author_id_in_message_dicts(message_dicts), - 'suggestion': suggestion.to_dict() if suggestion else None, + 'suggestion_dict': suggestion.to_dict() if suggestion else None, } ) self.render_json(self.values) diff --git a/core/controllers/learner_dashboard.py b/core/controllers/learner_dashboard.py index 449b595ac53de..1fa36b975f2af 100644 --- a/core/controllers/learner_dashboard.py +++ b/core/controllers/learner_dashboard.py @@ -19,6 +19,7 @@ from core import feconf from core.controllers import acl_decorators, base from core.domain import ( + collection_services, learner_progress_services, story_fetchers, subscription_services, @@ -203,7 +204,7 @@ class LearnerDashboardCollectionsProgressHandler( def get(self) -> None: """Handles GET requests.""" assert self.user_id is not None - (learner_progress, number_of_nonexistent_collections) = ( + learner_progress, number_of_nonexistent_collections = ( learner_progress_services.get_collection_progress(self.user_id) ) @@ -224,6 +225,27 @@ def get(self) -> None: ) ) + # Completed collections have all nodes done. + for summary_dict in completed_collection_summary_dicts: + summary_dict['completed_node_count'] = summary_dict[ + 'total_node_count' + ] + + # Fetch completed node counts for incomplete collections. + incomplete_collection_ids = [ + d['id'] for d in incomplete_collection_summary_dicts + ] + completed_exps_per_collection = ( + collection_services.get_explorations_completed_in_collections( + self.user_id, incomplete_collection_ids + ) + ) + for summary_dict, completed_exps in zip( + incomplete_collection_summary_dicts, + completed_exps_per_collection, + ): + summary_dict['completed_node_count'] = len(completed_exps) + self.values.update( { 'completed_collections_list': completed_collection_summary_dicts, @@ -253,7 +275,7 @@ class LearnerDashboardExplorationsProgressHandler( def get(self) -> None: """Handles GET requests.""" assert self.user_id is not None - (learner_progress, number_of_nonexistent_explorations) = ( + learner_progress, number_of_nonexistent_explorations = ( learner_progress_services.get_exploration_progress(self.user_id) ) @@ -275,6 +297,65 @@ def get(self) -> None: ) ) + seen_exploration_ids: set[str] = set() + exploration_ids_for_progress: list[str] = [] + for summary_dict in ( + incomplete_exp_summary_dicts + + completed_exp_summary_dicts + + exploration_playlist_summary_dicts + ): + exploration_id = summary_dict['id'] + if exploration_id not in seen_exploration_ids: + seen_exploration_ids.add(exploration_id) + exploration_ids_for_progress.append(exploration_id) + + progress_by_exp_id = ( + learner_progress_services.get_checkpoint_progress_for_explorations( + self.user_id, exploration_ids_for_progress + ) + ) + default_progress_data: ( + learner_progress_services.ExplorationCheckpointProgressDict + ) = { + 'visited_checkpoints_count': 0, + 'total_checkpoints_count': 0, + } + + # Add checkpoint progress counts to each exploration summary. + # Frontend will calculate percentage using the classroom lessons pattern. + for summary_dict in incomplete_exp_summary_dicts: + progress_data = progress_by_exp_id.get( + summary_dict['id'], default_progress_data + ) + summary_dict['visited_checkpoints_count'] = progress_data[ + 'visited_checkpoints_count' + ] + summary_dict['total_checkpoints_count'] = progress_data[ + 'total_checkpoints_count' + ] + + for summary_dict in completed_exp_summary_dicts: + progress_data = progress_by_exp_id.get( + summary_dict['id'], default_progress_data + ) + summary_dict['visited_checkpoints_count'] = progress_data[ + 'visited_checkpoints_count' + ] + summary_dict['total_checkpoints_count'] = progress_data[ + 'total_checkpoints_count' + ] + + for summary_dict in exploration_playlist_summary_dicts: + progress_data = progress_by_exp_id.get( + summary_dict['id'], default_progress_data + ) + summary_dict['visited_checkpoints_count'] = progress_data[ + 'visited_checkpoints_count' + ] + summary_dict['total_checkpoints_count'] = progress_data[ + 'total_checkpoints_count' + ] + creators_subscribed_to = ( subscription_services.get_all_creators_subscribed_to(self.user_id) ) diff --git a/core/controllers/learner_dashboard_test.py b/core/controllers/learner_dashboard_test.py index 0be9f31e93df3..cd3ce146dbe24 100644 --- a/core/controllers/learner_dashboard_test.py +++ b/core/controllers/learner_dashboard_test.py @@ -19,6 +19,9 @@ from core import feconf from core.constants import constants from core.domain import ( + exp_domain, + exp_fetchers, + exp_services, learner_progress_services, story_domain, story_services, @@ -26,10 +29,13 @@ topic_domain, topic_services, ) +from core.platform import models from core.tests import test_utils from typing import Final +user_models = models.Registry.import_models([models.Names.USER])[0] + class OldLearnerDashboardRedirectPageTest(test_utils.GenericTestBase): """Test for redirecting the old learner dashboard page URL @@ -354,6 +360,45 @@ def test_can_see_untracked_topics(self) -> None: self.assertEqual(len(response['untracked_topics']), 1) self.logout() + def test_duplicate_exploration_ids_are_handled_correctly(self) -> None: + """Test that duplicate exploration ids across categories are skipped + to avoid redundant progress tracking. + """ + self.login(self.VIEWER_EMAIL) + self.save_new_default_exploration( + self.EXP_ID_1, self.owner_id, title=self.EXP_TITLE_1 + ) + self.publish_exploration(self.owner_id, self.EXP_ID_1) + learner_progress_services.mark_exploration_as_completed( + self.viewer_id, self.EXP_ID_1 + ) + + playlist_model = user_models.LearnerPlaylistModel.get_by_id( + self.viewer_id + ) + if playlist_model is None: + playlist_model = user_models.LearnerPlaylistModel( + id=self.viewer_id, exploration_ids=[], collection_ids=[] + ) + + playlist_model.exploration_ids.append(self.EXP_ID_1) + playlist_model.update_timestamps() + playlist_model.put() + + response = self.get_json(feconf.LEARNER_DASHBOARD_EXPLORATION_DATA_URL) + + self.assertEqual(len(response['completed_explorations_list']), 1) + self.assertEqual(len(response['exploration_playlist']), 1) + + self.assertEqual( + response['completed_explorations_list'][0]['id'], self.EXP_ID_1 + ) + self.assertEqual( + response['exploration_playlist'][0]['id'], self.EXP_ID_1 + ) + + self.logout() + def test_get_learner_dashboard_ids(self) -> None: self.login(self.VIEWER_EMAIL) @@ -946,3 +991,389 @@ def test_can_see_subscription(self) -> None: self.OWNER_USERNAME, ) self.logout() + + def test_exploration_progress_is_zero_for_new_explorations(self) -> None: + """Test that progress is 0 for explorations with no checkpoint data.""" + self.login(self.VIEWER_EMAIL) + + # Create and publish an exploration with checkpoint. + exploration = self.save_new_valid_exploration( + self.EXP_ID_1, + self.owner_id, + title=self.EXP_TITLE_1, + category='Test', + ) + exp_services.update_exploration( + self.owner_id, + self.EXP_ID_1, + [ + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': exploration.init_state_name, + 'property_name': exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT, + 'new_value': True, + } + ), + ], + 'Mark initial state as checkpoint', + ) + self.publish_exploration(self.owner_id, self.EXP_ID_1) + + # Mark as incomplete without visiting checkpoints. + learner_progress_services.mark_exploration_as_incomplete( + self.viewer_id, self.EXP_ID_1, exploration.init_state_name, 1 + ) + + response = self.get_json(feconf.LEARNER_DASHBOARD_EXPLORATION_DATA_URL) + incomplete_exps = response['incomplete_explorations_list'] + self.assertEqual(len(incomplete_exps), 1) + self.assertEqual(incomplete_exps[0]['id'], self.EXP_ID_1) + self.assertEqual(incomplete_exps[0]['visited_checkpoints_count'], 0) + self.assertEqual(incomplete_exps[0]['total_checkpoints_count'], 1) + + self.logout() + + def test_exploration_progress_calculation_with_checkpoints(self) -> None: + """Test that progress is correctly calculated based on checkpoints.""" + self.login(self.VIEWER_EMAIL) + + # Create exploration with checkpoint. + exploration = self.save_new_valid_exploration( + self.EXP_ID_1, + self.owner_id, + title=self.EXP_TITLE_1, + category='Test', + ) + exp_services.update_exploration( + self.owner_id, + self.EXP_ID_1, + [ + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': exploration.init_state_name, + 'property_name': exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT, + 'new_value': True, + } + ), + ], + 'Mark initial state as checkpoint', + ) + self.publish_exploration(self.owner_id, self.EXP_ID_1) + + # Mark as incomplete and record checkpoint progress. + learner_progress_services.mark_exploration_as_incomplete( + self.viewer_id, self.EXP_ID_1, exploration.init_state_name, 1 + ) + + # Record checkpoint progress (visited the only checkpoint). + user_models.ExplorationUserDataModel( + id='%s.%s' % (self.viewer_id, self.EXP_ID_1), + user_id=self.viewer_id, + exploration_id=self.EXP_ID_1, + most_recently_reached_checkpoint_state_name=exploration.init_state_name, + ).put() + + response = self.get_json(feconf.LEARNER_DASHBOARD_EXPLORATION_DATA_URL) + incomplete_exps = response['incomplete_explorations_list'] + self.assertEqual(len(incomplete_exps), 1) + self.assertEqual(incomplete_exps[0]['visited_checkpoints_count'], 1) + self.assertEqual(incomplete_exps[0]['total_checkpoints_count'], 1) + + self.logout() + + def test_completed_exploration_progress_is_100(self) -> None: + """Test that completed explorations always show 100% progress.""" + self.login(self.VIEWER_EMAIL) + + # Create and publish an exploration. + exploration = self.save_new_valid_exploration( + self.EXP_ID_1, + self.owner_id, + title=self.EXP_TITLE_1, + category='Test', + ) + exp_services.update_exploration( + self.owner_id, + self.EXP_ID_1, + [ + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': exploration.init_state_name, + 'property_name': exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT, + 'new_value': True, + } + ), + ], + 'Mark initial state as checkpoint', + ) + self.publish_exploration(self.owner_id, self.EXP_ID_1) + + # Mark as completed. + learner_progress_services.mark_exploration_as_completed( + self.viewer_id, self.EXP_ID_1 + ) + + response = self.get_json(feconf.LEARNER_DASHBOARD_EXPLORATION_DATA_URL) + completed_exps = response['completed_explorations_list'] + self.assertEqual(len(completed_exps), 1) + self.assertEqual(completed_exps[0]['id'], self.EXP_ID_1) + self.assertEqual(completed_exps[0]['visited_checkpoints_count'], 0) + self.assertEqual(completed_exps[0]['total_checkpoints_count'], 1) + + self.logout() + + def test_exploration_playlist_has_progress_field(self) -> None: + """Test that exploration playlist items include progress field.""" + self.login(self.VIEWER_EMAIL) + + # Create and publish an exploration. + exploration = self.save_new_valid_exploration( + self.EXP_ID_1, + self.owner_id, + title=self.EXP_TITLE_1, + category='Test', + ) + exp_services.update_exploration( + self.owner_id, + self.EXP_ID_1, + [ + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': exploration.init_state_name, + 'property_name': exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT, + 'new_value': True, + } + ), + ], + 'Mark initial state as checkpoint', + ) + self.publish_exploration(self.owner_id, self.EXP_ID_1) + + # Add to playlist. + learner_progress_services.add_exp_to_learner_playlist( + self.viewer_id, self.EXP_ID_1 + ) + + response = self.get_json(feconf.LEARNER_DASHBOARD_EXPLORATION_DATA_URL) + playlist = response['exploration_playlist'] + self.assertEqual(len(playlist), 1) + self.assertEqual(playlist[0]['id'], self.EXP_ID_1) + self.assertEqual(playlist[0]['visited_checkpoints_count'], 0) + self.assertEqual(playlist[0]['total_checkpoints_count'], 1) + + self.logout() + + def test_multiple_explorations_have_individual_progress(self) -> None: + """Test that multiple explorations each have their own progress.""" + self.login(self.VIEWER_EMAIL) + + # Create three explorations with checkpoints. + for i, exp_id in enumerate( + [self.EXP_ID_1, self.EXP_ID_2, self.EXP_ID_3] + ): + exploration = self.save_new_valid_exploration( + exp_id, + self.owner_id, + title=f'Test Exploration {i+1}', + category='Test', + ) + exp_services.update_exploration( + self.owner_id, + exp_id, + [ + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': exploration.init_state_name, + 'property_name': exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT, + 'new_value': True, + } + ), + ], + 'Mark initial state as checkpoint', + ) + self.publish_exploration(self.owner_id, exp_id) + learner_progress_services.mark_exploration_as_incomplete( + self.viewer_id, exp_id, exploration.init_state_name, 1 + ) + + # Set different checkpoint progress for each. + # EXP_ID_1: No checkpoint visited (0%) + # EXP_ID_2: No checkpoint visited (0%) + # EXP_ID_3: Visited the checkpoint (0% = floor((1-1)/1*100) = 0%) + exp_3 = exp_fetchers.get_exploration_by_id(self.EXP_ID_3) + user_models.ExplorationUserDataModel( + id='%s.%s' % (self.viewer_id, self.EXP_ID_3), + user_id=self.viewer_id, + exploration_id=self.EXP_ID_3, + most_recently_reached_checkpoint_state_name=exp_3.init_state_name, + ).put() + + response = self.get_json(feconf.LEARNER_DASHBOARD_EXPLORATION_DATA_URL) + incomplete_exps = response['incomplete_explorations_list'] + self.assertEqual(len(incomplete_exps), 3) + + # Find each exploration and check its checkpoint counts. + exp_counts_map = { + exp['id']: ( + exp['visited_checkpoints_count'], + exp['total_checkpoints_count'], + ) + for exp in incomplete_exps + } + self.assertEqual(exp_counts_map[self.EXP_ID_1], (0, 1)) + self.assertEqual(exp_counts_map[self.EXP_ID_2], (0, 1)) + self.assertEqual(exp_counts_map[self.EXP_ID_3], (1, 1)) + + self.logout() + + def test_exploration_progress_with_missing_progress_data(self) -> None: + """Test that progress is 0 when progress data is missing.""" + self.login(self.VIEWER_EMAIL) + + # Create and publish an exploration with checkpoint. + exploration = self.save_new_valid_exploration( + self.EXP_ID_1, + self.owner_id, + title=self.EXP_TITLE_1, + category='Test', + ) + exp_services.update_exploration( + self.owner_id, + self.EXP_ID_1, + [ + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': exploration.init_state_name, + 'property_name': exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT, + 'new_value': True, + } + ), + ], + 'Mark initial state as checkpoint', + ) + self.publish_exploration(self.owner_id, self.EXP_ID_1) + + # Mark as incomplete without visiting checkpoints. + learner_progress_services.mark_exploration_as_incomplete( + self.viewer_id, self.EXP_ID_1, exploration.init_state_name, 1 + ) + + with self.swap_to_always_return( + learner_progress_services, + 'get_checkpoint_progress_for_explorations', + {}, + ): + response = self.get_json( + feconf.LEARNER_DASHBOARD_EXPLORATION_DATA_URL + ) + + incomplete_exps = response['incomplete_explorations_list'] + self.assertEqual(len(incomplete_exps), 1) + self.assertEqual(incomplete_exps[0]['visited_checkpoints_count'], 0) + self.assertEqual(incomplete_exps[0]['total_checkpoints_count'], 0) + + self.logout() + + def test_completed_exploration_without_progress_data(self) -> None: + """Test completed explorations when no progress data is available.""" + self.login(self.VIEWER_EMAIL) + + # Create and publish an exploration. + exploration = self.save_new_valid_exploration( + self.EXP_ID_1, + self.owner_id, + title=self.EXP_TITLE_1, + category='Test', + ) + exp_services.update_exploration( + self.owner_id, + self.EXP_ID_1, + [ + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': exploration.init_state_name, + 'property_name': exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT, + 'new_value': True, + } + ), + ], + 'Mark initial state as checkpoint', + ) + self.publish_exploration(self.owner_id, self.EXP_ID_1) + + # Mark as completed. + learner_progress_services.mark_exploration_as_completed( + self.viewer_id, self.EXP_ID_1 + ) + + with self.swap_to_always_return( + learner_progress_services, + 'get_checkpoint_progress_for_explorations', + {}, + ): + response = self.get_json( + feconf.LEARNER_DASHBOARD_EXPLORATION_DATA_URL + ) + + completed_exps = response['completed_explorations_list'] + self.assertEqual(len(completed_exps), 1) + self.assertEqual(completed_exps[0]['visited_checkpoints_count'], 0) + self.assertEqual(completed_exps[0]['total_checkpoints_count'], 0) + + self.logout() + + def test_exploration_playlist_without_progress_data(self) -> None: + """Test exploration playlist when no progress data is available.""" + self.login(self.VIEWER_EMAIL) + + # Create and publish an exploration. + exploration = self.save_new_valid_exploration( + self.EXP_ID_1, + self.owner_id, + title=self.EXP_TITLE_1, + category='Test', + ) + exp_services.update_exploration( + self.owner_id, + self.EXP_ID_1, + [ + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': exploration.init_state_name, + 'property_name': exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT, + 'new_value': True, + } + ), + ], + 'Mark initial state as checkpoint', + ) + self.publish_exploration(self.owner_id, self.EXP_ID_1) + + # Add to playlist. + learner_progress_services.add_exp_to_learner_playlist( + self.viewer_id, self.EXP_ID_1 + ) + + with self.swap_to_always_return( + learner_progress_services, + 'get_checkpoint_progress_for_explorations', + {}, + ): + response = self.get_json( + feconf.LEARNER_DASHBOARD_EXPLORATION_DATA_URL + ) + + playlist = response['exploration_playlist'] + self.assertEqual(len(playlist), 1) + self.assertEqual(playlist[0]['visited_checkpoints_count'], 0) + self.assertEqual(playlist[0]['total_checkpoints_count'], 0) + + self.logout() diff --git a/core/controllers/learner_playlist.py b/core/controllers/learner_playlist.py index 10ef188e5df07..218f8dce643e6 100644 --- a/core/controllers/learner_playlist.py +++ b/core/controllers/learner_playlist.py @@ -75,7 +75,7 @@ def post(self, activity_type: str, activity_id: str) -> None: activity_id, position_to_be_inserted=position_to_be_inserted_in, ) - elif activity_type == constants.ACTIVITY_TYPE_COLLECTION: + else: ( belongs_to_completed_or_incomplete_list, playlist_limit_exceeded, @@ -107,7 +107,7 @@ def delete(self, activity_type: str, activity_id: str) -> None: learner_playlist_services.remove_exploration_from_learner_playlist( self.user_id, activity_id ) - elif activity_type == constants.ACTIVITY_TYPE_COLLECTION: + else: learner_playlist_services.remove_collection_from_learner_playlist( self.user_id, activity_id ) diff --git a/core/controllers/library.py b/core/controllers/library.py index 7117b31063b37..c430a7b7af2c2 100644 --- a/core/controllers/library.py +++ b/core/controllers/library.py @@ -243,7 +243,7 @@ def get(self) -> None: activity_list = recently_published_summary_dicts header_i18n_id = feconf.LIBRARY_CATEGORY_RECENTLY_PUBLISHED - elif group_name == feconf.LIBRARY_GROUP_TOP_RATED: + else: top_rated_activity_summary_dicts = ( summary_services.get_top_rated_exploration_summary_dicts( [constants.DEFAULT_LANGUAGE_CODE], diff --git a/core/controllers/library_test.py b/core/controllers/library_test.py index cf6046cdf24c5..cddb2f024491e 100644 --- a/core/controllers/library_test.py +++ b/core/controllers/library_test.py @@ -22,6 +22,7 @@ from core import feconf, utils from core.constants import constants +from core.controllers import library as library_controllers from core.domain import ( activity_domain, activity_services, @@ -82,6 +83,50 @@ def test_library_page(self) -> None: response = self.get_html_response(feconf.LIBRARY_INDEX_URL) response.mustcontain('') + def test_get_matching_activity_dicts_skips_collection_search_with_offset( + self, + ) -> None: + observed_calls = {'collection_query_called': False} + + def mock_get_collection_ids_matching_query( + unused_query_string: str, + unused_categories: list[str], + unused_language_codes: list[str], + ) -> tuple[list[str], None]: + observed_calls['collection_query_called'] = True + return [], None + + def mock_get_exploration_ids_matching_query( + unused_query_string: str, + unused_categories: list[str], + unused_language_codes: list[str], + offset: int, + ) -> tuple[list[str], int]: + self.assertEqual(offset, 1) + return [], 2 + + with self.swap( + collection_services, + 'get_collection_ids_matching_query', + mock_get_collection_ids_matching_query, + ), self.swap( + exp_services, + 'get_exploration_ids_matching_query', + mock_get_exploration_ids_matching_query, + ): + activity_list, new_search_offset = ( + library_controllers.get_matching_activity_dicts( + query_string='query', + categories=[], + language_codes=[], + search_offset=1, + ) + ) + + self.assertEqual(activity_list, []) + self.assertEqual(new_search_offset, 2) + self.assertFalse(observed_calls['collection_query_called']) + def test_library_handler_for_collection_summaries(self) -> None: self.signup(self.OWNER_EMAIL, self.OWNER_USERNAME) owner_id = self.get_user_id_from_email(self.OWNER_EMAIL) diff --git a/core/controllers/oppia_root.py b/core/controllers/oppia_root.py index 5864fcf555565..cea5187bd55d8 100644 --- a/core/controllers/oppia_root.py +++ b/core/controllers/oppia_root.py @@ -42,42 +42,3 @@ def get(self, **kwargs: Dict[str, str]) -> None: return self.render_template('oppia-root.mainpage.html') - - -class OppiaLightweightRootPage( - base.BaseHandler[Dict[str, str], Dict[str, str]] -): - """Renders lightweight oppia root page (unified entry point) for all routes - registered with angular router. - """ - - # Using type ignore[misc] here because untyped decorator makes function - # "get" also untyped. - # The '**kwargs' argument is needed because some routes pass keyword - # arguments and even when we don't use them we need to allow them so that - # there is no error in the callsite. - @acl_decorators.open_access - def get(self, **kwargs: Dict[str, str]) -> None: - """Handles GET requests.""" - # The following logic determines which bundle to return. Currently the - # AoT bundle doesn't support rtl languages yet. So we switch between - # AoT and webpack bundle based on language direction. - # The order of preference to determine the language direction is: - # 1. Cookies - # 2. Url params - # In the case we don't find a language direction from the above two, - # we default to AoT bundle. - # TODO(#16300): Refactor the RTL css generation to add RTL CSS to the - # original CSS files instead of creating a new rtl CSS file - # NOTE: After the aforementioned issue is solved, the AoT bundle will be - # the only bundle that is returned. - if self.request.cookies.get('dir') == 'rtl': - self.render_template('lightweight-oppia-root.mainpage.html') - return - if self.request.cookies.get('dir') == 'ltr': - self.render_template('index.html', template_is_aot_compiled=True) - return - if self.request.get('dir') == 'rtl': - self.render_template('lightweight-oppia-root.mainpage.html') - return - self.render_template('index.html', template_is_aot_compiled=True) diff --git a/core/controllers/oppia_root_test.py b/core/controllers/oppia_root_test.py index 943d97bbd88aa..f4ea5c38a9c9b 100644 --- a/core/controllers/oppia_root_test.py +++ b/core/controllers/oppia_root_test.py @@ -29,95 +29,25 @@ def test_oppia_root_page(self) -> None: response = self.get_html_response( '/%s' % page['ROUTE'], expected_status_int=200 ) - if 'LIGHTWEIGHT' in page: - response.mustcontain( - '' - ) - else: - response.mustcontain('') + response.mustcontain('') + def test_explore_and_embed_urls_render_with_no_iframe_restriction( + self, + ) -> None: + """Tests that explore and embed URLs hit the iframe restriction bypass.""" -class OppiaLightweightRootPageTests(test_utils.GenericTestBase): - - def test_oppia_lightweight_root_page(self) -> None: - response = self.get_html_response('/', expected_status_int=200) - response.mustcontain( - '', - 'Loading | Oppia', - ) - - def test_oppia_lightweight_root_page_with_rtl_lang_param(self) -> None: - response = self.get_html_response('/?dir=rtl', expected_status_int=200) - response.mustcontain( - '', - no='Loading | Oppia', - ) - - def test_oppia_lightweight_root_page_with_ltr_lang_param(self) -> None: - response = self.get_html_response('/?dir=ltr', expected_status_int=200) - response.mustcontain( - '', - 'Loading | Oppia', - ) - - def test_oppia_lightweight_root_page_with_rtl_dir_cookie(self) -> None: - self.testapp.set_cookie('dir', 'rtl') - response = self.get_html_response('/', expected_status_int=200) - response.mustcontain( - '', - no='Loading | Oppia', - ) - - def test_oppia_lightweight_root_page_with_ltr_dir_cookie(self) -> None: - self.testapp.set_cookie('dir', 'ltr') - response = self.get_html_response('/', expected_status_int=200) - response.mustcontain( - '', - 'Loading | Oppia', - ) - - def test_return_bundle_modifier_precedence(self) -> None: - # In case of conflicting cookie and url param values for dir, cookie - # is preferred. - self.testapp.set_cookie('dir', 'ltr') - response = self.get_html_response('/?dir=rtl', expected_status_int=200) - response.mustcontain( - '', - 'Loading | Oppia', - ) - - self.testapp.set_cookie('dir', 'rtl') - response = self.get_html_response('/?dir=ltr', expected_status_int=200) - response.mustcontain( - '', - no='Loading | Oppia', - ) - - def test_invalid_bundle_modifier_values(self) -> None: - # In case of invalid values in cookie but valid query param respect the - # param value for dir. - self.testapp.set_cookie('dir', 'new_hacker_in_the_block') - response = self.get_html_response('/?dir=rtl', expected_status_int=200) - response.mustcontain( - '', - no='Loading | Oppia', - ) + valid_route = '' + for page in constants.PAGES_REGISTERED_WITH_FRONTEND.values(): + if 'MANUALLY_REGISTERED_WITH_BACKEND' not in page: + valid_route = page['ROUTE'] + break - self.testapp.set_cookie('dir', 'new_hacker_in_the_block') - response = self.get_html_response('/?dir=ltr', expected_status_int=200) - response.mustcontain( - '', - 'Loading | Oppia', + response_explore = self.get_html_response( + '/%s?explore=true' % valid_route, expected_status_int=200 ) - # The bundle modifier precedence guarantees that a valid cookie dir - # value will return the correct bundle. + response_explore.mustcontain('') - # When both modifiers are invalid, default to AoT bundle. - self.testapp.set_cookie('dir', 'new_hacker_in_the_block') - response = self.get_html_response( - '/?dir=is_trying_out', expected_status_int=200 - ) - response.mustcontain( - '', - 'Loading | Oppia', + response_embed = self.get_html_response( + '/%s?embed=true' % valid_route, expected_status_int=200 ) + response_embed.mustcontain('') diff --git a/core/controllers/payload_validator.py b/core/controllers/payload_validator.py index 32fb49073311b..aefa12684f784 100644 --- a/core/controllers/payload_validator.py +++ b/core/controllers/payload_validator.py @@ -95,8 +95,7 @@ def validate_arguments_against_schema( # Skip validation because the argument is optional. continue - if arg_schema['default_value'] is not None: - handler_args[arg_key] = arg_schema['default_value'] + handler_args[arg_key] = arg_schema['default_value'] else: errors.append('Missing key in handler args: %s.' % arg_key) continue diff --git a/core/controllers/payload_validator_test.py b/core/controllers/payload_validator_test.py index 35edd85ce964d..06f1b18ce1ccb 100644 --- a/core/controllers/payload_validator_test.py +++ b/core/controllers/payload_validator_test.py @@ -131,6 +131,17 @@ def test_valid_args_do_not_raises_exception(self) -> None: {'exploration_id': {'schema': {'type': 'basestring'}}}, {'exploration_id': 'any_exp_id'}, ), + ( + {'apply_draft': 'true'}, + { + 'apply_draft': { + 'schema': { + 'type': 'bool', + } + } + }, + {'apply_draft': True}, + ), ( {'apply_draft': 'true'}, { diff --git a/core/controllers/practice_sessions_test.py b/core/controllers/practice_sessions_test.py index 509b3ccb3fde1..ea655f5b71668 100644 --- a/core/controllers/practice_sessions_test.py +++ b/core/controllers/practice_sessions_test.py @@ -149,6 +149,19 @@ def test_any_user_can_access_practice_sessions_data(self) -> None: 'Skill 2', ) + def test_get_ignores_unselected_existing_subtopic_ids(self) -> None: + json_response = self.get_json( + '%s/staging/%s?selected_subtopic_ids=[1]' + % (feconf.PRACTICE_SESSION_DATA_URL_PREFIX, 'public-topic-name') + ) + + self.assertEqual(json_response['topic_name'], 'public_topic_name') + self.assertEqual(len(json_response['skill_ids_to_descriptions_map']), 1) + self.assertEqual( + json_response['skill_ids_to_descriptions_map']['skill_id_1'], + 'Skill 1', + ) + def test_no_user_can_access_unpublished_topic_practice_session_data( self, ) -> None: diff --git a/core/controllers/questions_list_test.py b/core/controllers/questions_list_test.py index 3ea6949e88646..f0e7be18ed758 100644 --- a/core/controllers/questions_list_test.py +++ b/core/controllers/questions_list_test.py @@ -197,6 +197,30 @@ def test_get_fails_when_skill_does_not_exist(self) -> None: expected_status_int=404, ) + def test_get_questions_ignores_none_summaries(self) -> None: + self.login(self.CURRICULUM_ADMIN_EMAIL) + + def mock_get_displayable_question_skill_link_details( + unused_question_count: int, + unused_skill_ids: list[str], + unused_offset: int, + ) -> tuple[list[None], list[None]]: + return [None], [None] + + with self.swap( + question_services, + 'get_displayable_question_skill_link_details', + mock_get_displayable_question_skill_link_details, + ): + json_response = self.get_json( + '%s/%s?offset=0' + % (feconf.QUESTIONS_LIST_URL_PREFIX, self.skill_id) + ) + + self.assertEqual(json_response['question_summary_dicts'], []) + self.assertFalse(json_response['more']) + self.logout() + class QuestionCountDataHandlerTests(BaseQuestionsListControllerTests): diff --git a/core/controllers/reader.py b/core/controllers/reader.py index c03c8646757af..2f4f25201d63d 100644 --- a/core/controllers/reader.py +++ b/core/controllers/reader.py @@ -727,7 +727,14 @@ def post(self, exploration_id: str) -> None: ) ) - normalized_answer = old_interaction_instance.normalize_answer(answer) + try: + normalized_answer = old_interaction_instance.normalize_answer( + answer + ) + except utils.InvalidInputException as e: + raise self.InvalidInputException( + 'Schema validation for \'answer\' failed: %s' % e + ) from e event_services.AnswerSubmissionEventHandler.record( exploration_id, diff --git a/core/controllers/reader_test.py b/core/controllers/reader_test.py index 9386f8603051c..8ed480cf04d45 100644 --- a/core/controllers/reader_test.py +++ b/core/controllers/reader_test.py @@ -2680,6 +2680,40 @@ def test_submit_answer_for_exp_raises_error_with_no_answer_matching_type( 'Type of 1.1 is not present in options', ) + def test_submit_answer_for_exp_raises_error_with_invalid_algebraic_answer( + self, + ) -> None: + exp_id = '16' + exp_services.delete_demo(exp_id) + exp_services.load_demo(exp_id) + version = 1 + + self.signup(self.VIEWER_EMAIL, self.VIEWER_USERNAME) + self.login(self.VIEWER_EMAIL) + + response = self.post_json( + '/explorehandler/answer_submitted_event/%s' % exp_id, + { + 'old_state_name': 'Algebraic Expression Input', + 'answer': 'V = uab', + 'version': version, + 'client_time_spent_in_secs': 0, + 'session_id': '1PZTCw9JY8y-8lqBeuoJS2ILZMxa5m8N', + 'answer_group_index': 0, + 'rule_spec_index': 0, + 'classification_categorization': ( + exp_domain.EXPLICIT_CLASSIFICATION + ), + }, + expected_status_int=400, + ) + self.assertEqual( + response['error'], + 'Schema validation for \'answer\' failed: Validation failed: ' + 'is_valid_algebraic_expression ({}) for object V = uab', + ) + self.logout() + class StateHitEventHandlerTests(test_utils.GenericTestBase): diff --git a/core/controllers/skill_editor.py b/core/controllers/skill_editor.py index 5c9a725470114..ea0d1edf2e31c 100644 --- a/core/controllers/skill_editor.py +++ b/core/controllers/skill_editor.py @@ -218,7 +218,7 @@ def get(self, skill_id: str) -> None: self.values.update( { - 'skill': skill.to_dict(), + 'skill_dict': skill.to_dict(), 'assigned_skill_topic_data_dict': assigned_skill_topic_data_dict, 'grouped_skill_summaries': grouped_skill_summary_dicts, } @@ -270,7 +270,7 @@ def put(self, skill_id: str) -> None: skill_dict = skill_fetchers.get_skill_by_id(skill_id).to_dict() - self.values.update({'skill': skill_dict}) + self.values.update({'skill_dict': skill_dict}) self.render_json(self.values) diff --git a/core/controllers/skill_editor_test.py b/core/controllers/skill_editor_test.py index 1a74c0524de0e..12fa3fd285ce7 100644 --- a/core/controllers/skill_editor_test.py +++ b/core/controllers/skill_editor_test.py @@ -179,7 +179,7 @@ def test_editable_skill_handler_get_succeeds(self) -> None: self.login(self.NEW_USER_EMAIL) # Check that admins can access the editable skill data. json_response = self.get_json(self.url) - self.assertEqual(self.skill_id, json_response['skill']['id']) + self.assertEqual(self.skill_id, json_response['skill_dict']['id']) self.assertEqual( json_response['assigned_skill_topic_data_dict']['Name'], 'Subtopic1' ) @@ -211,7 +211,7 @@ def test_skill_which_is_assigned_to_topic_but_not_subtopic(self) -> None: url = '%s/%s' % (feconf.SKILL_EDITOR_DATA_URL_PREFIX, skill_id) json_response = self.get_json(url) - self.assertEqual(skill_id, json_response['skill']['id']) + self.assertEqual(skill_id, json_response['skill_dict']['id']) self.assertIsNone( json_response['assigned_skill_topic_data_dict']['TopicName1'] ) @@ -229,7 +229,7 @@ def test_skill_which_is_not_assigned_to_any_topic(self) -> None: url = '%s/%s' % (feconf.SKILL_EDITOR_DATA_URL_PREFIX, skill_id) json_response = self.get_json(url) - self.assertEqual(skill_id, json_response['skill']['id']) + self.assertEqual(skill_id, json_response['skill_dict']['id']) self.assertEqual(json_response['assigned_skill_topic_data_dict'], {}) self.assertEqual( 1, len(json_response['grouped_skill_summaries']['Name']) @@ -283,7 +283,7 @@ def test_skill_which_is_assigned_to_multiple_topics(self) -> None: url = '%s/%s' % (feconf.SKILL_EDITOR_DATA_URL_PREFIX, skill_id) json_response = self.get_json(url) - self.assertEqual(skill_id, json_response['skill']['id']) + self.assertEqual(skill_id, json_response['skill_dict']['id']) self.assertEqual( 2, len(json_response['assigned_skill_topic_data_dict']) ) @@ -299,6 +299,46 @@ def test_skill_which_is_assigned_to_multiple_topics(self) -> None: ) self.logout() + def test_skill_which_is_assigned_to_second_subtopic(self) -> None: + skill_id = skill_services.get_new_skill_id() + self.save_new_skill( + skill_id, self.admin_id, description='DescriptionSkill' + ) + + first_subtopic = topic_domain.Subtopic.create_default_subtopic( + 1, 'First Subtopic', 'first-subtopic' + ) + first_subtopic.skill_ids = [self.skill_id] + + second_subtopic = topic_domain.Subtopic.create_default_subtopic( + 2, 'Second Subtopic', 'second-subtopic' + ) + second_subtopic.skill_ids = [skill_id] + + topic_id = topic_fetchers.get_new_topic_id() + self.save_new_topic( + topic_id, + self.admin_id, + name='Mixed Topic', + abbreviated_name='mixed-topic', + url_fragment='mixed-topic', + description='Description', + canonical_story_ids=[], + additional_story_ids=[], + uncategorized_skill_ids=[], + subtopics=[first_subtopic, second_subtopic], + next_subtopic_id=3, + ) + + url = '%s/%s' % (feconf.SKILL_EDITOR_DATA_URL_PREFIX, skill_id) + json_response = self.get_json(url) + + self.assertEqual(skill_id, json_response['skill_dict']['id']) + self.assertEqual( + json_response['assigned_skill_topic_data_dict']['Mixed Topic'], + 'Second Subtopic', + ) + def test_editable_skill_handler_get_fails(self) -> None: self.login(self.NEW_USER_EMAIL) # Check GET returns 404 when cannot get skill by id. @@ -313,9 +353,30 @@ def test_editable_skill_handler_put_succeeds(self) -> None: json_response = self.put_json( self.url, self.put_payload, csrf_token=csrf_token ) - self.assertEqual(self.skill_id, json_response['skill']['id']) + self.assertEqual(self.skill_id, json_response['skill_dict']['id']) + self.assertEqual( + 'New Description', json_response['skill_dict']['description'] + ) + self.logout() + + def test_editable_skill_handler_put_fails_with_empty_commit_message( + self, + ) -> None: + self.login(self.CURRICULUM_ADMIN_EMAIL) + csrf_token = self.get_new_csrf_token() + + put_payload_copy = self.put_payload.copy() + put_payload_copy['commit_message'] = '' + + json_response = self.put_json( + self.url, + put_payload_copy, + csrf_token=csrf_token, + expected_status_int=500, + ) + self.assertEqual( - 'New Description', json_response['skill']['description'] + json_response['error'], 'Expected a commit message, received none.' ) self.logout() diff --git a/core/controllers/story_editor.py b/core/controllers/story_editor.py index 4940ef2839e0f..cffc2cd16e56c 100644 --- a/core/controllers/story_editor.py +++ b/core/controllers/story_editor.py @@ -141,7 +141,7 @@ def get(self, story_id: str) -> None: self.values.update( { - 'story': story.to_dict(), + 'story_dict': story.to_dict(), 'topic_name': topic.name, 'story_is_published': story_is_published, 'skill_summaries': skill_summary_dicts, @@ -184,7 +184,7 @@ def put(self, story_id: str) -> None: story_dict = story_fetchers.get_story_by_id(story_id).to_dict() - self.values.update({'story': story_dict}) + self.values.update({'story_dict': story_dict}) self.render_json(self.values) diff --git a/core/controllers/story_editor_test.py b/core/controllers/story_editor_test.py index 1e05bc1a9d85c..f4ce76c798cac 100644 --- a/core/controllers/story_editor_test.py +++ b/core/controllers/story_editor_test.py @@ -185,6 +185,36 @@ def test_invalid_input_exception_when_no_exp_ids_passed(self) -> None: class StoryEditorTests(BaseStoryEditorControllerTests): + def test_get_story_data_when_story_reference_is_not_first(self) -> None: + self.login(self.CURRICULUM_ADMIN_EMAIL) + + topic_id = topic_fetchers.get_new_topic_id() + first_story_id = story_services.get_new_story_id() + second_story_id = story_services.get_new_story_id() + + self.save_new_story(first_story_id, self.admin_id, topic_id) + self.save_new_story(second_story_id, self.admin_id, topic_id) + self.save_new_topic( + topic_id, + self.admin_id, + name='Another Name', + abbreviated_name='another-name', + url_fragment='another-name', + description='Another description', + canonical_story_ids=[first_story_id, second_story_id], + additional_story_ids=[], + uncategorized_skill_ids=[], + subtopics=[], + next_subtopic_id=1, + ) + + json_response = self.get_json( + '%s/%s' % (feconf.STORY_EDITOR_DATA_URL_PREFIX, second_story_id) + ) + self.assertEqual(json_response['story_dict']['id'], second_story_id) + self.assertFalse(json_response['story_is_published']) + self.logout() + def test_can_not_get_access_story_handler_with_invalid_story_id( self, ) -> None: @@ -491,7 +521,7 @@ def test_editable_story_handler_get(self) -> None: json_response = self.get_json( '%s/%s' % (feconf.STORY_EDITOR_DATA_URL_PREFIX, self.story_id) ) - self.assertEqual(self.story_id, json_response['story']['id']) + self.assertEqual(self.story_id, json_response['story_dict']['id']) self.assertEqual('Name', json_response['topic_name']) self.assertEqual(len(json_response['skill_summaries']), 0) self.logout() @@ -518,9 +548,9 @@ def test_editable_story_handler_put(self) -> None: change_cmd, csrf_token=csrf_token, ) - self.assertEqual(self.story_id, json_response['story']['id']) + self.assertEqual(self.story_id, json_response['story_dict']['id']) self.assertEqual( - 'New Description', json_response['story']['description'] + 'New Description', json_response['story_dict']['description'] ) self.logout() diff --git a/core/controllers/subtopic_viewer.py b/core/controllers/subtopic_viewer.py index 54bcc04f3a14d..e93f784c5ab0c 100644 --- a/core/controllers/subtopic_viewer.py +++ b/core/controllers/subtopic_viewer.py @@ -72,17 +72,15 @@ def get(self, topic_name: str, subtopic_id: int) -> None: topic = topic_fetchers.get_topic_by_name(topic_name) next_subtopic_dict = None prev_subtopic_dict = None - for index, subtopic in enumerate(topic.subtopics): - if subtopic.id == subtopic_id: - subtopic_title = subtopic.title - if index != len(topic.subtopics) - 1: - next_subtopic_dict = topic.subtopics[index + 1].to_dict() - # Checking greater than 1 here, since otherwise the only - # subtopic page of the topic would always link to itself at the - # bottom of the subtopic page which isn't expected. - elif len(topic.subtopics) > 1: - prev_subtopic_dict = topic.subtopics[index - 1].to_dict() - break + index = topic.get_subtopic_index(subtopic_id) + subtopic_title = topic.subtopics[index].title + if index != len(topic.subtopics) - 1: + next_subtopic_dict = topic.subtopics[index + 1].to_dict() + # Checking greater than 1 here, since otherwise the only + # subtopic page of the topic would always link to itself at the + # bottom of the subtopic page which isn't expected. + elif len(topic.subtopics) > 1: + prev_subtopic_dict = topic.subtopics[index - 1].to_dict() study_guide_sections_dicts_list = [] subtopic_page_contents_dict: ( subtopic_page_domain.SubtopicPageContentsDict diff --git a/core/controllers/subtopic_viewer_test.py b/core/controllers/subtopic_viewer_test.py index 1d3a220dbd979..3c03dca8e808a 100644 --- a/core/controllers/subtopic_viewer_test.py +++ b/core/controllers/subtopic_viewer_test.py @@ -404,6 +404,62 @@ def setUp(self) -> None: class SubtopicPageDataHandlerTests(BaseSubtopicViewerControllerTests): + def test_get_for_only_subtopic_in_topic(self) -> None: + topic_id = 'single_subtopic_topic_id' + subtopic_id = 1 + subtopic_page = ( + subtopic_page_domain.SubtopicPage.create_default_subtopic_page( + subtopic_id, topic_id + ) + ) + subtopic_page_services.save_subtopic_page( + self.admin_id, + subtopic_page, + 'Added subtopic', + [ + topic_domain.TopicChange( + { + 'cmd': topic_domain.CMD_ADD_SUBTOPIC, + 'subtopic_id': subtopic_id, + 'title': 'Only Subtopic', + 'url_fragment': 'only-subtopic-fragment', + } + ) + ], + ) + + only_subtopic = topic_domain.Subtopic.create_default_subtopic( + subtopic_id, 'Only Subtopic', 'only-subtopic-fragment' + ) + only_subtopic.skill_ids = ['skill_id_only'] + + self.save_new_topic( + topic_id, + self.admin_id, + name='Single Subtopic Topic', + abbreviated_name='single-subtopic-topic', + url_fragment='single-subtopic', + description='Description', + canonical_story_ids=[], + additional_story_ids=[], + uncategorized_skill_ids=[], + subtopics=[only_subtopic], + next_subtopic_id=2, + ) + topic_services.publish_topic(topic_id, self.admin_id) + + json_response = self.get_json( + '%s/staging/%s/%s' + % ( + feconf.SUBTOPIC_DATA_HANDLER, + 'single-subtopic', + 'only-subtopic-fragment', + ) + ) + + self.assertEqual(json_response['next_subtopic_dict'], None) + self.assertEqual(json_response['prev_subtopic_dict'], None) + def test_get_for_first_subtopic_in_topic(self) -> None: json_response = self.get_json( '%s/staging/%s/%s' diff --git a/core/controllers/suggestion_test.py b/core/controllers/suggestion_test.py index 223e066f0d280..a708a0c678b85 100644 --- a/core/controllers/suggestion_test.py +++ b/core/controllers/suggestion_test.py @@ -4105,6 +4105,7 @@ def test_exploration_handler_returns_data_with_valid_exploration_id( 'exp1': { 'chapter_title': 'Node1', 'content_count': 1, + 'reviewer_only_content_count': 0, 'id': 'exp1', 'is_pinned': False, 'story_title': 'A story', diff --git a/core/controllers/tasks.py b/core/controllers/tasks.py index b9dd636bf6fb2..23dd56ebc2a3e 100644 --- a/core/controllers/tasks.py +++ b/core/controllers/tasks.py @@ -17,12 +17,14 @@ from __future__ import annotations import json +import logging import traceback from core import feconf from core.controllers import acl_decorators, base from core.domain import ( email_manager, + email_services, exp_fetchers, exp_services, feedback_services, @@ -233,6 +235,87 @@ def post(self) -> None: self.render_json({}) +class RetryEmailHandler(base.BaseHandler[Dict[str, str], Dict[str, str]]): + """Handler task for retrying unsuccessfully sent emails.""" + + URL_PATH_ARGS_SCHEMAS: Dict[str, str] = {} + HANDLER_ARGS_SCHEMAS = { + 'POST': { + 'sender_email': { + 'schema': {'type': 'basestring'}, + 'default_value': None, + }, + 'recipient_id': { + 'schema': {'type': 'basestring'}, + 'default_value': None, + }, + 'subject': { + 'schema': {'type': 'basestring'}, + 'default_value': None, + }, + 'html_body': { + 'schema': {'type': 'basestring'}, + 'default_value': None, + }, + 'text_body': { + 'schema': {'type': 'basestring'}, + 'default_value': None, + }, + } + } + + @acl_decorators.can_perform_tasks_in_taskqueue + def post(self) -> None: + """Attempts to resend an email. + + If it fails, raises an error to trigger an automatic retry via Cloud Tasks. + """ + payload = json.loads(self.request.body) + + sender_email = payload.get('sender_email') + recipient_id = payload.get('recipient_id') + subject = payload.get('subject') + html_body = payload.get('html_body') + text_body = payload.get('text_body') + + num_of_attempts_of_retry_made = int( + self.request.headers.get('X-AppEngine-TaskExecutionCount', 0) + ) + + # Cloud tasks automatically increment this header on each retry. + # It starts at 0 for the first attempt. + # + # Note: We use X-AppEngine-TaskExecutionCount instead of + # X-AppEngine-TaskRetryCount because TaskRetryCount includes infrastructure + # failures (e.g., lack of available instances) where the task never + # actually reached this handler. TaskExecutionCount strictly counts how + # many times this handler actually executed and failed, which is the + # exact metric we want to limit against. + # Docs: . + + # TODO(#25307): Improve this retry mechanism by differentiating between + # 4xx client errors (which should be dropped immediately) and 5xx server + # errors (which should be retried). Until then, we enforce a hard limit + # of 3 retries for all errors to prevent infinite queues. + + if num_of_attempts_of_retry_made >= 3: + logging.error('Failed sending email after three retries') + self.render_json({}) + return + + try: + email_services.send_mail( + sender_email, recipient_id, subject, text_body, html_body + ) + except Exception as e: + logging.error( + 'Email retry failed for recipient %s: %s', recipient_id, e + ) + raise Exception('Failed to resend email: %s' % e) from e + + self.render_json({}) + + class DeferredTasksHandler(base.BaseHandler[Dict[str, str], Dict[str, str]]): """This task handler handles special tasks that make single asynchronous function calls. For more complex tasks that require a large number of @@ -278,10 +361,18 @@ class DeferredTasksHandler(base.BaseHandler[Dict[str, str], Dict[str, str]]): voiceover_services.regenerate_voiceovers_on_exploration_added_to_topic ), fn_ids_to_names[ - 'FUNCTION_ID_REGENERATE_VOICEOVERS_OF_EXPLORATION_FOR_GIVEN_LANGUAGE_ACCENT' + 'FUNCTION_ID_REGENERATE_VOICEOVERS_BY_LANGUAGE_ACCENT' ]: ( voiceover_services.regenerate_voiceovers_of_exploration_for_given_language_accent ), + fn_ids_to_names[ + 'FUNCTION_ID_REGENERATE_VOICEOVERS_AFTER_ACCEPTING_SUGGESTION' + ]: ( + voiceover_services.regenerate_voiceovers_after_accepting_suggestion + ), + fn_ids_to_names[ + 'FUNCTION_ID_REGENERATE_VOICEOVERS_FOR_BATCH_CONTENTS' + ]: (voiceover_services.regenerate_voiceovers_for_batch_contents), } @acl_decorators.can_perform_tasks_in_taskqueue @@ -331,9 +422,20 @@ def post(self) -> None: payload['fn_identifier'] ] - # If the deferred task is a voiceover regeneration task, append the + # Some deferred tasks (e.g., voiceover regeneration) need to be + # split into multiple smaller tasks to distribute the load across + # separate deferred requests. If the payload contains a parent + # Cloud Task run ID, the ID must be passed as an argument to the + # downstream method. + parent_cloud_task_run_id = payload.get( + 'parent_cloud_task_run_id', None + ) + if parent_cloud_task_run_id is not None: + payload['args'].append(parent_cloud_task_run_id) + + # If the deferred task is a voiceover regeneration parent task, append the # cloud task model ID to the arguments list. - if voiceover_cloud_task_services.is_voiceover_regeneration_task_function( + if voiceover_cloud_task_services.is_voiceover_regeneration_defer_function( payload['fn_identifier'] ): payload['args'].append(cloud_task_model_id) diff --git a/core/controllers/tasks_test.py b/core/controllers/tasks_test.py index 4d3ae0a57608f..0fa23da87703c 100644 --- a/core/controllers/tasks_test.py +++ b/core/controllers/tasks_test.py @@ -20,6 +20,8 @@ from core import feconf from core.domain import ( + cloud_task_domain, + email_services, exp_fetchers, exp_services, feedback_services, @@ -29,8 +31,8 @@ stats_services, taskqueue_services, user_services, + voiceover_cloud_task_services, voiceover_regeneration_services, - voiceover_services, ) from core.platform import models from core.tests import test_utils @@ -574,7 +576,7 @@ def test_should_handle_voiceover_deferred_tasks_successfully(self) -> None: payload = { 'fn_identifier': function_id, 'cloud_task_model_id': new_model_id, - 'args': [exploration_id, '2025-12-13 21:08:46', self.owner_id], + 'args': [exploration_id], 'kwargs': {}, } @@ -601,16 +603,12 @@ def test_should_handle_failure_case_for_voiceover_deferred_tasks( self, ) -> None: exploration_id = 'exploration_id' + exploration_version = 1 + language_accent_code = 'en-US' + content_ids_to_contents_map = {'content_0': 'Hello world'} self.save_new_valid_exploration(exploration_id, self.owner_id) rights_manager.publish_exploration(self.owner, exploration_id) - language_codes_mapping: Dict[str, Dict[str, bool]] = { - 'en': {'en-US': True, 'en-NG': True}, - } - voiceover_services.save_language_accent_support( - language_codes_mapping=language_codes_mapping - ) - url = feconf.TASK_URL_DEFERRED csrf_token = self.get_new_csrf_token() headers = { @@ -618,30 +616,68 @@ def test_should_handle_failure_case_for_voiceover_deferred_tasks( 'X-Appengine-TaskName': 'None', 'X-AppEngine-Fake-Is-Admin': '1', } - new_model_id = 'cloud_task_model_id' + parent_cloud_task_run_id = 'parent_cloud_task_model_id' + child_cloud_task_run_id = 'cloud_task_model_id' project_id = 'dev-project-id' location_id = 'us-central' - task_id = uuid.uuid4().hex + task_id_1 = uuid.uuid4().hex + task_id_2 = uuid.uuid4().hex queue_name = 'test_queue_name' - task_name = 'projects/%s/locations/%s/queues/%s/tasks/%s' % ( + parent_task_name = 'projects/%s/locations/%s/queues/%s/tasks/%s' % ( project_id, location_id, queue_name, - task_id, + task_id_1, ) - function_id = 'regenerate_voiceovers_on_exploration_added_to_topic' + child_task_name = 'projects/%s/locations/%s/queues/%s/tasks/%s' % ( + project_id, + location_id, + queue_name, + task_id_2, + ) + parent_function_id = 'regenerate_voiceovers_on_exploration_update' + child_function_id = 'regenerate_voiceovers_for_batch_contents' payload = { - 'fn_identifier': function_id, - 'cloud_task_model_id': new_model_id, - 'args': [exploration_id, '2025-12-13 21:08:46', self.owner_id], + 'fn_identifier': child_function_id, + 'cloud_task_model_id': child_cloud_task_run_id, + 'parent_cloud_task_run_id': parent_cloud_task_run_id, + 'args': [exploration_id], 'kwargs': {}, } - cloud_task_run_model = taskqueue_services.create_new_cloud_task_model( - new_model_id, task_name, function_id + taskqueue_services.create_new_cloud_task_model( + parent_cloud_task_run_id, parent_task_name, parent_function_id + ) + child_cloud_task_run_model = ( + taskqueue_services.create_new_cloud_task_model( + child_cloud_task_run_id, child_task_name, child_function_id + ) + ) + voiceover_regeneration_task_batch = ( + cloud_task_domain.VoiceoverRegenerationTaskBatch( + parent_cloud_task_run_id, + child_cloud_task_run_id, + exploration_id, + exploration_version, + language_accent_code, + content_ids_to_contents_map, + ) + ) + voiceover_regeneration_job = cloud_task_domain.VoiceoverRegenerationJob( + exploration_id, + parent_cloud_task_run_id, + {'en-US': {'content_0': 'GENERATING'}}, + ) + + voiceover_cloud_task_services.create_voiceover_regeneration_task_batch_model( + voiceover_regeneration_task_batch + ) + voiceover_cloud_task_services.save_voiceover_regeneration_job( + voiceover_regeneration_job ) - self.assertEqual(cloud_task_run_model.latest_job_state, 'PENDING') + + self.assertEqual(child_cloud_task_run_model.latest_job_state, 'PENDING') def mock_regenerate_voiceovers_of_exploration( _exploration_id: str, @@ -649,10 +685,9 @@ def mock_regenerate_voiceovers_of_exploration( _content_id_to_content_html: Dict[str, str], _language_accent_code: str, ) -> List[Tuple[str, str]]: - errors_while_voiceover_regeneration = [ - ('content5', 'Error 1 occurred'), + return [ + ('content_0', 'Error 1 occurred'), ] - return errors_while_voiceover_regeneration with self.swap( voiceover_regeneration_services, @@ -669,13 +704,123 @@ def mock_regenerate_voiceovers_of_exploration( ) cloud_task_run_model_obj = ( - taskqueue_services.get_cloud_task_run_by_model_id(new_model_id) + taskqueue_services.get_cloud_task_run_by_model_id( + child_cloud_task_run_id + ) ) assert cloud_task_run_model_obj is not None self.assertEqual( cloud_task_run_model_obj.latest_job_state, 'PERMANENTLY_FAILED' ) + def test_should_request_batch_regeneration_successfully(self) -> None: + exploration_id = 'exploration_id' + exploration_version = 1 + language_accent_code = 'en-US' + content_ids_to_contents_map = {'content_0': 'Hello world'} + self.save_new_valid_exploration(exploration_id, self.owner_id) + rights_manager.publish_exploration(self.owner, exploration_id) + + url = feconf.TASK_URL_DEFERRED + csrf_token = self.get_new_csrf_token() + headers = { + 'X-Appengine-QueueName': 'queue', + 'X-Appengine-TaskName': 'None', + 'X-AppEngine-Fake-Is-Admin': '1', + } + parent_cloud_task_run_id = 'parent_cloud_task_model_id' + child_cloud_task_run_id = 'cloud_task_model_id' + project_id = 'dev-project-id' + location_id = 'us-central' + task_id_1 = uuid.uuid4().hex + task_id_2 = uuid.uuid4().hex + queue_name = 'test_queue_name' + parent_task_name = 'projects/%s/locations/%s/queues/%s/tasks/%s' % ( + project_id, + location_id, + queue_name, + task_id_1, + ) + child_task_name = 'projects/%s/locations/%s/queues/%s/tasks/%s' % ( + project_id, + location_id, + queue_name, + task_id_2, + ) + parent_function_id = 'regenerate_voiceovers_on_exploration_update' + child_function_id = 'regenerate_voiceovers_for_batch_contents' + + payload = { + 'fn_identifier': child_function_id, + 'cloud_task_model_id': child_cloud_task_run_id, + 'parent_cloud_task_run_id': parent_cloud_task_run_id, + 'args': [exploration_id], + 'kwargs': {}, + } + + taskqueue_services.create_new_cloud_task_model( + parent_cloud_task_run_id, parent_task_name, parent_function_id + ) + child_cloud_task_run_model = ( + taskqueue_services.create_new_cloud_task_model( + child_cloud_task_run_id, child_task_name, child_function_id + ) + ) + voiceover_regeneration_task_batch = ( + cloud_task_domain.VoiceoverRegenerationTaskBatch( + parent_cloud_task_run_id, + child_cloud_task_run_id, + exploration_id, + exploration_version, + language_accent_code, + content_ids_to_contents_map, + ) + ) + voiceover_regeneration_job = cloud_task_domain.VoiceoverRegenerationJob( + exploration_id, + parent_cloud_task_run_id, + {'en-US': {'content_0': 'GENERATING'}}, + ) + + voiceover_cloud_task_services.create_voiceover_regeneration_task_batch_model( + voiceover_regeneration_task_batch + ) + voiceover_cloud_task_services.save_voiceover_regeneration_job( + voiceover_regeneration_job + ) + + self.assertEqual(child_cloud_task_run_model.latest_job_state, 'PENDING') + + def mock_regenerate_voiceovers_of_exploration( + _exploration_id: str, + _exploration_version: int, + _content_id_to_content_html: Dict[str, str], + _language_accent_code: str, + ) -> List[Tuple[str, str]]: + return [] + + with self.swap( + voiceover_regeneration_services, + 'regenerate_voiceovers_of_exploration', + mock_regenerate_voiceovers_of_exploration, + ): + self.post_task( + url, + payload, + expect_errors=False, + expected_status_int=200, + csrf_token=csrf_token, + headers=headers, + ) + + cloud_task_run_model_obj = ( + taskqueue_services.get_cloud_task_run_by_model_id( + child_cloud_task_run_id + ) + ) + assert cloud_task_run_model_obj is not None + self.assertEqual(cloud_task_run_model_obj.latest_job_state, 'SUCCEEDED') + def test_should_raise_error_for_missing_cloud_task_model_id(self) -> None: url = feconf.TASK_URL_DEFERRED csrf_token = self.get_new_csrf_token() @@ -899,3 +1044,88 @@ def test_deferred_tasks_handler_handles_tasks_correctly(self) -> None: ].total_hit_count_v2, 1, ) + + +class RetryEmailHandlerTests(test_utils.EmailTestBase): + """Tests for the RetryEmailHandler.""" + + def setUp(self) -> None: + super().setUp() + self.payload = { + 'sender_email': 'sender@example.com', + 'recipient_id': 'recipient@example.com', + 'subject': 'Test Subject', + 'html_body': 'Test Body', + 'text_body': 'Test Body', + } + self.url = feconf.TASK_URL_RETRY_FAILED_EMAIL + self.csrf_token = self.get_new_csrf_token() + + self.headers = { + 'X-Appengine-QueueName': 'emails', + 'X-Appengine-TaskName': 'None', + 'X-AppEngine-Fake-Is-Admin': '1', + } + + def test_successful_retry_returns_200(self) -> None: + def mock_send_mail(*_args: str, **_kwargs: str) -> None: + pass + + send_mail_swap = self.swap(email_services, 'send_mail', mock_send_mail) + + with send_mail_swap: + self.post_task( + self.url, + self.payload, + self.headers, + csrf_token=self.csrf_token, + expect_errors=False, + expected_status_int=200, + ) + + def test_failed_retry_raises_exception_to_trigger_cloud_task_retry( + self, + ) -> None: + def mock_send_mail_that_fails(*_args: str, **_kwargs: str) -> None: + raise Exception('Mock email failure') + + send_mail_swap = self.swap( + email_services, 'send_mail', mock_send_mail_that_fails + ) + + with send_mail_swap: + response = self.post_task( + self.url, + self.payload, + self.headers, + csrf_token=self.csrf_token, + expect_errors=True, + expected_status_int=500, + ) + + self.assertEqual(response.status_int, 500) + self.assertIn( + b'Failed to resend email: Mock email failure', response.body + ) + + def test_drops_task_after_max_retries_exceeded(self) -> None: + def mock_send_mail_that_fails(*_args: str, **_kwargs: str) -> None: + raise Exception('Mock email failure') + + send_mail_swap = self.swap( + email_services, 'send_mail', mock_send_mail_that_fails + ) + + self.headers['X-AppEngine-TaskExecutionCount'] = '3' + + with send_mail_swap: + response = self.post_task( + self.url, + self.payload, + self.headers, + csrf_token=self.csrf_token, + expect_errors=False, + expected_status_int=200, + ) + + self.assertEqual(response.status_int, 200) diff --git a/core/controllers/topic_editor.py b/core/controllers/topic_editor.py index 0d93bfccab973..1bd945149071c 100644 --- a/core/controllers/topic_editor.py +++ b/core/controllers/topic_editor.py @@ -396,7 +396,7 @@ def get(self, topic_id: str, subtopic_id: int) -> None: 'The subtopic page with the given id doesn\'t exist.' ) - self.values.update({'subtopic_page': subtopic_page.to_dict()}) + self.values.update({'subtopic_page_dict': subtopic_page.to_dict()}) self.render_json(self.values) @@ -450,7 +450,7 @@ def get(self, topic_id: str, subtopic_id: int) -> None: 'The study guide with the given id doesn\'t exist.' ) - self.values.update({'study_guide': study_guide.to_dict()}) + self.values.update({'study_guide_dict': study_guide.to_dict()}) self.render_json(self.values) @@ -981,7 +981,7 @@ class TopicUrlFragmentHandler(base.BaseHandler[Dict[str, str], Dict[str, str]]): } HANDLER_ARGS_SCHEMAS: Dict[str, Dict[str, str]] = {'GET': {}} - @acl_decorators.can_create_topic + @acl_decorators.can_access_topics_and_skills_dashboard def get(self, topic_url_fragment: str) -> None: """Handler that receives a topic url fragment and checks whether a topic with the same url fragment exists. diff --git a/core/controllers/topic_editor_test.py b/core/controllers/topic_editor_test.py index 7a9ad2f40a82d..b357e2f3c76db 100644 --- a/core/controllers/topic_editor_test.py +++ b/core/controllers/topic_editor_test.py @@ -560,7 +560,7 @@ def test_editable_subtopic_page_get(self) -> None: 'translations_mapping': {'content': {}} }, }, - json_response['subtopic_page']['page_contents'], + json_response['subtopic_page_dict']['page_contents'], ) self.logout() @@ -578,7 +578,7 @@ def test_editable_subtopic_page_get(self) -> None: 'translations_mapping': {'content': {}} }, }, - json_response['subtopic_page']['page_contents'], + json_response['subtopic_page_dict']['page_contents'], ) self.logout() @@ -596,7 +596,7 @@ def test_editable_subtopic_page_get(self) -> None: 'translations_mapping': {'content': {}} }, }, - json_response['subtopic_page']['page_contents'], + json_response['subtopic_page_dict']['page_contents'], ) self.logout() @@ -722,7 +722,7 @@ def test_editable_study_guide_get(self) -> None: }, } ], - json_response['study_guide']['sections'], + json_response['study_guide_dict']['sections'], ) self.logout() @@ -745,7 +745,7 @@ def test_editable_study_guide_get(self) -> None: }, } ], - json_response['study_guide']['sections'], + json_response['study_guide_dict']['sections'], ) self.logout() @@ -768,7 +768,7 @@ def test_editable_study_guide_get(self) -> None: }, } ], - json_response['study_guide']['sections'], + json_response['study_guide_dict']['sections'], ) self.logout() @@ -1069,7 +1069,7 @@ def test_editable_topic_handler_put(self) -> None: 'translations_mapping': {'content': {}} }, }, - json_response['subtopic_page']['page_contents'], + json_response['subtopic_page_dict']['page_contents'], ) json_response = self.get_json( '%s/%s/%s' @@ -1097,7 +1097,7 @@ def test_editable_topic_handler_put(self) -> None: 'translations_mapping': {'content': {}} }, }, - json_response['subtopic_page']['page_contents'], + json_response['subtopic_page_dict']['page_contents'], ) # Test if the corresponding study guides were created. @@ -1118,7 +1118,7 @@ def test_editable_topic_handler_put(self) -> None: }, } ], - json_response['study_guide']['sections'], + json_response['study_guide_dict']['sections'], ) self.logout() @@ -1613,6 +1613,27 @@ def test_cannot_unpublish_or_delete_topic_which_is_assigned_to_a_classroom( class TopicUrlFragmentHandlerTest(BaseTopicEditorControllerTests): """Tests for TopicUrlFragmentHandler.""" + def test_normal_user_cannot_access_topic_url_fragment_handler(self) -> None: + self.login(self.NEW_USER_EMAIL) + + self.get_json( + '%s/%s' % (feconf.TOPIC_URL_FRAGMENT_HANDLER, 'test'), + expected_status_int=401, + ) + + self.logout() + + def test_topic_manager_can_access_topic_url_fragment_handler(self) -> None: + self.login(self.TOPIC_MANAGER_EMAIL) + + json_response = self.get_json( + '%s/%s' % (feconf.TOPIC_URL_FRAGMENT_HANDLER, 'unique-fragment') + ) + + self.assertEqual(json_response['topic_url_fragment_exists'], False) + + self.logout() + def test_topic_url_fragment_handler_when_unique(self) -> None: self.login(self.CURRICULUM_ADMIN_EMAIL) diff --git a/core/controllers/topics_and_skills_dashboard.py b/core/controllers/topics_and_skills_dashboard.py index 23e25ef59ddaa..3ec3ed09931d8 100644 --- a/core/controllers/topics_and_skills_dashboard.py +++ b/core/controllers/topics_and_skills_dashboard.py @@ -71,17 +71,12 @@ def get(self) -> None: merged_skill_ids = skill_services.get_merged_skill_ids() topic_rights_dict = topic_fetchers.get_all_topic_rights() for topic_summary in topic_summary_dicts: - if topic_rights_dict[topic_summary['id']]: - topic_rights = topic_rights_dict[topic_summary['id']] - if topic_rights: - topic_summary['is_published'] = ( - topic_rights.topic_is_published - ) - topic_summary['can_edit_topic'] = ( - topic_services.check_can_edit_topic( - self.user, topic_rights - ) - ) + topic_rights = topic_rights_dict[topic_summary['id']] + if topic_rights: + topic_summary['is_published'] = topic_rights.topic_is_published + topic_summary['can_edit_topic'] = ( + topic_services.check_can_edit_topic(self.user, topic_rights) + ) classrooms = classroom_config_services.get_all_classrooms() all_classroom_names = [classroom.name for classroom in classrooms] @@ -605,14 +600,13 @@ def post(self) -> None: files = self.normalized_payload['files'] new_skill_id = skill_services.get_new_skill_id() - if linked_topic_ids is not None: - topics = topic_fetchers.get_topics_by_ids(linked_topic_ids) - for topic in topics: - if topic is None: - raise self.InvalidInputException - topic_services.add_uncategorized_skill( - self.user_id, topic.id, new_skill_id - ) + topics = topic_fetchers.get_topics_by_ids(linked_topic_ids) + for topic in topics: + if topic is None: + raise self.InvalidInputException + topic_services.add_uncategorized_skill( + self.user_id, topic.id, new_skill_id + ) if skill_services.does_skill_with_description_exist(description): raise self.InvalidInputException( diff --git a/core/controllers/topics_and_skills_dashboard_test.py b/core/controllers/topics_and_skills_dashboard_test.py index c1ca3d3175bcd..6865984f7402a 100644 --- a/core/controllers/topics_and_skills_dashboard_test.py +++ b/core/controllers/topics_and_skills_dashboard_test.py @@ -162,6 +162,29 @@ def test_get(self) -> None: self.assertEqual(json_response['can_create_skill'], False) self.logout() + def test_get_with_topic_rights_set_to_none(self) -> None: + self.login(self.CURRICULUM_ADMIN_EMAIL) + + def mock_get_all_topic_rights() -> Dict[str, None]: + return {self.topic_id: None} + + with self.swap( + topic_fetchers, + 'get_all_topic_rights', + mock_get_all_topic_rights, + ): + json_response = self.get_json( + feconf.TOPICS_AND_SKILLS_DASHBOARD_DATA_URL + ) + + self.assertNotIn( + 'is_published', json_response['topic_summary_dicts'][0] + ) + self.assertNotIn( + 'can_edit_topic', json_response['topic_summary_dicts'][0] + ) + self.logout() + class CategorizedAndUntriagedSkillsDataHandlerTests( BaseTopicsAndSkillsDashboardTests diff --git a/core/controllers/voiceover.py b/core/controllers/voiceover.py index 9cf799a78b658..ba8ea6a953acf 100644 --- a/core/controllers/voiceover.py +++ b/core/controllers/voiceover.py @@ -22,6 +22,7 @@ from core.constants import constants from core.controllers import acl_decorators, base from core.domain import ( + beam_job_services, exp_fetchers, feature_flag_services, opportunity_services, @@ -31,6 +32,7 @@ voiceover_regeneration_services, voiceover_services, ) +from core.jobs.batch_jobs import synthesize_voiceover_by_language_accent_jobs from typing import Dict, List, Optional, TypedDict @@ -115,7 +117,28 @@ def put(self) -> None: 'language_codes_mapping' ] + new_accent_code = voiceover_services.get_new_auto_voiceover_accent( + language_codes_mapping + ) voiceover_services.save_language_accent_support(language_codes_mapping) + + if ( + new_accent_code + and voiceover_services.is_accent_code_valid_for_autogeneration( + new_accent_code + ) + and feature_flag_services.is_feature_flag_enabled( + feature_flag_list.FeatureNames.ENABLE_BACKGROUND_VOICEOVER_SYNTHESIS.value, + None, + ) + ): + beam_job_services.run_beam_job( + job_class=( + synthesize_voiceover_by_language_accent_jobs.VoiceoverSynthesisByAccentJob + ), + parameterized_args={'language_accent_code': new_accent_code}, + ) + self.render_json(self.values) @@ -211,7 +234,7 @@ def get(self) -> None: self.values.update( { 'automatic_voiceover_regeneration_records': [ - cloud_task_run.to_dict() + cloud_task_run.to_dict_with_timezone_info() for cloud_task_run in cloud_task_run_objects[ :maximum_allowed_records ] @@ -286,7 +309,6 @@ class RegenerateVoiceoverOnExpUpdateHandler( URL_PATH_ARGS_SCHEMAS = { 'exploration_id': {'schema': {'type': 'basestring'}}, 'exploration_version': {'schema': {'type': 'int'}}, - 'exploration_title': {'schema': {'type': 'basestring'}}, } HANDLER_ARGS_SCHEMAS: Dict[str, Dict[str, str]] = {'POST': {}} @@ -295,7 +317,6 @@ def post( self, exploration_id: str, exploration_version: int, - exploration_title: str, ) -> None: """Regenerates the voiceover for the given exploration data when an exploration is updated. @@ -314,10 +335,7 @@ def post( ], taskqueue_services.QUEUE_NAME_VOICEOVER_REGENERATION, exploration_id, - exploration_title, exploration_version, - feconf.SYSTEM_COMMITTER_ID, - datetime.datetime.utcnow().isoformat(), ) self.render_json(self.values) @@ -475,12 +493,10 @@ def post(self, exploration_id: str, language_accent_code: str) -> None: ): taskqueue_services.defer( feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[ - 'FUNCTION_ID_REGENERATE_VOICEOVERS_OF_EXPLORATION_FOR_GIVEN_LANGUAGE_ACCENT' + 'FUNCTION_ID_REGENERATE_VOICEOVERS_BY_LANGUAGE_ACCENT' ], taskqueue_services.QUEUE_NAME_VOICEOVER_REGENERATION, exploration_id, language_accent_code, - self.user_id, - datetime.datetime.utcnow().isoformat(), ) self.render_json(self.values) diff --git a/core/controllers/voiceover_test.py b/core/controllers/voiceover_test.py index c07e49f4cdd7e..f197b17be0ad2 100644 --- a/core/controllers/voiceover_test.py +++ b/core/controllers/voiceover_test.py @@ -80,6 +80,9 @@ class VoiceoverLanguageCodesMappingHandlerTests(test_utils.GenericTestBase): update correctly. """ + @test_utils.enable_feature_flags( + [feature_flag_list.FeatureNames.ENABLE_BACKGROUND_VOICEOVER_SYNTHESIS] + ) def test_put_language_accent_codes_mapping_correctly(self) -> None: self.signup(self.VOICEOVER_ADMIN_EMAIL, self.VOICEOVER_ADMIN_USERNAME) self.set_voiceover_admin([self.VOICEOVER_ADMIN_USERNAME]) @@ -272,7 +275,11 @@ def setUp(self) -> None: self.owner = user_services.get_user_actions_info(self.owner_id) self.exploration = self.save_new_valid_exploration( - 'exp_id', self.owner_id, title='Exploration 1' + 'exp_id', + self.owner_id, + title='Exploration 1', + category=constants.constants.ALL_CATEGORIES[0], + end_state_name='End State', ) rights_manager.publish_exploration(self.owner, self.exploration.id) rights_manager.assign_role_for_exploration( @@ -281,6 +288,35 @@ def setUp(self) -> None: self.voice_artist_id, rights_domain.ROLE_VOICE_ARTIST, ) + exp_services.update_exploration( + self.owner_id, + self.exploration.id, + [ + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'property_name': exp_domain.STATE_PROPERTY_CONTENT, + 'state_name': 'Introduction', + 'new_value': { + 'content_id': 'content_0', + 'html': '

This is the first card of the exploration.

', + }, + } + ), + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'property_name': exp_domain.STATE_PROPERTY_CONTENT, + 'state_name': 'End State', + 'new_value': { + 'content_id': 'content_3', + 'html': '

This is the last card of the exploration.

', + }, + } + ), + ], + 'Changes content.', + ) def test_should_be_able_to_regenerate_voiceovers(self) -> None: self.login(self.VOICE_ARTIST_EMAIL) @@ -290,7 +326,7 @@ def test_should_be_able_to_regenerate_voiceovers(self) -> None: 'language_accent_code': 'en-US', 'state_name': 'Introduction', 'content_id': 'content_0', - 'exploration_version': 1, + 'exploration_version': 2, } handler_url = '/regenerate_automatic_voiceover/%s' % self.exploration.id @@ -362,31 +398,23 @@ def mock_defer( function_id: str, queue_name: str, exploration_id: str, - exploration_title: str, exploration_version: int, - committer_id: str, - datetime_str: str, ) -> None: deferred_calls.append( { 'function_id': function_id, 'queue_name': queue_name, 'exploration_id': exploration_id, - 'exploration_title': exploration_title, 'exploration_version': exploration_version, - 'committer_id': committer_id, - 'datetime_str': datetime_str, } ) exploration_id = self.exploration.id exploration_version = self.exploration.version - exploration_title = self.exploration.title - handler_url = '/regenerate_voiceover_on_exp_update/%s/%s/%s' % ( + handler_url = '/regenerate_voiceover_on_exp_update/%s/%s' % ( exploration_id, exploration_version, - exploration_title, ) with ( @@ -411,9 +439,7 @@ def mock_defer( self.assertEqual(args['function_id'], expected_func_name) self.assertEqual(args['queue_name'], 'voiceover-regeneration') self.assertEqual(args['exploration_id'], exploration_id) - self.assertEqual(args['exploration_title'], exploration_title) self.assertEqual(args['exploration_version'], exploration_version) - self.assertEqual(args['committer_id'], feconf.SYSTEM_COMMITTER_ID) self.logout() @@ -445,7 +471,7 @@ def test_get_automatic_voiceover_regeneration_records(self) -> None: queue_name, task_id, ) - function_id = 'delete_exps_from_user_models' + function_id = 'regenerate_voiceovers_on_exploration_update' taskqueue_services.create_new_cloud_task_model( new_model_id, task_name, function_id @@ -470,7 +496,7 @@ def test_get_automatic_voiceover_regeneration_records(self) -> None: ) self.assertEqual( json_response['automatic_voiceover_regeneration_records'], - [cloud_task_run.to_dict()], + [cloud_task_run.to_dict_with_timezone_info()], ) self.logout() @@ -497,14 +523,14 @@ def test_get_automatic_voiceover_regeneration_status(self) -> None: 'en-US': {'content_0': 'SUCCEEDED', 'content_1': 'SUCCEEDED'} } voiceover_regeneration_task_mapping = ( - cloud_task_domain.VoiceoverRegenerationTaskMapping( + cloud_task_domain.VoiceoverRegenerationJob( exploration_id, task_run_id, language_accent_to_content_status_map, ) ) - voiceover_cloud_task_services.save_voiceover_regeneration_task_run_mapping( + voiceover_cloud_task_services.save_voiceover_regeneration_job( voiceover_regeneration_task_mapping ) exploration = exp_domain.Exploration.create_default_exploration( @@ -711,7 +737,6 @@ def test_regenerate_voiceovers_on_exploration_added_to_topic(self) -> None: cloud_task_runs = taskqueue_services.get_all_cloud_task_runs() function_id = cloud_task_runs[0].function_id task_run_id = cloud_task_runs[0].task_run_id - created_on_time_str = cloud_task_runs[0].created_on.isoformat() # Verifying that a Cloud Task run is created to regenerate the # voiceovers. @@ -736,11 +761,22 @@ def test_regenerate_voiceovers_on_exploration_added_to_topic(self) -> None: # via a deferred job. voiceover_services.regenerate_voiceovers_on_exploration_added_to_topic( self.exploration_id, - created_on_time_str, - feconf.SYSTEM_COMMITTER_ID, task_run_id, ) + updated_cloud_task_runs = taskqueue_services.get_all_cloud_task_runs() + + for cloud_run in updated_cloud_task_runs: + if ( + cloud_run.function_id + == 'regenerate_voiceovers_for_batch_contents' + ): + child_task_run_id = cloud_run.task_run_id + + voiceover_services.regenerate_voiceovers_for_batch_contents( + self.exploration_id, task_run_id, child_task_run_id + ) + entity_voiceovers = ( voiceover_services.get_entity_voiceovers_for_given_exploration( self.exploration_id, 'exploration', 2 @@ -753,10 +789,8 @@ def test_regenerate_voiceovers_on_exploration_added_to_topic(self) -> None: self.assertEqual(entity_voiceovers[0].entity_id, self.exploration_id) self.assertEqual(entity_voiceovers[0].language_accent_code, 'en-US') - # The exploration contains two non-empty contents, content_0 and - # content_3, that are voiceovered automatically. self.assertListEqual( - ['content_0', 'content_3'], + ['content_0', 'default_outcome_1', 'ca_placeholder_2', 'content_3'], list(entity_voiceovers[0].voiceovers_mapping.keys()), ) @@ -771,6 +805,8 @@ def test_regenerate_voiceovers_on_exploration_added_to_topic(self) -> None: ] automated_voiceovers_audio_offsets_msecs = { 'content_0': dummy_audio_offset, + 'default_outcome_1': dummy_audio_offset, + 'ca_placeholder_2': dummy_audio_offset, 'content_3': dummy_audio_offset, } @@ -825,7 +861,6 @@ def test_regenerate_voiceovers_on_exploration_update(self) -> None: cloud_task_runs = taskqueue_services.get_all_cloud_task_runs() function_id = cloud_task_runs[0].function_id task_run_id = cloud_task_runs[0].task_run_id - created_on_time_str = cloud_task_runs[0].created_on.isoformat() # Verifying that a Cloud Task run is created to regenerate the # voiceovers. @@ -850,11 +885,22 @@ def test_regenerate_voiceovers_on_exploration_update(self) -> None: # via a deferred job. voiceover_services.regenerate_voiceovers_on_exploration_added_to_topic( self.exploration_id, - created_on_time_str, - feconf.SYSTEM_COMMITTER_ID, task_run_id, ) + updated_cloud_task_runs = taskqueue_services.get_all_cloud_task_runs() + + for cloud_run in updated_cloud_task_runs: + if ( + cloud_run.function_id + == 'regenerate_voiceovers_for_batch_contents' + ): + child_task_run_id = cloud_run.task_run_id + + voiceover_services.regenerate_voiceovers_for_batch_contents( + self.exploration_id, task_run_id, child_task_run_id + ) + entity_voiceovers = ( voiceover_services.get_entity_voiceovers_for_given_exploration( self.exploration_id, 'exploration', 2 @@ -893,10 +939,9 @@ def test_regenerate_voiceovers_on_exploration_update(self) -> None: # Simulating the frontend request that triggers voiceover regeneration # after an exploration update via a deferred job. - handler_url = '/regenerate_voiceover_on_exp_update/%s/%s/%s' % ( + handler_url = '/regenerate_voiceover_on_exp_update/%s/%s' % ( self.exploration_id, updated_exp.version, - updated_exp.title, ) csrf_token = self.get_new_csrf_token() self.post_json(handler_url, {}, csrf_token=csrf_token) @@ -909,10 +954,9 @@ def test_regenerate_voiceovers_on_exploration_update(self) -> None: # Updating a curated exploration triggers voiceover regeneration via # the Cloud Task service, confirming that a deferred request exists in # the model. - cloud_task_run = cloud_task_runs[1] + cloud_task_run = cloud_task_runs[2] function_id = cloud_task_run.function_id task_run_id = cloud_task_run.task_run_id - created_on_time_str = cloud_task_run.created_on.isoformat() self.assertEqual( function_id, feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[ @@ -924,10 +968,7 @@ def test_regenerate_voiceovers_on_exploration_update(self) -> None: # via a deferred job. voiceover_services.regenerate_voiceovers_on_exploration_update( self.exploration_id, - updated_exp.title, updated_exp.version, - feconf.SYSTEM_COMMITTER_ID, - created_on_time_str, task_run_id, ) @@ -955,15 +996,16 @@ def test_regenerate_voiceovers_on_exploration_update(self) -> None: automated_voiceovers_audio_offsets_msecs = { 'content_0': dummy_audio_offset, 'content_3': dummy_audio_offset, + 'default_outcome_1': dummy_audio_offset, + 'ca_placeholder_2': dummy_audio_offset, } self.assertDictEqual( entity_voiceovers[0].automated_voiceovers_audio_offsets_msecs, automated_voiceovers_audio_offsets_msecs, ) - # The exploration contains two non-empty contents, content_0 and - # content_3, that are voiceovered automatically. + self.assertListEqual( - ['content_0', 'content_3'], + ['content_0', 'default_outcome_1', 'ca_placeholder_2', 'content_3'], list(entity_voiceovers[0].voiceovers_mapping.keys()), ) @@ -1015,7 +1057,6 @@ def test_regenerate_voiceovers_on_translation_addition(self) -> None: cloud_task_runs = taskqueue_services.get_all_cloud_task_runs() function_id = cloud_task_runs[0].function_id task_run_id = cloud_task_runs[0].task_run_id - created_on_time_str = cloud_task_runs[0].created_on.isoformat() # Verifying that a Cloud Task run is created to regenerate the # voiceovers. @@ -1040,11 +1081,23 @@ def test_regenerate_voiceovers_on_translation_addition(self) -> None: # via a deferred job. voiceover_services.regenerate_voiceovers_on_exploration_added_to_topic( self.exploration_id, - created_on_time_str, - feconf.SYSTEM_COMMITTER_ID, task_run_id, ) + updated_cloud_task_runs = taskqueue_services.get_all_cloud_task_runs() + child_task_run_ids = [] + for cloud_run in updated_cloud_task_runs: + if ( + cloud_run.function_id + == 'regenerate_voiceovers_for_batch_contents' + ): + child_task_run_ids.append(cloud_run.task_run_id) + + for child_task_run_id in child_task_run_ids: + voiceover_services.regenerate_voiceovers_for_batch_contents( + self.exploration_id, task_run_id, child_task_run_id + ) + entity_voiceovers = ( voiceover_services.get_entity_voiceovers_for_given_exploration( self.exploration_id, 'exploration', 2 @@ -1102,10 +1155,9 @@ def test_regenerate_voiceovers_on_translation_addition(self) -> None: # Simulating the frontend request that triggers voiceover regeneration # after an exploration update via a deferred job. - handler_url = '/regenerate_voiceover_on_exp_update/%s/%s/%s' % ( + handler_url = '/regenerate_voiceover_on_exp_update/%s/%s' % ( self.exploration_id, updated_exp.version, - updated_exp.title, ) csrf_token = self.get_new_csrf_token() self.post_json(handler_url, {}, csrf_token=csrf_token) @@ -1118,10 +1170,9 @@ def test_regenerate_voiceovers_on_translation_addition(self) -> None: # Updating a curated exploration triggers voiceover regeneration via # the Cloud Task service, confirming that a deferred request exists in # the model. - cloud_task_run = cloud_task_runs[1] + cloud_task_run = cloud_task_runs[2] function_id = cloud_task_run.function_id task_run_id = cloud_task_run.task_run_id - created_on_time_str = cloud_task_run.created_on.isoformat() self.assertEqual( function_id, feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[ @@ -1133,13 +1184,36 @@ def test_regenerate_voiceovers_on_translation_addition(self) -> None: # via a deferred job. voiceover_services.regenerate_voiceovers_on_exploration_update( self.exploration_id, - updated_exp.title, updated_exp.version, - feconf.SYSTEM_COMMITTER_ID, - created_on_time_str, task_run_id, ) + updated_cloud_task_runs = sorted( + taskqueue_services.get_all_cloud_task_runs(), + key=lambda task_run: task_run.created_on, + ) + second_iter_child_task_run_ids = [] + for cloud_run in updated_cloud_task_runs: + if ( + cloud_run.function_id + == 'regenerate_voiceovers_for_batch_contents' + and cloud_run.task_run_id not in child_task_run_ids + ): + second_iter_child_task_run_ids.append(cloud_run.task_run_id) + + if ( + cloud_run.function_id + == 'regenerate_voiceovers_on_exploration_update' + ): + second_iter_parent_task_run_id = cloud_run.task_run_id + + for child_task_run_id in second_iter_child_task_run_ids: + voiceover_services.regenerate_voiceovers_for_batch_contents( + self.exploration_id, + second_iter_parent_task_run_id, + child_task_run_id, + ) + entity_voiceovers = sorted( voiceover_services.get_entity_voiceovers_for_given_exploration( self.exploration_id, 'exploration', 3 @@ -1161,7 +1235,7 @@ def test_regenerate_voiceovers_on_translation_addition(self) -> None: self.assertEqual(hindi_entity_voiceover.language_accent_code, 'hi-IN') self.assertListEqual( - ['content_0', 'content_3'], + ['content_0', 'default_outcome_1', 'ca_placeholder_2', 'content_3'], list(english_entity_voiceover.voiceovers_mapping.keys()), ) # Hindi translation was added only for the first content. @@ -1255,6 +1329,30 @@ def test_regenerate_voiceovers_on_translation_suggestion_acceptance( }, csrf_token=csrf_token_2, ) + cloud_task_runs = taskqueue_services.get_all_cloud_task_runs() + + voiceover_services.regenerate_voiceovers_after_accepting_suggestion( + suggestion_to_accept['suggestion_id'], + cloud_task_runs[1].task_run_id, + ) + + updated_cloud_task_runs = sorted( + taskqueue_services.get_all_cloud_task_runs(), + key=lambda task_run: task_run.created_on, + ) + child_task_run_ids = [] + for cloud_run in updated_cloud_task_runs: + if ( + cloud_run.function_id + == 'regenerate_voiceovers_for_batch_contents' + ): + child_task_run_ids.append(cloud_run.task_run_id) + for child_task_run_id in child_task_run_ids: + voiceover_services.regenerate_voiceovers_for_batch_contents( + self.exploration_id, + cloud_task_runs[1].task_run_id, + child_task_run_id, + ) self.logout() @@ -1622,11 +1720,9 @@ class RegenerateVoiceoversForExplorationHandlerTests( def mock_defer( self, _function_id: str, - _queue_id: str, + _queue_name: str, _exploration_id: str, _language_accent_code: str, - _user_id: str, - _datetime_str: str, ) -> None: pass diff --git a/core/domain/beam_job_services.py b/core/domain/beam_job_services.py index 7ffd135a8643d..c81eeee3cc1aa 100644 --- a/core/domain/beam_job_services.py +++ b/core/domain/beam_job_services.py @@ -24,7 +24,7 @@ from core.jobs import registry as jobs_registry from core.platform import models -from typing import List, Optional, Type +from typing import Dict, List, Optional, Type MYPY = False if MYPY: # pragma: no cover @@ -38,6 +38,7 @@ def run_beam_job( job_name: Optional[str] = None, job_class: Optional[Type[base_jobs.JobBase]] = None, + parameterized_args: Optional[Dict[str, str]] = None, ) -> beam_job_domain.BeamJobRun: """Starts a new Apache Beam job and returns metadata about its execution. @@ -46,6 +47,10 @@ def run_beam_job( job_class must not be None. job_class: type(JobBase). A subclass of JobBase to begin running. This value takes precedence over job_name. + parameterized_args: dict(str, str). The arguments to pass to the + job when it is run. The keys of the dict should be the names of the + arguments as defined in the job's class definition, and the values + should be the corresponding values to use for those arguments. Returns: BeamJobRun. Metadata about the run's execution. @@ -60,7 +65,10 @@ def run_beam_job( raise ValueError('Must specify the job class or name to run') run_synchronously = constants.EMULATOR_MODE - run_model = jobs_manager.run_job(job_class, run_synchronously) + + run_model = jobs_manager.run_job( + job_class, run_synchronously, parameterized_args=parameterized_args + ) return get_beam_job_run_from_model(run_model) diff --git a/core/domain/blog_services.py b/core/domain/blog_services.py index 5662fa36d591e..777defaf3c5a7 100644 --- a/core/domain/blog_services.py +++ b/core/domain/blog_services.py @@ -412,6 +412,26 @@ def get_blog_post_rights( return get_blog_post_rights_from_model(model) +def get_total_number_of_matching_blog_posts( + query_string: str, tags: List[str] +) -> int: + """Returns the total number of blog posts matching the search query and tags.""" + valid_blog_post_ids: List[str] = [] + search_offset: Optional[int] = None + + for _ in range(MAX_ITERATIONS): + remaining_to_fetch = 1000 + + batch_ids, search_offset = get_blog_post_ids_matching_query( + query_string, tags, size=remaining_to_fetch, offset=search_offset + ) + valid_blog_post_ids.extend(batch_ids) + + if search_offset is None: + break + return len(valid_blog_post_ids) + + def get_published_blog_post_summaries_by_user_id( user_id: str, max_limit: int, offset: int = 0 ) -> List[blog_domain.BlogPostSummary]: diff --git a/core/domain/cloud_task_domain.py b/core/domain/cloud_task_domain.py index 66eaded5d2d36..db037ce12fb7a 100644 --- a/core/domain/cloud_task_domain.py +++ b/core/domain/cloud_task_domain.py @@ -91,6 +91,34 @@ def to_dict(self) -> CloudTaskRunDict: 'created_on': self.created_on.isoformat(), } + def to_dict_with_timezone_info(self) -> CloudTaskRunDict: + """Returns a dictionary representation of this domain object with timezone + information included in the datetime fields. + + Returns: + CloudTaskRunDict. A dictionary representation of the CloudTaskRun + object, with keys matching the attributes of the object and timezone + information included in the datetime fields. + """ + return { + 'task_run_id': self.task_run_id, + 'cloud_task_name': self.cloud_task_name, + 'task_id': self.task_id, + 'queue_id': self.queue_id, + 'latest_job_state': self.latest_job_state, + 'function_id': self.function_id, + 'exception_messages_for_failed_runs': ( + self.exception_messages_for_failed_runs + ), + 'current_retry_attempt': self.current_retry_attempt, + 'last_updated': self.last_updated.replace( + tzinfo=datetime.timezone.utc + ).isoformat(), + 'created_on': self.created_on.replace( + tzinfo=datetime.timezone.utc + ).isoformat(), + } + @classmethod def from_dict(cls, cloud_task_run_dict: CloudTaskRunDict) -> CloudTaskRun: """Returns a domain object from a dictionary. @@ -123,15 +151,15 @@ def from_dict(cls, cloud_task_run_dict: CloudTaskRunDict) -> CloudTaskRun: ) -class VoiceoverRegenerationTaskMappingDict(TypedDict): - """Dictionary representing the VoiceoverRegenerationTaskMapping object.""" +class VoiceoverRegenerationJobDict(TypedDict): + """Dictionary representing the VoiceoverRegenerationJob object.""" exploration_id: str task_run_id: str language_accent_to_content_status_map: Dict[str, Dict[str, str]] -class VoiceoverRegenerationTaskMapping: +class VoiceoverRegenerationJob: """Domain object class that models the voiceover regeneration request for an exploration, associated with a specific cloud task run. """ @@ -142,7 +170,7 @@ def __init__( task_run_id: str, language_accent_to_content_status_map: Dict[str, Dict[str, str]], ) -> None: - """Initializes a VoiceoverRegenerationTaskMapping domain object. + """Initializes a VoiceoverRegenerationJob domain object. Args: exploration_id: str. The ID of the exploration. @@ -156,12 +184,12 @@ def __init__( language_accent_to_content_status_map ) - def to_dict(self) -> VoiceoverRegenerationTaskMappingDict: + def to_dict(self) -> VoiceoverRegenerationJobDict: """Returns a dictionary representation of this domain object. Returns: dict. A dictionary representation of the - VoiceoverRegenerationTaskMapping object, with keys matching the + VoiceoverRegenerationJob object, with keys matching the attributes of the object. """ @@ -176,44 +204,42 @@ def to_dict(self) -> VoiceoverRegenerationTaskMappingDict: @classmethod def from_dict( cls, - voiceover_regeneration_task_mapping_dict: VoiceoverRegenerationTaskMappingDict, - ) -> VoiceoverRegenerationTaskMapping: - """Returns an instance of VoiceoverRegenerationTaskMapping from the + voiceover_regeneration_job_dict: VoiceoverRegenerationJobDict, + ) -> VoiceoverRegenerationJob: + """Returns an instance of VoiceoverRegenerationJob from the given dictionary. Args: - voiceover_regeneration_task_mapping_dict: dict. A dictionary - representation of the VoiceoverRegenerationTaskMapping object. + voiceover_regeneration_job_dict: dict. A dictionary + representation of the VoiceoverRegenerationJob object. Returns: - VoiceoverRegenerationTaskMapping. A VoiceoverRegenerationTaskMapping + VoiceoverRegenerationJob. A VoiceoverRegenerationJob domain object created from the given dict representation. """ return cls( - exploration_id=voiceover_regeneration_task_mapping_dict[ - 'exploration_id' - ], - task_run_id=voiceover_regeneration_task_mapping_dict['task_run_id'], + exploration_id=voiceover_regeneration_job_dict['exploration_id'], + task_run_id=voiceover_regeneration_job_dict['task_run_id'], language_accent_to_content_status_map=( - voiceover_regeneration_task_mapping_dict[ + voiceover_regeneration_job_dict[ 'language_accent_to_content_status_map' ] ), ) @classmethod - def create_default_voiceover_regeneration_task_mapping( + def create_default( cls, exploration_id: str, task_run_id: str - ) -> VoiceoverRegenerationTaskMapping: - """Creates a default voiceover regeneration task mapping. + ) -> VoiceoverRegenerationJob: + """Creates a default voiceover regeneration job instance. Args: exploration_id: str. The ID of the exploration. task_run_id: str. The ID of the cloud task run. Returns: - VoiceoverRegenerationTaskMapping. The created voiceover - regeneration task mapping. + VoiceoverRegenerationJob. The created voiceover regeneration job + instance with an empty language accent to content status map. """ return cls( exploration_id=exploration_id, @@ -241,7 +267,86 @@ def are_all_voiceovers_generated(self) -> bool: return False return True - def update_final_content_status_for_cloud_task_run( + def are_all_voiceovers_attempted(self) -> bool: + """Checks if all the contents for the voiceover regeneration request + have been attempted i.e., either succeeded or failed, none + of them are still generating. + + Returns: + bool. Whether all contents have been attempted or not. + """ + for ( + content_id_to_regeneration_status + ) in self.language_accent_to_content_status_map.values(): + for ( + regeneration_status + ) in content_id_to_regeneration_status.values(): + if ( + regeneration_status + == feconf.VoiceoverRegenerationState.GENERATING.value + ): + return False + return True + + def update_failed_content_status( + self, language_accent_code: str, failed_content_ids: List[str] + ) -> None: + """Updates the content-status map for a given language-accent code by + marking the content IDs in failed_content_ids as FAILED. + + Args: + language_accent_code: str. The language accent code. + failed_content_ids: List[str]. The list of content IDs for which + voiceover regeneration has failed. + """ + content_status_map = self.language_accent_to_content_status_map.get( + language_accent_code, {} + ) + + for content_id in failed_content_ids: + if content_id in content_status_map: + content_status_map[content_id] = ( + feconf.VoiceoverRegenerationState.FAILED.value + ) + + def update_succeeded_content_status( + self, language_accent_code: str, succeeded_content_ids: List[str] + ) -> None: + """Updates the content-status map for a given language-accent code by + marking the content IDs in succeeded_content_ids as SUCCEEDED. + + Args: + language_accent_code: str. The language accent code. + succeeded_content_ids: List[str]. The list of content IDs for which + voiceover regeneration has succeeded. + """ + content_status_map = self.language_accent_to_content_status_map.get( + language_accent_code, {} + ) + + for content_id in succeeded_content_ids: + if content_id in content_status_map: + content_status_map[content_id] = ( + feconf.VoiceoverRegenerationState.SUCCEEDED.value + ) + + def update_remaining_content_status_as_succeeded(self) -> None: + """Updates the content-status map for a given language-accent code by + marking all content IDs which are still GENERATING as SUCCEEDED. + """ + for ( + content_status_map + ) in self.language_accent_to_content_status_map.values(): + for content_id, regeneration_status in content_status_map.items(): + if ( + regeneration_status + == feconf.VoiceoverRegenerationState.GENERATING.value + ): + content_status_map[content_id] = ( + feconf.VoiceoverRegenerationState.SUCCEEDED.value + ) + + def update_final_content_status( self, language_accent_code: str, failed_content_ids: List[str] ) -> None: """Updates the content-status map for a given language-accent code by @@ -287,3 +392,121 @@ def add_language_accent_to_content_status_map( self.language_accent_to_content_status_map[language_accent_code] = ( content_status_map ) + + def count_total_failed_contents(self) -> int: + """Counts the total number of contents for which voiceover regeneration + has failed. + + Returns: + int. The total number of contents for which voiceover regeneration + has failed. + """ + total_failed_contents = 0 + for ( + content_id_to_regeneration_status + ) in self.language_accent_to_content_status_map.values(): + for ( + regeneration_status + ) in content_id_to_regeneration_status.values(): + if ( + regeneration_status + == feconf.VoiceoverRegenerationState.FAILED.value + ): + total_failed_contents += 1 + + return total_failed_contents + + +class VoiceoverRegenerationTaskBatchDict(TypedDict): + """Dictionary representing the VoiceoverRegenerationTaskBatch object.""" + + parent_cloud_task_run_id: str + child_cloud_task_run_id: str + exploration_id: str + exploration_version: int + language_accent_code: str + content_ids_to_contents_map: Dict[str, str] + + +class VoiceoverRegenerationTaskBatch: + """Voiceover regeneration for a large number of contents within a single + Cloud Task run (deferred request) significantly increases the workload and + may lead to timeout failures due to Gunicorn limitations. + + To mitigate this issue, a single deferred regeneration task is split into + multiple smaller batches, organized in a parent-child relationship between + Cloud Task runs. + + This class is the domain class representation for + VoiceoverRegenerationBatchExecutionModel. + """ + + def __init__( + self, + parent_cloud_task_run_id: str, + child_cloud_task_run_id: str, + exploration_id: str, + exploration_version: int, + language_accent_code: str, + content_ids_to_contents_map: Dict[str, str], + ) -> None: + self.parent_cloud_task_run_id = parent_cloud_task_run_id + self.child_cloud_task_run_id = child_cloud_task_run_id + self.exploration_id = exploration_id + self.exploration_version = exploration_version + self.language_accent_code = language_accent_code + self.content_ids_to_contents_map = content_ids_to_contents_map + + def to_dict(self) -> VoiceoverRegenerationTaskBatchDict: + """Returns a dictionary representation of this domain object. + + Returns: + dict. A dictionary representation of the + VoiceoverRegenerationTaskBatch object, with keys matching the + attributes of the object. + """ + return { + 'parent_cloud_task_run_id': self.parent_cloud_task_run_id, + 'child_cloud_task_run_id': self.child_cloud_task_run_id, + 'exploration_id': self.exploration_id, + 'exploration_version': self.exploration_version, + 'language_accent_code': self.language_accent_code, + 'content_ids_to_contents_map': self.content_ids_to_contents_map, + } + + @classmethod + def from_dict( + cls, + voiceover_regeneration_task_batch_dict: VoiceoverRegenerationTaskBatchDict, + ) -> VoiceoverRegenerationTaskBatch: + """Returns an instance of VoiceoverRegenerationTaskBatch from the + given dictionary. + + Args: + voiceover_regeneration_task_batch_dict: dict. A dictionary + representation of the VoiceoverRegenerationTaskBatch object. + + Returns: + VoiceoverRegenerationTaskBatch. A VoiceoverRegenerationTaskBatch + domain object created from the given dict representation. + """ + return cls( + parent_cloud_task_run_id=voiceover_regeneration_task_batch_dict[ + 'parent_cloud_task_run_id' + ], + child_cloud_task_run_id=voiceover_regeneration_task_batch_dict[ + 'child_cloud_task_run_id' + ], + exploration_id=voiceover_regeneration_task_batch_dict[ + 'exploration_id' + ], + exploration_version=voiceover_regeneration_task_batch_dict[ + 'exploration_version' + ], + language_accent_code=voiceover_regeneration_task_batch_dict[ + 'language_accent_code' + ], + content_ids_to_contents_map=voiceover_regeneration_task_batch_dict[ + 'content_ids_to_contents_map' + ], + ) diff --git a/core/domain/cloud_task_domain_test.py b/core/domain/cloud_task_domain_test.py index 5ba4393fb96fe..b28fc30fb4529 100644 --- a/core/domain/cloud_task_domain_test.py +++ b/core/domain/cloud_task_domain_test.py @@ -107,9 +107,33 @@ def test_should_create_domain_object_from_dict(self) -> None: self.assertEqual(cloud_task_run.to_dict(), cloud_task_run_dict) + def test_should_convert_datetime_fields_with_timezone_info(self) -> None: + cloud_task_run = cloud_task_domain.CloudTaskRun( + task_run_id='cloud_task_run_id', + cloud_task_name='projects/dev-project-id/locations/us-central/' + 'queues/test_queue_name/tasks/task_id', + task_id='task_id', + queue_id='test_queue_name', + latest_job_state='running', + function_id='delete_exps_from_user_models', + exception_messages_for_failed_runs=[], + current_retry_attempt=0, + last_updated=datetime.datetime(2026, 1, 2, 3, 4, 5), + created_on=datetime.datetime(2026, 1, 2, 3, 4, 6), + ) + + cloud_task_run_dict = cloud_task_run.to_dict_with_timezone_info() + + self.assertEqual( + cloud_task_run_dict['last_updated'], '2026-01-02T03:04:05+00:00' + ) + self.assertEqual( + cloud_task_run_dict['created_on'], '2026-01-02T03:04:06+00:00' + ) -class VoiceoverRegenerationTaskMappingTests(test_utils.GenericTestBase): - """Unit tests for VoiceoverRegenerationTaskMapping domain object.""" + +class VoiceoverRegenerationJobTests(test_utils.GenericTestBase): + """Unit tests for VoiceoverRegenerationJob domain object.""" def test_should_create_domain_object_correctly(self) -> None: exploration_id = 'exp_id' @@ -122,7 +146,7 @@ def test_should_create_domain_object_correctly(self) -> None: } voiceover_regeneration_task_mapping = ( - cloud_task_domain.VoiceoverRegenerationTaskMapping( + cloud_task_domain.VoiceoverRegenerationJob( exploration_id, task_run_id, language_accent_to_content_status_map, @@ -151,7 +175,7 @@ def test_should_create_domain_object_from_dict(self) -> None: } voiceover_regeneration_task_mapping_dict: ( - cloud_task_domain.VoiceoverRegenerationTaskMappingDict + cloud_task_domain.VoiceoverRegenerationJobDict ) = { 'exploration_id': exploration_id, 'task_run_id': task_run_id, @@ -161,7 +185,7 @@ def test_should_create_domain_object_from_dict(self) -> None: } voiceover_regeneration_task_mapping = ( - cloud_task_domain.VoiceoverRegenerationTaskMapping.from_dict( + cloud_task_domain.VoiceoverRegenerationJob.from_dict( voiceover_regeneration_task_mapping_dict ) ) @@ -175,8 +199,10 @@ def test_should_be_able_to_create_default_object(self) -> None: exploration_id = 'exp_id' task_run_id = 'task_run_id' - voiceover_regeneration_task_mapping = cloud_task_domain.VoiceoverRegenerationTaskMapping.create_default_voiceover_regeneration_task_mapping( - exploration_id, task_run_id + voiceover_regeneration_task_mapping = ( + cloud_task_domain.VoiceoverRegenerationJob.create_default( + exploration_id, task_run_id + ) ) self.assertEqual( @@ -205,7 +231,7 @@ def test_should_verify_if_all_voiceovers_are_generated(self) -> None: } voiceover_regeneration_task_mapping = ( - cloud_task_domain.VoiceoverRegenerationTaskMapping( + cloud_task_domain.VoiceoverRegenerationJob( exploration_id, task_run_id, language_accent_to_content_status_map, @@ -227,7 +253,7 @@ def test_should_verify_if_all_voiceovers_are_generated(self) -> None: voiceover_regeneration_task_mapping.are_all_voiceovers_generated() ) - def test_should_update_final_content_status_for_cloud_task_run( + def test_should_update_final_content_status_successfully( self, ) -> None: exploration_id = 'exp_id' @@ -241,14 +267,17 @@ def test_should_update_final_content_status_for_cloud_task_run( } voiceover_regeneration_task_mapping = ( - cloud_task_domain.VoiceoverRegenerationTaskMapping( + cloud_task_domain.VoiceoverRegenerationJob( exploration_id, task_run_id, language_accent_to_content_status_map, ) ) + self.assertFalse( + voiceover_regeneration_task_mapping.are_all_voiceovers_attempted() + ) - voiceover_regeneration_task_mapping.update_final_content_status_for_cloud_task_run( + voiceover_regeneration_task_mapping.update_final_content_status( 'en-US', ['content_1'] ) @@ -264,6 +293,12 @@ def test_should_update_final_content_status_for_cloud_task_run( voiceover_regeneration_task_mapping.language_accent_to_content_status_map, expected_language_accent_to_content_status_map, ) + self.assertTrue( + voiceover_regeneration_task_mapping.are_all_voiceovers_attempted() + ) + self.assertEqual( + voiceover_regeneration_task_mapping.count_total_failed_contents(), 1 + ) def test_should_add_language_accent_to_content_status_map(self) -> None: exploration_id = 'exp_id' @@ -271,7 +306,7 @@ def test_should_add_language_accent_to_content_status_map(self) -> None: language_accent_to_content_status_map: Dict[str, Dict[str, str]] = {} voiceover_regeneration_task_mapping = ( - cloud_task_domain.VoiceoverRegenerationTaskMapping( + cloud_task_domain.VoiceoverRegenerationJob( exploration_id, task_run_id, language_accent_to_content_status_map, @@ -293,3 +328,229 @@ def test_should_add_language_accent_to_content_status_map(self) -> None: voiceover_regeneration_task_mapping.language_accent_to_content_status_map, expected_language_accent_to_content_status_map, ) + + def test_should_successfully_update_status_of_contents(self) -> None: + exploration_id = 'exp_id' + task_run_id = 'task_run_id' + language_accent_to_content_status_map = { + 'en-US': { + 'content_0': 'GENERATING', + 'content_1': 'GENERATING', + } + } + + voiceover_regeneration_task_mapping = ( + cloud_task_domain.VoiceoverRegenerationJob( + exploration_id, + task_run_id, + language_accent_to_content_status_map, + ) + ) + + voiceover_regeneration_task_mapping.update_succeeded_content_status( + 'en-US', ['content_0'] + ) + voiceover_regeneration_task_mapping.update_failed_content_status( + 'en-US', ['content_1'] + ) + + expected_language_accent_to_content_status_map = { + 'en-US': { + 'content_0': 'SUCCEEDED', + 'content_1': 'FAILED', + } + } + + self.assertEqual( + voiceover_regeneration_task_mapping.language_accent_to_content_status_map, + expected_language_accent_to_content_status_map, + ) + + def test_should_update_remaining_content_status_as_succeeded(self) -> None: + exploration_id = 'exp_id' + task_run_id = 'task_run_id' + language_accent_to_content_status_map = { + 'en-US': { + 'content_0': 'GENERATING', + 'content_1': 'FAILED', + 'content_2': 'SUCCEEDED', + }, + 'hi-IN': { + 'content_3': 'GENERATING', + }, + } + + voiceover_regeneration_task_mapping = ( + cloud_task_domain.VoiceoverRegenerationJob( + exploration_id, + task_run_id, + language_accent_to_content_status_map, + ) + ) + + self.assertFalse( + voiceover_regeneration_task_mapping.are_all_voiceovers_attempted() + ) + + ( + voiceover_regeneration_task_mapping.update_remaining_content_status_as_succeeded() + ) + + expected_language_accent_to_content_status_map = { + 'en-US': { + 'content_0': 'SUCCEEDED', + 'content_1': 'FAILED', + 'content_2': 'SUCCEEDED', + }, + 'hi-IN': { + 'content_3': 'SUCCEEDED', + }, + } + + self.assertEqual( + voiceover_regeneration_task_mapping.language_accent_to_content_status_map, + expected_language_accent_to_content_status_map, + ) + self.assertTrue( + voiceover_regeneration_task_mapping.are_all_voiceovers_attempted() + ) + + +class VoiceoverRegenerationTaskBatchTests(test_utils.GenericTestBase): + """Unit tests for VoiceoverRegenerationTaskBatch domain object.""" + + def test_should_create_domain_object_correctly(self) -> None: + parent_cloud_task_run_id = 'parent_task_run_id' + child_cloud_task_run_id = 'child_task_run_id_1' + exploration_id = 'exp_id' + exploration_version = 1 + language_accent_code = 'en-US' + content_ids_to_contents_map = { + 'content_0': 'This is content 0', + 'content_1': 'This is content 1', + } + + voiceover_regeneration_task_batch = ( + cloud_task_domain.VoiceoverRegenerationTaskBatch( + parent_cloud_task_run_id, + child_cloud_task_run_id, + exploration_id, + exploration_version, + language_accent_code, + content_ids_to_contents_map, + ) + ) + + self.assertEqual( + voiceover_regeneration_task_batch.parent_cloud_task_run_id, + parent_cloud_task_run_id, + ) + self.assertEqual( + voiceover_regeneration_task_batch.child_cloud_task_run_id, + child_cloud_task_run_id, + ) + self.assertEqual( + voiceover_regeneration_task_batch.exploration_id, exploration_id + ) + self.assertEqual( + voiceover_regeneration_task_batch.exploration_version, + exploration_version, + ) + self.assertEqual( + voiceover_regeneration_task_batch.language_accent_code, + language_accent_code, + ) + self.assertEqual( + voiceover_regeneration_task_batch.content_ids_to_contents_map, + content_ids_to_contents_map, + ) + + def test_should_create_domain_object_from_dict(self) -> None: + parent_cloud_task_run_id = 'parent_task_run_id' + child_cloud_task_run_id = 'child_task_run_id_1' + exploration_id = 'exp_id' + exploration_version = 1 + language_accent_code = 'en-US' + content_ids_to_contents_map = { + 'content_0': 'This is content 0', + 'content_1': 'This is content 1', + } + + voiceover_regeneration_task_batch_dict: ( + cloud_task_domain.VoiceoverRegenerationTaskBatchDict + ) = { + 'parent_cloud_task_run_id': parent_cloud_task_run_id, + 'child_cloud_task_run_id': child_cloud_task_run_id, + 'exploration_id': exploration_id, + 'exploration_version': exploration_version, + 'language_accent_code': language_accent_code, + 'content_ids_to_contents_map': content_ids_to_contents_map, + } + + voiceover_regeneration_task_batch = ( + cloud_task_domain.VoiceoverRegenerationTaskBatch.from_dict( + voiceover_regeneration_task_batch_dict + ) + ) + + self.assertEqual( + voiceover_regeneration_task_batch.to_dict(), + voiceover_regeneration_task_batch_dict, + ) + + def test_should_convert_to_dict_correctly(self) -> None: + parent_cloud_task_run_id = 'parent_task_run_id' + child_cloud_task_run_id = 'child_task_run_id_1' + exploration_id = 'exp_id' + exploration_version = 2 + language_accent_code = 'hi-IN' + content_ids_to_contents_map = { + 'content_0': 'Content 0 text', + } + + voiceover_regeneration_task_batch = ( + cloud_task_domain.VoiceoverRegenerationTaskBatch( + parent_cloud_task_run_id, + child_cloud_task_run_id, + exploration_id, + exploration_version, + language_accent_code, + content_ids_to_contents_map, + ) + ) + + expected_dict = { + 'parent_cloud_task_run_id': parent_cloud_task_run_id, + 'child_cloud_task_run_id': child_cloud_task_run_id, + 'exploration_id': exploration_id, + 'exploration_version': exploration_version, + 'language_accent_code': language_accent_code, + 'content_ids_to_contents_map': content_ids_to_contents_map, + } + + self.assertEqual( + voiceover_regeneration_task_batch.to_dict(), expected_dict + ) + + def test_should_handle_empty_content_map(self) -> None: + parent_cloud_task_run_id = 'parent_task_run_id' + child_cloud_task_run_id = 'child_task_run_id_1' + exploration_id = 'exp_id' + exploration_version = 1 + language_accent_code = 'en-US' + content_ids_to_contents_map: Dict[str, str] = {} + + voiceover_regeneration_task_batch = ( + cloud_task_domain.VoiceoverRegenerationTaskBatch( + parent_cloud_task_run_id, + child_cloud_task_run_id, + exploration_id, + exploration_version, + language_accent_code, + content_ids_to_contents_map, + ) + ) + + self.assertEqual( + voiceover_regeneration_task_batch.content_ids_to_contents_map, {} + ) diff --git a/core/domain/email_manager.py b/core/domain/email_manager.py index 42ca0297901e8..519829feecddf 100644 --- a/core/domain/email_manager.py +++ b/core/domain/email_manager.py @@ -36,6 +36,7 @@ story_domain, subscription_services, suggestion_registry, + taskqueue_services, user_services, ) from core.platform import models @@ -672,16 +673,36 @@ def _send_email_transactional() -> None: """Sends the email to a single recipient.""" sender_name_email = '%s <%s>' % (sender_name, sender_email) - email_services.send_mail( - sender_name_email, - recipient_email_address, - email_subject, - cleaned_plaintext_body, - cleaned_html_body, - cc_emails=cc_emails, - bcc_admin=bcc_admin, - attachments=attachments, - ) + try: + email_services.send_mail( + sender_name_email, + recipient_email_address, + email_subject, + cleaned_plaintext_body, + cleaned_html_body, + cc_emails=cc_emails, + bcc_admin=bcc_admin, + attachments=attachments, + ) + except Exception as e: + logging.error( + 'Email to %s failed to send: %s. Enqueuing for retry.', + recipient_email_address, + e, + ) + + payload = { + 'sender_email': sender_name_email, + 'recipient_id': recipient_email_address, + 'subject': email_subject, + 'html_body': cleaned_html_body, + 'text_body': cleaned_plaintext_body, + } + + taskqueue_services.enqueue_task( + feconf.TASK_URL_RETRY_FAILED_EMAIL, payload, 0 + ) + email_models.SentEmailModel.create( recipient_id, recipient_email_address, @@ -756,14 +777,31 @@ def _send_bulk_mail_transactional(instance_id: str) -> None: """ sender_name_email = '%s <%s>' % (sender_name, sender_email) - email_services.send_bulk_mail( - sender_name_email, - recipient_emails, - email_subject, - cleaned_plaintext_body, - cleaned_html_body, - attachments, - ) + try: + email_services.send_bulk_mail( + sender_name_email, + recipient_emails, + email_subject, + cleaned_plaintext_body, + cleaned_html_body, + attachments, + ) + except Exception as e: + logging.error( + 'Bulk email failed to send: %s. Enqueuing for retry.', e + ) + + for recipient_email in recipient_emails: + payload = { + 'sender_email': sender_name_email, + 'recipient_id': recipient_email, + 'subject': email_subject, + 'html_body': cleaned_html_body, + 'text_body': cleaned_plaintext_body, + } + taskqueue_services.enqueue_task( + feconf.TASK_URL_RETRY_FAILED_EMAIL, payload, 0 + ) email_models.BulkEmailModel.create( instance_id, diff --git a/core/domain/email_manager_test.py b/core/domain/email_manager_test.py index d626cadc14f77..b39ea688dd94b 100644 --- a/core/domain/email_manager_test.py +++ b/core/domain/email_manager_test.py @@ -24,6 +24,7 @@ from core.constants import constants from core.domain import ( email_manager, + email_services, exp_domain, html_cleaner, platform_parameter_domain, @@ -37,6 +38,7 @@ subscription_services, suggestion_registry, suggestion_services, + taskqueue_services, translation_domain, user_services, ) @@ -9330,3 +9332,82 @@ def test_sends_email_to_tech_leads_on_regeneration_failure(self) -> None: sent_email_model.subject, '[Attention needed] Automatic Voiceover Generation Failed', ) + + +class EmailRetryQueueTests(test_utils.EmailTestBase): + """Tests the retry logic when email sending fails.""" + + USER_A_EMAIL = 'a@example.com' + + def setUp(self) -> None: + super().setUp() + self.signup(self.USER_A_EMAIL, 'userA') + self.user_a_id = self.get_user_id_from_email(self.USER_A_EMAIL) + + def test_failed_send_mail_enqueues_retry_task(self) -> None: + def mock_send_mail(*_args: str, **_kwargs: str) -> None: + raise Exception('Simulated email failure') + + enqueued_tasks = [] + + def mock_enqueue_task( + url: str, payload: dict[str, str], _delay: int + ) -> None: + enqueued_tasks.append((url, payload)) + + send_mail_swap = self.swap(email_services, 'send_mail', mock_send_mail) + enqueue_task_swap = self.swap( + taskqueue_services, 'enqueue_task', mock_enqueue_task + ) + + with send_mail_swap, enqueue_task_swap: + email_manager._send_email( # pylint: disable=protected-access + self.user_a_id, + feconf.SYSTEM_COMMITTER_ID, + feconf.EMAIL_INTENT_SIGNUP, + 'Subject', + 'Body', + 'sender@example.com', + ) + + self.assertEqual(len(enqueued_tasks), 1) + self.assertEqual( + enqueued_tasks[0][0], feconf.TASK_URL_RETRY_FAILED_EMAIL + ) + self.assertEqual(enqueued_tasks[0][1]['subject'], 'Subject') + + def test_failed_send_bulk_mail_enqueues_retry_task(self) -> None: + def mock_send_bulk_mail(*_args: str, **_kwargs: str) -> None: + raise Exception('Simulated bulk email failure') + + enqueued_tasks = [] + + def mock_enqueue_task( + url: str, payload: dict[str, str], _delay: int + ) -> None: + enqueued_tasks.append((url, payload)) + + send_bulk_mail_swap = self.swap( + email_services, 'send_bulk_mail', mock_send_bulk_mail + ) + enqueue_task_swap = self.swap( + taskqueue_services, 'enqueue_task', mock_enqueue_task + ) + + with send_bulk_mail_swap, enqueue_task_swap: + email_manager._send_bulk_mail( # pylint: disable=protected-access + [self.user_a_id], + feconf.SYSTEM_COMMITTER_ID, + feconf.BULK_EMAIL_INTENT_MARKETING, + 'Bulk Subject', + 'Bulk Body', + 'sender@example.com', + 'Sender Name', + 'instance_id', + ) + + self.assertEqual(len(enqueued_tasks), 1) + self.assertEqual( + enqueued_tasks[0][0], feconf.TASK_URL_RETRY_FAILED_EMAIL + ) + self.assertEqual(enqueued_tasks[0][1]['subject'], 'Bulk Subject') diff --git a/core/domain/exp_services.py b/core/domain/exp_services.py index 1cb73af057984..d97901db91b34 100644 --- a/core/domain/exp_services.py +++ b/core/domain/exp_services.py @@ -476,6 +476,178 @@ def export_states_to_yaml( return exploration_dict +def get_content_updates_from_cmd_edit_state_property_change( + change: exp_domain.ExplorationChange, +) -> Dict[str, str]: + """Extracts content ids and content values from CMD_EDIT_STATE_PROPERTY. + + Args: + change: ExplorationChange. The exploration change object. + + Returns: + dict(str, str). A mapping from content_id to content html. + """ + content_id_to_content_value: Dict[str, str] = {} + + if change.cmd != exp_domain.CMD_EDIT_STATE_PROPERTY: + return content_id_to_content_value + + if change.new_value is None: + return content_id_to_content_value + + def add_subtitled_html_from_dict( + subtitled_html: state_domain.SubtitledHtmlDict, + ) -> None: + """Adds a mapping from a subtitled html dict, if valid.""" + conten_id = subtitled_html.get('content_id') + + content_value = None + if subtitled_html.get('html'): + content_value = subtitled_html.get('html') + + if isinstance(conten_id, str) and isinstance(content_value, str): + content_id_to_content_value[conten_id] = content_value + + def add_subtitled_unicode_from_dict( + subtitled_unicode: state_domain.SubtitledUnicodeDict, + ) -> None: + """Adds a mapping from a subtitled unicode dict, if valid.""" + conten_id = subtitled_unicode.get('content_id') + + content_value = None + if subtitled_unicode.get('unicode_str'): + content_value = subtitled_unicode.get('unicode_str') + + if isinstance(conten_id, str) and isinstance(content_value, str): + content_id_to_content_value[conten_id] = content_value + + if change.property_name == exp_domain.STATE_PROPERTY_CONTENT: + # Here we use cast because this 'if' condition forces change to have + # type EditExpStatePropertyContentCmd. + edit_content_cmd = cast( + exp_domain.EditExpStatePropertyContentCmd, change + ) + add_subtitled_html_from_dict(edit_content_cmd.new_value) + elif ( + change.property_name + == exp_domain.STATE_PROPERTY_INTERACTION_DEFAULT_OUTCOME + ): + # Here we use cast because this 'elif' condition forces change to have + # type EditExpStatePropertyInteractionDefaultOutcomeCmd. + edit_interaction_default_outcome_cmd = cast( + exp_domain.EditExpStatePropertyInteractionDefaultOutcomeCmd, + change, + ) + + add_subtitled_html_from_dict( + edit_interaction_default_outcome_cmd.new_value['feedback'] + ) + elif ( + change.property_name + == exp_domain.STATE_PROPERTY_INTERACTION_ANSWER_GROUPS + ): + # Here we use cast because this 'elif' condition forces change to have + # type EditExpStatePropertyInteractionAnswerGroupsCmd. + edit_interaction_answer_group_cmd = cast( + exp_domain.EditExpStatePropertyInteractionAnswerGroupsCmd, + change, + ) + answer_group_dicts = edit_interaction_answer_group_cmd.new_value or [] + + for answer_group_dict in answer_group_dicts: + add_subtitled_html_from_dict( + answer_group_dict['outcome']['feedback'] + ) + elif change.property_name == exp_domain.STATE_PROPERTY_INTERACTION_HINTS: + # Here we use cast because this 'elif' condition forces change to have + # type EditExpStatePropertyInteractionHintsCmd. + edit_state_interaction_hints_cmd = cast( + exp_domain.EditExpStatePropertyInteractionHintsCmd, + change, + ) + hint_dicts = edit_state_interaction_hints_cmd.new_value or [] + + for hint_dict in hint_dicts: + add_subtitled_html_from_dict(hint_dict['hint_content']) + elif change.property_name == exp_domain.STATE_PROPERTY_INTERACTION_SOLUTION: + # Here we use cast because this 'elif' condition forces change to have + # type EditExpStatePropertyInteractionSolutionCmd. + edit_interaction_solution_cmd = cast( + exp_domain.EditExpStatePropertyInteractionSolutionCmd, + change, + ) + add_subtitled_html_from_dict( + edit_interaction_solution_cmd.new_value['explanation'] + ) + elif ( + change.property_name == exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS + ): + # Here we use cast because this 'elif' condition forces change to have + # type EditExpStatePropertyInteractionCustArgsCmd. + edit_interaction_cust_arg_cmd = cast( + exp_domain.EditExpStatePropertyInteractionCustArgsCmd, + change, + ) + customization_arg_dicts = edit_interaction_cust_arg_cmd.new_value or {} + for cust_arg_dict in customization_arg_dicts.values(): + for cust_arg_value in cust_arg_dict.values(): + # Each of these conversions are intended to be run on every + # single item. + try: + # Here we use cast because we are narrowing down the type + # UnionOfCustomizationArgsDictValues dict to SubtitledHtmlDict. + cust_arg_subtitled_html_dict = cast( + state_domain.SubtitledHtmlDict, cust_arg_value + ) + add_subtitled_html_from_dict(cust_arg_subtitled_html_dict) + except Exception: + pass + + try: + # Here we use cast because we are narrowing down the type + # UnionOfCustomizationArgsDictValues dict to SubtitledUnicodeDict. + cust_arg_subtitled_unicode_dict = cast( + state_domain.SubtitledUnicodeDict, cust_arg_value + ) + add_subtitled_unicode_from_dict( + cust_arg_subtitled_unicode_dict + ) + except Exception: + pass + + if isinstance(cust_arg_value, list): + for item in cust_arg_value: + # Each of these conversions are intended to be run on + # every single item. + try: + # Here we use cast because we are narrowing down the + # type UnionOfCustomizationArgsDictValues dict to + # SubtitledHtmlDict. + item_subtitled_html_dict = cast( + state_domain.SubtitledHtmlDict, item + ) + add_subtitled_html_from_dict( + item_subtitled_html_dict + ) + except Exception: + pass + + try: + # Here we use cast because we are narrowing down the + # type UnionOfCustomizationArgsDictValues dict to + # SubtitledUnicodeDict. + item_subtitled_unicode_dict = cast( + state_domain.SubtitledUnicodeDict, item + ) + add_subtitled_unicode_from_dict( + item_subtitled_unicode_dict + ) + except Exception: + pass + + return content_id_to_content_value + + # Repository SAVE and DELETE methods. def apply_change_list( exploration_id: str, change_list: Sequence[exp_domain.ExplorationChange] diff --git a/core/domain/exp_services_test.py b/core/domain/exp_services_test.py index f4ab1f23c216f..81dd09e4da89b 100644 --- a/core/domain/exp_services_test.py +++ b/core/domain/exp_services_test.py @@ -2404,6 +2404,282 @@ def test_cannot_load_yaml_with_no_schema_version(self) -> None: ) +class GetContentUpdatesFromCmdEditStatePropertyChangeTests( + test_utils.GenericTestBase +): + """Tests for get_content_updates_from_cmd_edit_state_property_change.""" + + def test_returns_empty_mapping_for_non_edit_state_property_cmd( + self, + ) -> None: + change = exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_ADD_STATE, + 'state_name': 'State A', + 'content_id_for_state_content': 'content_1', + 'content_id_for_default_outcome': 'default_outcome_1', + } + ) + + self.assertEqual( + exp_services.get_content_updates_from_cmd_edit_state_property_change( + change + ), + {}, + ) + + def test_returns_empty_mapping_when_new_value_is_none(self) -> None: + change = exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': 'State A', + 'property_name': exp_domain.STATE_PROPERTY_CONTENT, + 'new_value': None, + } + ) + + self.assertEqual( + exp_services.get_content_updates_from_cmd_edit_state_property_change( + change + ), + {}, + ) + + def test_extracts_content_updates_for_content_property(self) -> None: + change = exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': 'State A', + 'property_name': exp_domain.STATE_PROPERTY_CONTENT, + 'new_value': { + 'content_id': 'content_1', + 'html': '

New content.

', + }, + } + ) + + self.assertEqual( + exp_services.get_content_updates_from_cmd_edit_state_property_change( + change + ), + {'content_1': '

New content.

'}, + ) + + def test_extracts_content_updates_for_default_outcome(self) -> None: + change = exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': 'State A', + 'property_name': ( + exp_domain.STATE_PROPERTY_INTERACTION_DEFAULT_OUTCOME + ), + 'new_value': { + 'feedback': { + 'content_id': 'default_outcome_1', + 'html': '

Try again.

', + } + }, + } + ) + + self.assertEqual( + exp_services.get_content_updates_from_cmd_edit_state_property_change( + change + ), + {'default_outcome_1': '

Try again.

'}, + ) + + def test_extracts_content_updates_for_answer_groups(self) -> None: + change = exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': 'State A', + 'property_name': ( + exp_domain.STATE_PROPERTY_INTERACTION_ANSWER_GROUPS + ), + 'new_value': [ + { + 'outcome': { + 'feedback': { + 'content_id': 'answer_group_1', + 'html': '

Correct.

', + } + } + }, + { + 'outcome': { + 'feedback': { + 'content_id': 'answer_group_2', + 'html': '

Incorrect.

', + } + } + }, + ], + } + ) + + self.assertEqual( + exp_services.get_content_updates_from_cmd_edit_state_property_change( + change + ), + { + 'answer_group_1': '

Correct.

', + 'answer_group_2': '

Incorrect.

', + }, + ) + + def test_extracts_content_updates_for_hints_and_solution(self) -> None: + hints_change = exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': 'State A', + 'property_name': exp_domain.STATE_PROPERTY_INTERACTION_HINTS, + 'new_value': [ + { + 'hint_content': { + 'content_id': 'hint_1', + 'html': '

Hint 1.

', + } + }, + { + 'hint_content': { + 'content_id': 'hint_2', + 'html': '

Hint 2.

', + } + }, + ], + } + ) + solution_change = exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': 'State A', + 'property_name': exp_domain.STATE_PROPERTY_INTERACTION_SOLUTION, + 'new_value': { + 'explanation': { + 'content_id': 'solution_1', + 'html': '

Explanation.

', + } + }, + } + ) + + self.assertEqual( + exp_services.get_content_updates_from_cmd_edit_state_property_change( + hints_change + ), + { + 'hint_1': '

Hint 1.

', + 'hint_2': '

Hint 2.

', + }, + ) + self.assertEqual( + exp_services.get_content_updates_from_cmd_edit_state_property_change( + solution_change + ), + {'solution_1': '

Explanation.

'}, + ) + + def test_extracts_content_updates_for_customization_args(self) -> None: + change = exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': 'State A', + 'property_name': ( + exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS + ), + 'new_value': { + 'placeholder': { + 'value': { + 'content_id': 'ca_placeholder_1', + 'unicode_str': 'Enter answer', + } + }, + 'choices': { + 'value': [ + { + 'content_id': 'ca_choices_1', + 'html': '

Choice 1

', + }, + { + 'content_id': 'ca_choices_2', + 'unicode_str': '

Choice 2

', + }, + ] + }, + 'rows': {'value': 1}, + }, + } + ) + + self.assertEqual( + exp_services.get_content_updates_from_cmd_edit_state_property_change( + change + ), + { + 'ca_placeholder_1': 'Enter answer', + 'ca_choices_1': '

Choice 1

', + 'ca_choices_2': '

Choice 2

', + }, + ) + + def test_extracts_content_updates_for_customization_args_html_dict( + self, + ) -> None: + change = exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': 'State A', + 'property_name': ( + exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS + ), + 'new_value': { + 'question': { + 'value': { + 'content_id': 'ca_question_1', + 'html': '

Question prompt

', + } + }, + 'rows': {'value': 2}, + }, + } + ) + + self.assertEqual( + exp_services.get_content_updates_from_cmd_edit_state_property_change( + change + ), + {'ca_question_1': '

Question prompt

'}, + ) + + def test_should_not_extract_content_for_invalid_customization_args( + self, + ) -> None: + # Invalid value type for customization arg. + change = exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': 'State A', + 'property_name': ( + exp_domain.STATE_PROPERTY_INTERACTION_CUST_ARGS + ), + 'new_value': { + 'question': { + 'value': [999], + }, + 'rows': {'value': 2}, + }, + } + ) + + self.assertEqual( + exp_services.get_content_updates_from_cmd_edit_state_property_change( + change + ), + {}, + ) + + class GetImageFilenamesFromExplorationTests(ExplorationServicesUnitTests): def test_get_image_filenames_from_exploration(self) -> None: diff --git a/core/domain/fs_services.py b/core/domain/fs_services.py index db651303fc034..cac189255fdb4 100644 --- a/core/domain/fs_services.py +++ b/core/domain/fs_services.py @@ -114,8 +114,25 @@ class GcsFileSystem(GeneralFileSystem): This implementation ignores versioning. """ - def __init__(self, entity_name: str, entity_id: str) -> None: - self._bucket_name = app_identity_services.get_gcs_resource_bucket_name() + def __init__( + self, + entity_name: str, + entity_id: str, + oppia_project_id: Optional[str] = None, + ) -> None: + """Constructs a GcsFileSystem object. + + Args: + entity_name: str. The name of the entity + (eg: exploration, topic etc). + entity_id: str. The ID of the corresponding entity. + oppia_project_id: Optional[str]. The Google Cloud Project ID. Explicitly + required when running on Beam Dataflow, as workers cannot + retrieve the ID from environment variables. + """ + self._bucket_name = app_identity_services.get_gcs_resource_bucket_name( + oppia_project_id + ) super().__init__(entity_name, entity_id) def _get_gcs_file_url(self, filepath: str) -> str: diff --git a/core/domain/html_cleaner_test.py b/core/domain/html_cleaner_test.py index 420cb99a66a02..e09e6bd4f6b7c 100644 --- a/core/domain/html_cleaner_test.py +++ b/core/domain/html_cleaner_test.py @@ -18,6 +18,7 @@ from __future__ import annotations +from core import utils from core.domain import html_cleaner from core.tests import test_utils @@ -50,6 +51,36 @@ def test_whitelisted_tags(self) -> None: ): html_cleaner.filter_a('link', 'href', 'http://www.oppia.com') + def test_filter_a_with_https_scheme(self) -> None: + """Test filter_a allows https URLs.""" + self.assertTrue( + html_cleaner.filter_a('a', 'href', 'https://www.oppia.com') + ) + + def test_filter_a_with_target_attribute(self) -> None: + """Test filter_a allows target attribute.""" + self.assertTrue(html_cleaner.filter_a('a', 'target', '_blank')) + + def test_filter_a_with_invalid_scheme(self) -> None: + """Test filter_a rejects non-http/https schemes like ftp.""" + self.assertFalse( + html_cleaner.filter_a('a', 'href', 'ftp://www.oppia.com') + ) + + def test_filter_a_with_javascript_scheme(self) -> None: + """Test filter_a rejects javascript scheme.""" + self.assertFalse( + html_cleaner.filter_a('a', 'href', 'javascript:alert(1)') + ) + + def test_filter_a_with_unknown_attribute(self) -> None: + """Test filter_a returns False for unknown attributes.""" + self.assertFalse(html_cleaner.filter_a('a', 'onclick', 'alert(1)')) + + def test_filter_a_with_empty_href(self) -> None: + """Test filter_a rejects empty href.""" + self.assertFalse(html_cleaner.filter_a('a', 'href', '')) + def test_good_tags_allowed(self) -> None: test_data: List[Tuple[str, str]] = [ ( @@ -165,6 +196,19 @@ def test_strip_html_tags(self) -> None: for datum in test_data: self.assertEqual(html_cleaner.strip_html_tags(datum[0]), datum[1]) + def test_strip_html_tags_removes_all_markup(self) -> None: + """Test that strip_html_tags removes nested and complex tags.""" + self.assertEqual( + html_cleaner.strip_html_tags( + '

Hello World

' + ), + 'Hello World', + ) + + def test_strip_html_tags_with_empty_string(self) -> None: + """Test strip_html_tags with empty string.""" + self.assertEqual(html_cleaner.strip_html_tags(''), '') + class RteComponentExtractorUnitTests(test_utils.GenericTestBase): """Test the RTE component extractor.""" @@ -275,3 +319,806 @@ def test_get_image_filenames_from_html_strings(self) -> None: ], html_cleaner.get_image_filenames_from_html_strings(html_strings), ) + + def test_get_image_filenames_from_html_strings_with_no_components( + self, + ) -> None: + """Test get_image_filenames_from_html_strings with no RTE components.""" + self.assertEqual( + html_cleaner.get_image_filenames_from_html_strings( + ['

Just text

'] + ), + [], + ) + + def test_get_image_filenames_from_html_strings_with_empty_list( + self, + ) -> None: + """Test get_image_filenames_from_html_strings with empty list.""" + self.assertEqual( + html_cleaner.get_image_filenames_from_html_strings([]), + [], + ) + + def test_get_image_filenames_from_html_strings_with_only_images( + self, + ) -> None: + """Test get_image_filenames_from_html_strings with only image tags.""" + html_strings = [ + '' + '' + ] + result = html_cleaner.get_image_filenames_from_html_strings( + html_strings + ) + self.assertEqual(result, ['test.svg']) + + def test_get_image_filenames_from_html_strings_with_only_math( + self, + ) -> None: + """Test get_image_filenames_from_html_strings with only math tags.""" + html_strings = [ + '' + '' + ] + result = html_cleaner.get_image_filenames_from_html_strings( + html_strings + ) + self.assertEqual(result, ['math.svg']) + + def test_get_image_filenames_deduplicates(self) -> None: + """Test that duplicate filenames are removed.""" + html_strings = [ + '' + '', + '' + '', + ] + result = html_cleaner.get_image_filenames_from_html_strings( + html_strings + ) + self.assertEqual(result, ['dup.svg']) + + def test_get_image_filenames_from_html_strings_with_non_image_component( + self, + ) -> None: + """Test get_image_filenames_from_html_strings ignores non-image + and non-math RTE components like links. + """ + html_strings = [ + '' + '' + ] + result = html_cleaner.get_image_filenames_from_html_strings( + html_strings + ) + self.assertEqual(result, []) + + +class IsHtmlEmptyTests(test_utils.GenericTestBase): + """Tests for the is_html_empty function.""" + + def test_empty_quot_string_is_empty(self) -> None: + """Test that "" is considered empty.""" + self.assertTrue(html_cleaner.is_html_empty('""')) + + def test_escaped_quot_string_is_empty(self) -> None: + """Test that \\"""\\" is considered empty.""" + self.assertTrue(html_cleaner.is_html_empty('\\"""\\"')) + + def test_html_with_only_tags_is_empty(self) -> None: + """Test that HTML with only formatting tags is empty.""" + self.assertTrue(html_cleaner.is_html_empty('

')) + self.assertTrue(html_cleaner.is_html_empty('


')) + self.assertTrue(html_cleaner.is_html_empty('

 

')) + self.assertTrue(html_cleaner.is_html_empty('')) + self.assertTrue(html_cleaner.is_html_empty('')) + self.assertTrue(html_cleaner.is_html_empty('')) + self.assertTrue(html_cleaner.is_html_empty('')) + self.assertTrue(html_cleaner.is_html_empty('
')) + self.assertTrue(html_cleaner.is_html_empty('')) + self.assertTrue( + html_cleaner.is_html_empty( + '

' + ) + ) + + def test_html_with_text_content_is_not_empty(self) -> None: + """Test that HTML with actual text is not empty.""" + self.assertFalse(html_cleaner.is_html_empty('

Hello

')) + self.assertFalse(html_cleaner.is_html_empty('Some text')) + + def test_empty_string_is_empty(self) -> None: + """Test that an empty string is considered empty.""" + self.assertTrue(html_cleaner.is_html_empty('')) + + def test_whitespace_only_string_is_empty(self) -> None: + """Test that whitespace only string is considered empty.""" + self.assertTrue(html_cleaner.is_html_empty(' ')) + + def test_double_quotes_only_is_empty(self) -> None: + """Test that \"\" is considered empty.""" + self.assertTrue(html_cleaner.is_html_empty('\"\"')) + + def test_single_quotes_only_is_empty(self) -> None: + """Test that '' is considered empty.""" + self.assertTrue(html_cleaner.is_html_empty('\'\'')) + + +class ValidateRteTagsTests(test_utils.GenericTestBase): + """Tests for the validate_rte_tags function.""" + + def test_valid_html_without_rte_tags_passes(self) -> None: + """Test that plain HTML without RTE tags passes validation.""" + html_cleaner.validate_rte_tags('

Hello world

') + + def test_image_missing_alt_attribute_raises_error(self) -> None: + """Test image tag without alt-with-value raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Image tag does not have \'alt-with-value\' attribute.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_image_missing_caption_attribute_raises_error(self) -> None: + """Test image tag without caption-with-value raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Image tag does not have \'caption-with-value\' attribute.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_image_caption_too_long_raises_error(self) -> None: + """Test image tag with caption > 500 chars raises error.""" + long_caption = 'a' * 501 + html_data = ( + '' + '' % long_caption + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Image tag \'caption-with-value\' attribute should not ' + 'be greater than 500 characters.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_image_missing_filepath_raises_error(self) -> None: + """Test image tag without filepath-with-value raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Image tag does not have \'filepath-with-value\' attribute.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_image_empty_filepath_raises_error(self) -> None: + """Test image tag with empty filepath raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Image tag \'filepath-with-value\' attribute should not be empty.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_valid_image_tag_passes(self) -> None: + """Test a fully valid image tag passes.""" + html_data = ( + '' + '' + ) + html_cleaner.validate_rte_tags(html_data) + + def test_skillreview_missing_text_raises_error(self) -> None: + """Test skillreview tag without text-with-value raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'SkillReview tag does not have \'text-with-value\' attribute.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_skillreview_empty_text_raises_error(self) -> None: + """Test skillreview tag with empty text-with-value raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'SkillReview tag \'text-with-value\' attribute should ' + 'not be empty.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_skillreview_missing_skill_id_raises_error(self) -> None: + """Test skillreview tag without skill_id-with-value raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'SkillReview tag does not have \'skill_id-with-value\' attribute.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_skillreview_empty_skill_id_raises_error(self) -> None: + """Test skillreview tag with empty skill_id raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'SkillReview tag \'skill_id-with-value\' attribute should ' + 'not be empty.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_video_missing_start_raises_error(self) -> None: + """Test video tag without start-with-value raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Video tag does not have \'start-with-value\' attribute.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_video_empty_start_raises_error(self) -> None: + """Test video tag with empty start-with-value raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Video tag \'start-with-value\' attribute should not be empty.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_video_missing_end_raises_error(self) -> None: + """Test video tag without end-with-value raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Video tag does not have \'end-with-value\' attribute.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_video_missing_autoplay_raises_error(self) -> None: + """Test video tag without autoplay-with-value raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Video tag does not have \'autoplay-with-value\' attribute.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_video_invalid_autoplay_raises_error(self) -> None: + """Test video tag with non-boolean autoplay raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Video tag \'autoplay-with-value\' attribute should be ' + 'a boolean value.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_video_missing_video_id_raises_error(self) -> None: + """Test video tag without video_id-with-value raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Video tag does not have \'video_id-with-value\' attribute.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_video_empty_video_id_raises_error(self) -> None: + """Test video tag with empty video_id raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Video tag \'video_id-with-value\' attribute should not be empty.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_link_missing_text_raises_error(self) -> None: + """Test link tag without text-with-value raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Link tag does not have \'text-with-value\' attribute.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_link_missing_url_raises_error(self) -> None: + """Test link tag without url-with-value raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Link tag does not have \'url-with-value\' attribute.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_link_empty_url_raises_error(self) -> None: + """Test link tag with empty url raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Link tag \'url-with-value\' attribute should not be empty.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_link_with_invalid_scheme_raises_error(self) -> None: + """Test link tag with non-acceptable URL scheme raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Link should be prefix with acceptable schemas', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_valid_link_tag_passes(self) -> None: + """Test a fully valid link tag passes validation.""" + html_data = ( + '' + '' + ) + html_cleaner.validate_rte_tags(html_data) + + def test_math_missing_math_content_raises_error(self) -> None: + """Test math tag without math_content-with-value raises error.""" + html_data = '' + with self.assertRaisesRegex( + utils.ValidationError, + 'Math tag does not have \'math_content-with-value\' attribute.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_math_empty_math_content_raises_error(self) -> None: + """Test math tag with empty math_content raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Math tag \'math_content-with-value\' attribute should not ' + 'be empty.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_math_missing_raw_latex_raises_error(self) -> None: + """Test math tag without raw_latex in math_content raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Math tag does not have \'raw_latex-with-value\' attribute.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_math_empty_raw_latex_raises_error(self) -> None: + """Test math tag with empty raw_latex raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Math tag \'raw_latex-with-value\' attribute should not be empty.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_math_missing_svg_filename_raises_error(self) -> None: + """Test math tag without svg_filename raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Math tag does not have \'svg_filename-with-value\' attribute.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_math_empty_svg_filename_raises_error(self) -> None: + """Test math tag with empty svg_filename raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Math tag \'svg_filename-with-value\' attribute should not ' + 'be empty.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_math_svg_filename_without_svg_extension_raises_error( + self, + ) -> None: + """Test math tag with non-svg extension raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Math tag \'svg_filename-with-value\' attribute should ' + 'have svg extension.', + ): + html_cleaner.validate_rte_tags(html_data) + + def test_valid_math_tag_passes(self) -> None: + """Test that a valid math tag passes validation.""" + html_data = ( + '' + '' + ) + html_cleaner.validate_rte_tags(html_data) + + def test_nested_tabs_inside_tabs_or_collapsible_raises_error(self) -> None: + """Test tabs tag inside tabs/collapsible raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Tabs tag should not be present inside another ' + 'Tabs or Collapsible tag.', + ): + html_cleaner.validate_rte_tags( + html_data, is_tag_nested_inside_tabs_or_collapsible=True + ) + + def test_nested_collapsible_inside_tabs_or_collapsible_raises_error( + self, + ) -> None: + """Test collapsible tag inside tabs/collapsible raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Collapsible tag should not be present inside Tabs ' + 'or another Collapsible tag.', + ): + html_cleaner.validate_rte_tags( + html_data, is_tag_nested_inside_tabs_or_collapsible=True + ) + + def test_no_nested_tags_when_not_inside_tabs_or_collapsible(self) -> None: + """Test tabs/collapsible not checked when flag is False.""" + html_data = ( + '' + '' + ) + # Should not raise since is_tag_nested_inside_tabs_or_collapsible + # defaults to False. + html_cleaner.validate_rte_tags(html_data) + + def test_no_error_when_nested_flag_true_but_no_tabs_or_collapsible( + self, + ) -> None: + """Test that no error is raised when + is_tag_nested_inside_tabs_or_collapsible is True but the HTML + contains no tabs or collapsible tags. + """ + html_cleaner.validate_rte_tags( + '

Plain text

', + is_tag_nested_inside_tabs_or_collapsible=True, + ) + + +class ValidateTabsAndCollapsibleRteTagsTests(test_utils.GenericTestBase): + """Tests for validate_tabs_and_collapsible_rte_tags function.""" + + def test_no_tabs_or_collapsible_passes(self) -> None: + """Test that HTML without tabs or collapsible passes.""" + html_cleaner.validate_tabs_and_collapsible_rte_tags( + '

Hello world

' + ) + + def test_tabs_missing_tab_contents_attribute_raises_error(self) -> None: + """Test tabs tag without tab_contents-with-value raises error.""" + html_data = '' + with self.assertRaisesRegex( + utils.ValidationError, + 'No content attribute is present inside the tabs tag.', + ): + html_cleaner.validate_tabs_and_collapsible_rte_tags(html_data) + + def test_tabs_with_empty_tab_contents_raises_error(self) -> None: + """Test tabs tag with empty tab_contents list raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'No tabs are present inside the tabs tag.', + ): + html_cleaner.validate_tabs_and_collapsible_rte_tags(html_data) + + def test_tabs_missing_title_raises_error(self) -> None: + """Test tabs content without title raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'No title attribute is present inside the tabs tag.', + ): + html_cleaner.validate_tabs_and_collapsible_rte_tags(html_data) + + def test_tabs_empty_title_raises_error(self) -> None: + """Test tabs content with empty title raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'title present inside tabs tag is empty.', + ): + html_cleaner.validate_tabs_and_collapsible_rte_tags(html_data) + + def test_tabs_missing_content_raises_error(self) -> None: + """Test tabs content without content key raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'No content attribute is present inside the tabs tag.', + ): + html_cleaner.validate_tabs_and_collapsible_rte_tags(html_data) + + def test_tabs_empty_content_raises_error(self) -> None: + """Test tabs content with empty content raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'content present inside tabs tag is empty.', + ): + html_cleaner.validate_tabs_and_collapsible_rte_tags(html_data) + + def test_valid_tabs_passes(self) -> None: + """Test valid tabs tag passes validation.""" + html_data = ( + '' + '' + ) + html_cleaner.validate_tabs_and_collapsible_rte_tags(html_data) + + def test_collapsible_missing_content_attribute_raises_error(self) -> None: + """Test collapsible tag without content-with-value raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'No content attribute present in collapsible tag.', + ): + html_cleaner.validate_tabs_and_collapsible_rte_tags(html_data) + + def test_collapsible_empty_content_raises_error(self) -> None: + """Test collapsible tag with empty content raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'No collapsible content is present inside the tag.', + ): + html_cleaner.validate_tabs_and_collapsible_rte_tags(html_data) + + def test_collapsible_missing_heading_raises_error(self) -> None: + """Test collapsible tag without heading-with-value raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'No heading attribute present in collapsible tag.', + ): + html_cleaner.validate_tabs_and_collapsible_rte_tags(html_data) + + def test_collapsible_empty_heading_raises_error(self) -> None: + """Test collapsible tag with empty heading raises error.""" + html_data = ( + '' + '' + ) + with self.assertRaisesRegex( + utils.ValidationError, + 'Heading attribute inside the collapsible tag is empty.', + ): + html_cleaner.validate_tabs_and_collapsible_rte_tags(html_data) + + def test_valid_collapsible_passes(self) -> None: + """Test valid collapsible tag passes validation.""" + html_data = ( + '' + '' + ) + html_cleaner.validate_tabs_and_collapsible_rte_tags(html_data) diff --git a/core/domain/interaction_registry_test.py b/core/domain/interaction_registry_test.py index ba1abb9bfb3b0..78584ec777c75 100644 --- a/core/domain/interaction_registry_test.py +++ b/core/domain/interaction_registry_test.py @@ -18,6 +18,7 @@ from __future__ import annotations +import importlib import json import os @@ -236,3 +237,91 @@ def test_get_interaction_by_id_raises_error_for_none_interaction_id( Exception, 'No interaction exists for the None interaction_id.' ): interaction_registry.Registry.get_interaction_by_id(None) + + def test_refresh_skips_classes_not_inheriting_base_interaction( + self, + ) -> None: + """Test that _refresh skips classes whose base class is not + BaseInteraction. + """ + + class NotAnInteraction: + """A dummy class that does not inherit from BaseInteraction.""" + + pass + + original_import = importlib.import_module + + # Here we use type Any because the mock_import_module function + # needs to return different module types depending on the name, + # so a specific return type cannot be used. + def mock_import_module(name: str) -> Any: + module = original_import(name) + # For one specific interaction, replace the class with one that + # does not inherit from BaseInteraction. getattr(module, + # interaction_id) will then return NotAnInteraction for that id. + if name.endswith('.Continue.Continue'): + setattr(module, 'Continue', NotAnInteraction) + return module + + with self.swap(importlib, 'import_module', mock_import_module): + interaction_registry.Registry._refresh() # pylint: disable=protected-access + + # 'Continue' should NOT be in the registry since its mock class + # does not inherit from BaseInteraction. + self.assertNotIn( + 'Continue', + interaction_registry.Registry._interactions, # pylint: disable=protected-access + ) + # But other real interactions should still be registered. + self.assertTrue( + len( + interaction_registry.Registry._interactions # pylint: disable=protected-access + ) + > 0 + ) + + def test_get_all_specs_for_state_schema_version_with_can_fetch_latest( + self, + ) -> None: + """Test that get_all_specs_for_state_schema_version returns latest + specs when the file is not found and can_fetch_latest_specs is True. + """ + result = interaction_registry.Registry.get_all_specs_for_state_schema_version( + 0, can_fetch_latest_specs=True + ) + expected = interaction_registry.Registry.get_all_specs() + self.assertEqual(result, expected) + + def test_get_all_specs_for_state_schema_version_loads_from_file( + self, + ) -> None: + """Test that get_all_specs_for_state_schema_version successfully + loads specs from a legacy JSON file and caches them. + """ + # Use a version that has an actual legacy specs file. + version = 52 + # Ensure it's not already cached. + if ( + version + in interaction_registry.Registry._state_schema_version_to_interaction_specs # pylint: disable=protected-access,line-too-long + ): + del interaction_registry.Registry._state_schema_version_to_interaction_specs[ # pylint: disable=protected-access,line-too-long + version + ] + + # First call — reads from file and caches (lines 176-182). + result_first = interaction_registry.Registry.get_all_specs_for_state_schema_version( + version + ) + self.assertTrue(len(result_first) > 0) + self.assertIn( + version, + interaction_registry.Registry._state_schema_version_to_interaction_specs, # pylint: disable=protected-access,line-too-long + ) + + # Second call — should return from cache (line 193). + result_second = interaction_registry.Registry.get_all_specs_for_state_schema_version( + version + ) + self.assertEqual(result_first, result_second) diff --git a/core/domain/learner_playlist_services_test.py b/core/domain/learner_playlist_services_test.py index 947c53e8b4913..413a399ce1894 100644 --- a/core/domain/learner_playlist_services_test.py +++ b/core/domain/learner_playlist_services_test.py @@ -571,3 +571,113 @@ def test_get_all_learner_playlist_collection_ids(self) -> None: ), [self.COL_ID_0, self.COL_ID_1], ) + + def test_mark_exploration_to_be_played_later_with_subscribed_exp( + self, + ) -> None: + """Test that mark_exploration_to_be_played_later returns the correct + flags when the exploration is subscribed to by the user. + """ + swap_get_exp_ids = self.swap( + subscription_services, + 'get_exploration_ids_subscribed_to', + lambda _: [self.EXP_ID_0], + ) + with swap_get_exp_ids: + playlist_limit_exceeded, exp_belongs_to_subscribed = ( + learner_playlist_services.mark_exploration_to_be_played_later( + self.user_id, self.EXP_ID_0 + ) + ) + self.assertFalse(playlist_limit_exceeded) + self.assertTrue(exp_belongs_to_subscribed) + + def test_mark_exploration_already_in_playlist_with_no_position( + self, + ) -> None: + """Test that adding an exploration already in the playlist with no + position specified does nothing. + """ + learner_playlist_services.mark_exploration_to_be_played_later( + self.user_id, self.EXP_ID_0 + ) + self.assertEqual( + self._get_all_learner_playlist_exp_ids(self.user_id), + [self.EXP_ID_0], + ) + learner_playlist_services.mark_exploration_to_be_played_later( + self.user_id, self.EXP_ID_0 + ) + self.assertEqual( + self._get_all_learner_playlist_exp_ids(self.user_id), + [self.EXP_ID_0], + ) + + def test_mark_collection_to_be_played_later_with_subscribed_col( + self, + ) -> None: + """Test that mark_collection_to_be_played_later returns the correct + flags when the collection is subscribed to by the user. + """ + swap_get_col_ids = self.swap( + subscription_services, + 'get_collection_ids_subscribed_to', + lambda _: [self.COL_ID_0], + ) + with swap_get_col_ids: + playlist_limit_exceeded, col_belongs_to_subscribed = ( + learner_playlist_services.mark_collection_to_be_played_later( + self.user_id, self.COL_ID_0 + ) + ) + self.assertFalse(playlist_limit_exceeded) + self.assertTrue(col_belongs_to_subscribed) + + def test_mark_collection_already_in_playlist_with_no_position( + self, + ) -> None: + """Test that adding a collection already in the playlist with no + position specified does nothing. + """ + learner_playlist_services.mark_collection_to_be_played_later( + self.user_id, self.COL_ID_0 + ) + self.assertEqual( + self._get_all_learner_playlist_collection_ids(self.user_id), + [self.COL_ID_0], + ) + learner_playlist_services.mark_collection_to_be_played_later( + self.user_id, self.COL_ID_0 + ) + self.assertEqual( + self._get_all_learner_playlist_collection_ids(self.user_id), + [self.COL_ID_0], + ) + + def test_remove_exploration_from_playlist_when_no_playlist_exists( + self, + ) -> None: + """Test that remove_exploration_from_learner_playlist does nothing + when the user has no learner playlist model. + """ + self.signup('noplaylist@example.com', 'noplaylistuser') + no_playlist_user_id = self.get_user_id_from_email( + 'noplaylist@example.com' + ) + learner_playlist_services.remove_exploration_from_learner_playlist( + no_playlist_user_id, self.EXP_ID_0 + ) + + def test_remove_collection_from_playlist_when_no_playlist_exists( + self, + ) -> None: + """Test that remove_collection_from_learner_playlist does nothing + when the user has no learner playlist model. + """ + self.signup('noplaylist2@example.com', 'noplaylistuser2') + no_playlist_user_id = self.get_user_id_from_email( + 'noplaylist2@example.com' + ) + learner_playlist_services.remove_collection_from_learner_playlist( + no_playlist_user_id, self.COL_ID_0 + ) diff --git a/core/domain/learner_progress_services.py b/core/domain/learner_progress_services.py index e03a633fcf3c4..35fd1f5a627ec 100644 --- a/core/domain/learner_progress_services.py +++ b/core/domain/learner_progress_services.py @@ -19,6 +19,7 @@ from __future__ import annotations import collections +import itertools from core import utils from core.constants import constants @@ -40,6 +41,7 @@ topic_fetchers, topic_services, user_domain, + user_services, ) from core.platform import models @@ -112,12 +114,20 @@ class DisplayableCollectionSummaryDict(TypedDict): last_updated_msec: float created_on: float status: str - node_count: int + total_node_count: int + completed_node_count: int community_owned: bool thumbnail_icon_url: str thumbnail_bg_color: str +class ExplorationCheckpointProgressDict(TypedDict): + """Checkpoint progress data for a single exploration.""" + + visited_checkpoints_count: int + total_checkpoints_count: int + + def _get_completed_activities_from_model( completed_activities_model: user_models.CompletedActivitiesModel, ) -> user_domain.CompletedActivities: @@ -2031,7 +2041,8 @@ def get_collection_summary_dicts( collection_summary.collection_model_created_on ), 'status': collection_summary.status, - 'node_count': collection_summary.node_count, + 'total_node_count': collection_summary.node_count, + 'completed_node_count': 0, 'community_owned': collection_summary.community_owned, 'thumbnail_icon_url': ( utils.get_thumbnail_icon_url_for_category( @@ -2660,3 +2671,58 @@ def get_exploration_progress( learner_progress_in_explorations, number_of_nonexistent_explorations, ) + + +def get_checkpoint_progress_for_explorations( + user_id: str, exploration_ids: List[str] +) -> Dict[str, ExplorationCheckpointProgressDict]: + """Returns checkpoint progress data for each exploration ID. + + Args: + user_id: str. The id of the learner. + exploration_ids: list(str). Exploration IDs to compute progress for. + + Returns: + dict. Mapping from exploration ID to checkpoint progress counts. + """ + if not exploration_ids: + return {} + + exp_id_to_exp_map = exp_fetchers.get_multiple_explorations_by_id( + exploration_ids, strict=False + ) + user_id_exp_id_pairs = list(itertools.product([user_id], exploration_ids)) + exp_user_data_models = user_models.ExplorationUserDataModel.get_multi( + user_id_exp_id_pairs + ) + + progress_by_exp_id: Dict[str, ExplorationCheckpointProgressDict] = {} + for index, exp_id in enumerate(exploration_ids): + exploration = exp_id_to_exp_map.get(exp_id) + if exploration is None: + continue + + checkpoints_in_exp = user_services.get_checkpoints_in_order( + exploration.init_state_name, exploration.states + ) + visited_checkpoints = 0 + model = exp_user_data_models[index] + most_recently_visited_checkpoint = ( + model.most_recently_reached_checkpoint_state_name + if model is not None + else None + ) + if ( + most_recently_visited_checkpoint is not None + and most_recently_visited_checkpoint in checkpoints_in_exp + ): + visited_checkpoints = ( + checkpoints_in_exp.index(most_recently_visited_checkpoint) + 1 + ) + + progress_by_exp_id[exp_id] = { + 'visited_checkpoints_count': visited_checkpoints, + 'total_checkpoints_count': len(checkpoints_in_exp), + } + + return progress_by_exp_id diff --git a/core/domain/learner_progress_services_test.py b/core/domain/learner_progress_services_test.py index 6876083f475f4..121f6c112ac5c 100644 --- a/core/domain/learner_progress_services_test.py +++ b/core/domain/learner_progress_services_test.py @@ -24,6 +24,7 @@ from core.domain import ( collection_domain, collection_services, + exp_domain, exp_fetchers, exp_services, learner_goals_services, @@ -3356,3 +3357,225 @@ def test_get_displayable_collection_story_summaries(self) -> None: self.assertEqual( displayable_compeleted_story_summaries[1]['id'], self.COL_ID_3 ) + + def test_get_checkpoint_progress_for_explorations_with_no_explorations( + self, + ) -> None: + """Test checkpoint progress calculation with no explorations.""" + progress = ( + learner_progress_services.get_checkpoint_progress_for_explorations( + self.user_id, [] + ) + ) + self.assertEqual(progress, {}) + + def test_get_checkpoint_progress_for_explorations_with_no_progress( + self, + ) -> None: + """Test checkpoint progress with explorations that have no checkpoints + visited. + """ + # Create exploration with checkpoint. + exploration = self.save_new_valid_exploration( + self.EXP_ID_0, + self.owner_id, + title='Test Exploration', + category='Test', + objective='Test Objective', + ) + + # Mark initial state as checkpoint. + exp_services.update_exploration( + self.owner_id, + self.EXP_ID_0, + [ + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': exploration.init_state_name, + 'property_name': exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT, + 'new_value': True, + } + ), + ], + 'Mark initial state as checkpoint', + ) + + # Get checkpoint progress. + progress = ( + learner_progress_services.get_checkpoint_progress_for_explorations( + self.user_id, [self.EXP_ID_0] + ) + ) + + # Verify no progress has been made. + self.assertIn(self.EXP_ID_0, progress) + self.assertEqual( + progress[self.EXP_ID_0]['visited_checkpoints_count'], 0 + ) + self.assertEqual(progress[self.EXP_ID_0]['total_checkpoints_count'], 1) + + def test_get_checkpoint_progress_for_explorations_with_partial_progress( + self, + ) -> None: + """Test checkpoint progress calculation with partial completion.""" + # Create exploration with checkpoint. + exploration = self.save_new_valid_exploration( + self.EXP_ID_0, + self.owner_id, + title='Test Exploration', + category='Test', + objective='Test Objective', + ) + + # Mark initial state as checkpoint. + exp_services.update_exploration( + self.owner_id, + self.EXP_ID_0, + [ + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': exploration.init_state_name, + 'property_name': exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT, + 'new_value': True, + } + ), + ], + 'Mark initial state as checkpoint', + ) + + # Record progress to checkpoint. + user_models.ExplorationUserDataModel( + id='%s.%s' % (self.user_id, self.EXP_ID_0), + user_id=self.user_id, + exploration_id=self.EXP_ID_0, + most_recently_reached_checkpoint_state_name=exploration.init_state_name, + ).put() + + # Get checkpoint progress. + progress = ( + learner_progress_services.get_checkpoint_progress_for_explorations( + self.user_id, [self.EXP_ID_0] + ) + ) + + # Verify partial progress. + self.assertIn(self.EXP_ID_0, progress) + self.assertEqual( + progress[self.EXP_ID_0]['visited_checkpoints_count'], 1 + ) + self.assertEqual(progress[self.EXP_ID_0]['total_checkpoints_count'], 1) + + def test_get_checkpoint_progress_for_multiple_explorations(self) -> None: + """Test checkpoint progress for multiple explorations.""" + # Create first exploration with checkpoint. + exploration_1 = self.save_new_valid_exploration( + self.EXP_ID_0, self.owner_id, title='Test Exploration 1' + ) + exp_services.update_exploration( + self.owner_id, + self.EXP_ID_0, + [ + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': exploration_1.init_state_name, + 'property_name': exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT, + 'new_value': True, + } + ), + ], + 'Mark initial state as checkpoint', + ) + + # Create second exploration with checkpoint. + exploration_2 = self.save_new_valid_exploration( + self.EXP_ID_1, self.owner_id, title='Test Exploration 2' + ) + exp_services.update_exploration( + self.owner_id, + self.EXP_ID_1, + [ + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'state_name': exploration_2.init_state_name, + 'property_name': exp_domain.STATE_PROPERTY_CARD_IS_CHECKPOINT, + 'new_value': True, + } + ), + ], + 'Mark initial state as checkpoint', + ) + + # Record progress on first exploration only. + user_models.ExplorationUserDataModel( + id='%s.%s' % (self.user_id, self.EXP_ID_0), + user_id=self.user_id, + exploration_id=self.EXP_ID_0, + most_recently_reached_checkpoint_state_name=exploration_1.init_state_name, + ).put() + + # Get checkpoint progress for both. + progress = ( + learner_progress_services.get_checkpoint_progress_for_explorations( + self.user_id, [self.EXP_ID_0, self.EXP_ID_1] + ) + ) + + # Verify progress for both explorations. + self.assertIn(self.EXP_ID_0, progress) + self.assertEqual( + progress[self.EXP_ID_0]['visited_checkpoints_count'], 1 + ) + self.assertEqual(progress[self.EXP_ID_0]['total_checkpoints_count'], 1) + + self.assertIn(self.EXP_ID_1, progress) + self.assertEqual( + progress[self.EXP_ID_1]['visited_checkpoints_count'], 0 + ) + self.assertEqual(progress[self.EXP_ID_1]['total_checkpoints_count'], 1) + + def test_get_checkpoint_progress_for_nonexistent_exploration(self) -> None: + """Test checkpoint progress handles nonexistent explorations gracefully.""" + progress = ( + learner_progress_services.get_checkpoint_progress_for_explorations( + self.user_id, ['nonexistent_exp_id'] + ) + ) + + # Should return empty dict for nonexistent exploration. + self.assertEqual(progress, {}) + + def test_get_checkpoint_progress_with_invalid_checkpoint_name(self) -> None: + """Test checkpoint progress with invalid checkpoint name in user data.""" + # Create exploration with checkpoints. + exploration = self.save_new_valid_exploration( + self.EXP_ID_0, self.owner_id, title='Test Exploration' + ) + init_state = exploration.states[exploration.init_state_name] + init_state.card_is_checkpoint = True + exp_services.save_new_exploration(self.owner_id, exploration) + + # Record progress with invalid checkpoint name. + user_models.ExplorationUserDataModel( + id='%s.%s' % (self.user_id, self.EXP_ID_0), + user_id=self.user_id, + exploration_id=self.EXP_ID_0, + most_recently_reached_checkpoint_state_name='invalid_checkpoint', + ).put() + + # Get checkpoint progress. + progress = ( + learner_progress_services.get_checkpoint_progress_for_explorations( + self.user_id, [self.EXP_ID_0] + ) + ) + + # Should return 0 visited checkpoints for invalid checkpoint. + self.assertIn(self.EXP_ID_0, progress) + self.assertEqual( + progress[self.EXP_ID_0]['visited_checkpoints_count'], 0 + ) + self.assertEqual(progress[self.EXP_ID_0]['total_checkpoints_count'], 1) diff --git a/core/domain/opportunity_domain.py b/core/domain/opportunity_domain.py index 0939ad7edc98f..851b367c83b02 100644 --- a/core/domain/opportunity_domain.py +++ b/core/domain/opportunity_domain.py @@ -25,11 +25,8 @@ class PartialExplorationOpportunitySummaryDict(TypedDict): - """A dictionary representing partial fields of - ExplorationOpportunitySummary object. - - This dict has only required fields to represent - an opportunity to a contributor. + """A dictionary representing a PartialExplorationOpportunitySummary + object. """ id: str @@ -39,6 +36,7 @@ class PartialExplorationOpportunitySummaryDict(TypedDict): content_count: int translation_counts: Dict[str, int] translation_in_review_counts: Dict[str, int] + reviewer_only_content_count: int is_pinned: bool @@ -93,6 +91,7 @@ def __init__( language_codes_needing_voice_artists: List[str], language_codes_with_assigned_voice_artists: List[str], translation_in_review_counts: Dict[str, int], + reviewer_only_content_count: int = 0, is_pinned: bool = False, ) -> None: """Constructs a ExplorationOpportunitySummary domain object. @@ -118,6 +117,9 @@ def __init__( translation_in_review_counts: dict. A dict with language code as a key and number of translation in review in that language as the value. + reviewer_only_content_count: int. The number of content items that are + only translatable by reviewers (e.g. content with + 'set_of_strings' data format). is_pinned: bool. Denotes whether the opportunity is pinned or not in contributor dashboard. """ @@ -139,6 +141,7 @@ def __init__( language_codes_with_assigned_voice_artists ) self.translation_in_review_counts = translation_in_review_counts + self.reviewer_only_content_count = reviewer_only_content_count self.is_pinned = is_pinned self.validate() @@ -178,6 +181,9 @@ def from_dict( exploration_opportunity_summary_dict[ 'translation_in_review_counts' ], + exploration_opportunity_summary_dict.get( + 'reviewer_only_content_count', 0 + ), ) def to_dict(self) -> PartialExplorationOpportunitySummaryDict: @@ -200,6 +206,7 @@ def to_dict(self) -> PartialExplorationOpportunitySummaryDict: 'content_count': self.content_count, 'translation_counts': self.translation_counts, 'translation_in_review_counts': self.translation_in_review_counts, + 'reviewer_only_content_count': self.reviewer_only_content_count, 'is_pinned': self.is_pinned, } diff --git a/core/domain/opportunity_domain_test.py b/core/domain/opportunity_domain_test.py index 54da95208be87..d5b2109828b63 100644 --- a/core/domain/opportunity_domain_test.py +++ b/core/domain/opportunity_domain_test.py @@ -57,6 +57,7 @@ def setUp(self) -> None: 'language_codes_needing_voice_artists': ['en'], 'language_codes_with_assigned_voice_artists': ['hi'], 'translation_in_review_counts': {}, + 'reviewer_only_content_count': 0, 'is_pinned': False, } ) @@ -84,6 +85,7 @@ def test_to_and_from_dict_works_correctly(self) -> None: 'language_codes_needing_voice_artists': ['en'], 'language_codes_with_assigned_voice_artists': [], 'translation_in_review_counts': {}, + 'reviewer_only_content_count': 0, 'is_pinned': False, } @@ -105,6 +107,7 @@ def test_to_and_from_dict_works_correctly(self) -> None: 'content_count': 5, 'translation_counts': {}, 'translation_in_review_counts': {}, + 'reviewer_only_content_count': 0, 'is_pinned': False, }, ) diff --git a/core/domain/opportunity_services.py b/core/domain/opportunity_services.py index ec21add820a79..9fc03f00b902a 100644 --- a/core/domain/opportunity_services.py +++ b/core/domain/opportunity_services.py @@ -19,7 +19,6 @@ from __future__ import annotations import collections -import datetime import logging from core import feature_flag_list, feconf @@ -124,6 +123,11 @@ def get_exploration_opportunity_summary_from_model( model.language_codes_needing_voice_artists, model.language_codes_with_assigned_voice_artists, {}, + ( + model.reviewer_only_content_count + if model.reviewer_only_content_count is not None + else 0 + ), False, ) @@ -164,6 +168,9 @@ def _construct_new_opportunity_summary_models( language_codes_with_assigned_voice_artists=( opportunity_summary.language_codes_with_assigned_voice_artists ), + reviewer_only_content_count=( + opportunity_summary.reviewer_only_content_count + ), ) exploration_opportunity_summary_model_list.append(model) @@ -241,6 +248,7 @@ def create_exp_opportunity_summary( language_codes_needing_voice_artists.add(exploration.language_code) content_count = exploration.get_content_count() + reviewer_only_content_count = exploration.get_reviewer_only_content_count() translation_counts = translation_services.get_translation_counts( feconf.TranslatableEntityType.EXPLORATION, exploration ) @@ -267,9 +275,20 @@ def create_exp_opportunity_summary( list(language_codes_needing_voice_artists), [], {}, + reviewer_only_content_count, ) ) + return exploration_opportunity_summary + + +def generate_voiceovers_async_for_exp_linked_to_topic(exp_id: str) -> None: + """Triggers asynchronous voiceover generation for the specified exploration. + + Args: + exp_id: str. The ID of the exploration for which voiceovers should be + generated. + """ # Asynchronously regenerates voiceovers for exploration contents in English # and other available translations when the exploration is linked to a # story. @@ -282,11 +301,8 @@ def create_exp_opportunity_summary( 'FUNCTION_ID_REGENERATE_VOICEOVERS_ON_EXP_CURATION' ], taskqueue_services.QUEUE_NAME_VOICEOVER_REGENERATION, - exploration.id, - datetime.datetime.utcnow().isoformat(), - feconf.SYSTEM_COMMITTER_ID, + exp_id, ) - return exploration_opportunity_summary def _compute_exploration_incomplete_translation_languages( @@ -348,6 +364,7 @@ def _create_exploration_opportunities( exploration_opportunity_summary_list.append( create_exp_opportunity_summary(topic, story, exploration) ) + generate_voiceovers_async_for_exp_linked_to_topic(exploration.id) _save_multi_exploration_opportunity_summary( exploration_opportunity_summary_list ) @@ -383,6 +400,9 @@ def compute_opportunity_models_with_updated_exploration( ) exploration_opportunity_summary.content_count = content_count exploration_opportunity_summary.translation_counts = translation_counts + exploration_opportunity_summary.reviewer_only_content_count = ( + updated_exploration.get_reviewer_only_content_count() + ) incomplete_translation_language_codes = ( _compute_exploration_incomplete_translation_languages( complete_translation_language_list @@ -445,6 +465,11 @@ def update_translation_opportunity_with_accepted_suggestion( model ) + # Capture the old stored count before recounting for audit tracking. + old_translation_count = exp_opportunity_summary.translation_counts.get( + language_code, 0 + ) + # Recount the translations to ensure that the counts are accurate and to # prevent any double counting of translations. exploration = exp_fetchers.get_exploration_by_id(exploration_id) @@ -477,6 +502,20 @@ def update_translation_opportunity_with_accepted_suggestion( exp_opportunity_summary.validate() _save_multi_exploration_opportunity_summary([exp_opportunity_summary]) + audit_model = ( + opportunity_models.ExplorationOpportunitySummaryAuditModel.create_new( + exploration_id=exploration_id, + language_code=language_code, + action='translation_accepted', + old_translation_count=old_translation_count, + new_translation_count=exp_opportunity_summary.translation_counts[ + language_code + ], + content_count=exp_opportunity_summary.content_count, + ) + ) + audit_model.put() + def update_exploration_opportunities_with_story_changes( story: story_domain.Story, exp_ids: List[str] @@ -660,6 +699,7 @@ def get_translation_opportunities( opportunity_summary_exp_ids, language_code ) ) + for exp_opportunity_summary_model in exp_opportunity_summary_models: opportunity_summary = get_exploration_opportunity_summary_from_model( exp_opportunity_summary_model @@ -673,6 +713,7 @@ def get_translation_opportunities( opportunity_summary.translation_in_review_counts = { language_code: exp_id_to_in_review_count[opportunity_summary.id] } + opportunity_summaries.append(opportunity_summary) return opportunity_summaries, cursor, more diff --git a/core/domain/playthrough_issue_registry_test.py b/core/domain/playthrough_issue_registry_test.py index 6c17b73340dd8..38b69f4e632f0 100644 --- a/core/domain/playthrough_issue_registry_test.py +++ b/core/domain/playthrough_issue_registry_test.py @@ -18,6 +18,8 @@ from __future__ import annotations +import importlib + from core.domain import playthrough_issue_registry from core.tests import test_utils from extensions.issues.CyclicStateTransitions import CyclicStateTransitions @@ -26,6 +28,8 @@ MultipleIncorrectSubmissions, ) +from typing import Any + class IssueRegistryUnitTests(test_utils.GenericTestBase): """Test for the issue registry.""" @@ -75,3 +79,55 @@ def test_incorrect_issue_registry_types(self) -> None: playthrough_issue_registry.Registry.get_issue_by_type( self.invalid_issue_type ) + + def test_refresh_skips_classes_not_inheriting_base_issue_spec( + self, + ) -> None: + """Test that _refresh skips classes whose base class is not + BaseExplorationIssueSpec. + """ + + class NotAnIssue: + """A dummy class that does not inherit from + BaseExplorationIssueSpec. + """ + + pass + + original_import = importlib.import_module + + # Here we use type Any because the mock_import_module function + # needs to return different module types depending on the name, + # so a specific return type cannot be used. + def mock_import_module(name: str) -> Any: + module = original_import(name) + if name.endswith('.EarlyQuit.EarlyQuit'): + setattr(module, 'EarlyQuit', NotAnIssue) + return module + + with self.swap(importlib, 'import_module', mock_import_module): + playthrough_issue_registry.Registry._refresh() # pylint: disable=protected-access + + self.assertNotIn( + 'EarlyQuit', + playthrough_issue_registry.Registry._issues, # pylint: disable=protected-access + ) + self.assertTrue( + len( + playthrough_issue_registry.Registry._issues # pylint: disable=protected-access + ) + > 0 + ) + + def test_get_all_issues_returns_cached_when_already_populated( + self, + ) -> None: + """Test that get_all_issues skips _refresh when _issues is already + populated. + """ + # First call populates _issues. + first_result = playthrough_issue_registry.Registry.get_all_issues() + self.assertTrue(len(first_result) > 0) + # Second call should return from cache without calling _refresh. + second_result = playthrough_issue_registry.Registry.get_all_issues() + self.assertEqual(len(first_result), len(second_result)) diff --git a/core/domain/stats_domain_test.py b/core/domain/stats_domain_test.py index e6d8778350e22..67fa32210c5dd 100644 --- a/core/domain/stats_domain_test.py +++ b/core/domain/stats_domain_test.py @@ -2739,6 +2739,14 @@ def test_update_state_reference(self) -> None: self.learner_answer_details.state_reference, 'exp_id_1:state_name_1' ) + def test_state_reference_valid_for_question(self) -> None: + """Test that validation passes for a valid question state reference + with a single segment (no colon). + """ + self.learner_answer_details.entity_type = 'question' + self.learner_answer_details.state_reference = 'question_id' + self.learner_answer_details.validate() + # TODO(#13528): Here we use MyPy ignore because we remove this test after # the backend is fully type-annotated. Here ignore[assignment] is used to # test that state_reference is str. @@ -2929,6 +2937,16 @@ def test_get_new_learner_answer_info_id(self) -> None: self.assertNotEqual(learner_answer_info_id, None) self.assertTrue(isinstance(learner_answer_info_id, str)) + def test_validate_with_non_empty_dict_answer(self) -> None: + """Test that validation passes when the answer is a non-empty dict.""" + self.learner_answer_info.answer = {'key': 'value'} + self.learner_answer_info.validate() + + def test_validate_with_int_answer(self) -> None: + """Test that validation passes when the answer is an int.""" + self.learner_answer_info.answer = 42 + self.learner_answer_info.validate() + # TODO(#13528): Here we use MyPy ignore because we remove this test after # the backend is fully type-annotated. Here ignore[assignment] is used to # test id type. diff --git a/core/domain/suggestion_registry.py b/core/domain/suggestion_registry.py index 44a4827c4a4a1..b8660fb784bc9 100644 --- a/core/domain/suggestion_registry.py +++ b/core/domain/suggestion_registry.py @@ -1983,6 +1983,7 @@ class ContributorCertificateInfoDict(TypedDict): to_date: str team_lead: str contribution_hours: str + contribution_word_count: int language: Optional[str] @@ -1997,12 +1998,14 @@ def __init__( to_date: str, team_lead: str, contribution_hours: str, + contribution_word_count: int, language: Optional[str], ) -> None: self.from_date = from_date self.to_date = to_date self.team_lead = team_lead self.contribution_hours = contribution_hours + self.contribution_word_count = contribution_word_count self.language = language def to_dict(self) -> ContributorCertificateInfoDict: @@ -2018,6 +2021,7 @@ def to_dict(self) -> ContributorCertificateInfoDict: 'to_date': self.to_date, 'team_lead': self.team_lead, 'contribution_hours': self.contribution_hours, + 'contribution_word_count': self.contribution_word_count, 'language': self.language, } diff --git a/core/domain/suggestion_services.py b/core/domain/suggestion_services.py index e109426089c2d..05409e32894eb 100644 --- a/core/domain/suggestion_services.py +++ b/core/domain/suggestion_services.py @@ -44,7 +44,6 @@ translation_fetchers, user_domain, user_services, - voiceover_services, ) from core.platform import models @@ -972,14 +971,15 @@ def accept_suggestion( ) and suggestion.change_cmd.cmd == 'add_written_translation' ): - translated_content = suggestion.change_cmd.translation_html - content_id = suggestion.change_cmd.content_id - voiceover_services.generate_voiceover_from_translated_content( - suggestion.target_id, - suggestion.target_version_at_submission, - translated_content, - content_id, - suggestion.language_code, + # Here voiceover regeneration can run in the background (asynchronous), + # allowing translation reviewers to continue accepting or rejecting + # translations without being blocked. + taskqueue_services.defer( + feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[ + 'FUNCTION_ID_REGENERATE_VOICEOVERS_AFTER_ACCEPTING_SUGGESTION' + ], + taskqueue_services.QUEUE_NAME_VOICEOVER_REGENERATION, + suggestion.suggestion_id, ) @@ -4538,6 +4538,7 @@ def _generate_translation_contributor_certificate_data( to_date.strftime('%d %b %Y'), signature, str(hours_contributed), + words_count, language_description, ) @@ -4603,5 +4604,6 @@ def _generate_question_contributor_certificate_data( to_date.strftime('%d %b %Y'), signature, str(hours_contributed), + 0, None, ) diff --git a/core/domain/suggestion_services_test.py b/core/domain/suggestion_services_test.py index a8fc03a592e96..86a48c5959b53 100644 --- a/core/domain/suggestion_services_test.py +++ b/core/domain/suggestion_services_test.py @@ -9108,6 +9108,7 @@ def test_create_translation_contributor_certificate(self) -> None: certificate_data['contribution_hours'], self._calculate_translation_contribution_hours(3), ) + self.assertEqual(certificate_data['contribution_word_count'], 3) self.assertEqual(certificate_data['language'], 'Hindi') def test_create_translation_contributor_certificate_for_rule_translation( @@ -9150,6 +9151,7 @@ def test_create_translation_contributor_certificate_for_rule_translation( certificate_data['contribution_hours'], self._calculate_translation_contribution_hours(4), ) + self.assertEqual(certificate_data['contribution_word_count'], 4) self.assertEqual(certificate_data['language'], 'Hindi') def test_create_translation_contributor_certificate_for_english( @@ -9200,6 +9202,7 @@ def test_create_translation_contributor_certificate_for_english( certificate_data['contribution_hours'], self._calculate_translation_contribution_hours(3), ) + self.assertEqual(certificate_data['contribution_word_count'], 3) self.assertEqual(certificate_data['language'], 'English') def test_create_question_contributor_certificate(self) -> None: @@ -9266,6 +9269,7 @@ def test_create_question_contributor_certificate(self) -> None: certificate_data['contribution_hours'], self._calculate_question_contribution_hours(False), ) + self.assertEqual(certificate_data['contribution_word_count'], 0) def test_create_question_contributor_certificate_with_image_content( self, @@ -9335,6 +9339,7 @@ def test_create_question_contributor_certificate_with_image_content( certificate_data['contribution_hours'], self._calculate_question_contribution_hours(True), ) + self.assertEqual(certificate_data['contribution_word_count'], 0) def test_create_certificate_returns_none_for_no_translation_suggestions( self, diff --git a/core/domain/summary_services.py b/core/domain/summary_services.py index 0023ebf7f40e8..49e62a7d519e4 100644 --- a/core/domain/summary_services.py +++ b/core/domain/summary_services.py @@ -83,6 +83,8 @@ class DisplayableExplorationSummaryDict(TypedDict): tags: List[str] thumbnail_icon_url: str thumbnail_bg_color: str + visited_checkpoints_count: int + total_checkpoints_count: int num_views: int @@ -609,6 +611,8 @@ def get_displayable_exp_summary_dicts( exploration_summary.category ), 'num_views': view_counts[ind], + 'visited_checkpoints_count': 0, + 'total_checkpoints_count': 0, } displayable_exp_summaries.append(summary_dict) diff --git a/core/domain/taskqueue_services.py b/core/domain/taskqueue_services.py index fc05d693ce504..8b13c94025edf 100644 --- a/core/domain/taskqueue_services.py +++ b/core/domain/taskqueue_services.py @@ -110,6 +110,69 @@ def defer( cloud_task_model.put() +# Here we use type Any because in defer() function '*args' points to the +# positional arguments of any other function and those arguments can be of +# type str, list, int and other types too. Similarly, '**kwargs' points to +# the keyword arguments of any other function and those can also accept +# different types of values like '*args'. +def defer_voiceover_regeneration_task_in_batches( + fn_identifier: str, + queue_name: str, + parent_cloud_task_run_id: str, + child_cloud_task_model_id: str, + *args: Any, + **kwargs: Any, +) -> None: + """Adds a new task to a specified deferred queue scheduled for immediate + execution. + + Args: + fn_identifier: str. The string identifier of the function being + deferred. + queue_name: str. The name of the queue to place the task into. Should be + one of the QUEUE_NAME_* constants listed above. + parent_cloud_task_run_id: str. The ID of the parent Cloud Task run for + which the voiceover regeneration task is being deferred. + child_cloud_task_model_id: str. The ID for the new CloudTaskRunModel to + be created for this deferred task. + *args: list(*). Positional arguments for fn. Positional arguments + should be json serializable. + **kwargs: dict(str : *). Keyword arguments for fn. + + Raises: + ValueError. The arguments and keyword arguments that are passed in are + not JSON serializable. + """ + payload = { + 'fn_identifier': fn_identifier, + 'parent_cloud_task_run_id': parent_cloud_task_run_id, + 'cloud_task_model_id': child_cloud_task_model_id, + 'args': (args if args else []), + 'kwargs': (kwargs if kwargs else {}), + } + try: + json.dumps(payload) + except TypeError as e: + raise ValueError( + 'The args or kwargs passed to the deferred call with ' + 'function_identifier, %s, are not json serializable.' + % fn_identifier + ) from e + # This is a workaround for a known python bug. + # See https://bugs.python.org/issue7980 + datetime.datetime.strptime('', '') + + task = platform_taskqueue_services.create_http_task( + queue_name=queue_name, url=feconf.TASK_URL_DEFERRED, payload=payload + ) + assert task.name is not None + cloud_task_model = create_new_cloud_task_model( + child_cloud_task_model_id, task.name, fn_identifier + ) + cloud_task_model.update_timestamps() + cloud_task_model.put() + + # Here we use type Any because the argument 'params' can accept payload # dictionaries which can hold the values of type string, set, int and # other types too. @@ -220,6 +283,29 @@ def get_cloud_task_run_by_model_id( return convert_cloud_task_run_model_to_domain_object(cloud_task_model) +def get_cloud_task_runs_by_model_ids( + model_ids: List[str], +) -> List[cloud_task_domain.CloudTaskRun]: + """Fetches the CloudTaskRunModels using the provided model_ids. + + Args: + model_ids: list(str). The IDs of the CloudTaskRunModels to retrieve. + + Returns: + list(CloudTaskRun). A list of CloudTaskRun instances corresponding to the given + model_ids. + """ + cloud_task_model_instances = cloud_task_models.CloudTaskRunModel.get_multi( + model_ids + ) + + return [ + convert_cloud_task_run_model_to_domain_object(model) + for model in cloud_task_model_instances + if model is not None + ] + + def get_new_cloud_task_run_id() -> str: """Generates and returns a new unique ID for a CloudTaskRunModel. diff --git a/core/domain/taskqueue_services_test.py b/core/domain/taskqueue_services_test.py index 82ed5ca98fbfc..d7520a588fbdd 100644 --- a/core/domain/taskqueue_services_test.py +++ b/core/domain/taskqueue_services_test.py @@ -271,6 +271,50 @@ def test_should_fetch_cloud_task_run_model(self) -> None: ) self.assertIsNone(cloud_task_run) + def test_should_fetch_cloud_task_run_models(self) -> None: + model_id_1 = cloud_task_models.CloudTaskRunModel.get_new_id() + model_id_2 = cloud_task_models.CloudTaskRunModel.get_new_id() + project_id = 'dev-project-id' + location_id = 'us-central' + task_id_1 = uuid.uuid4().hex + task_id_2 = uuid.uuid4().hex + queue_name = 'test_queue_name' + + task_name_1 = 'projects/%s/locations/%s/queues/%s/tasks/%s' % ( + project_id, + location_id, + queue_name, + task_id_1, + ) + task_name_2 = 'projects/%s/locations/%s/queues/%s/tasks/%s' % ( + project_id, + location_id, + queue_name, + task_id_2, + ) + function_id = 'regenerate_voiceovers_for_batch_contents' + + taskqueue_services.create_new_cloud_task_model( + model_id_1, task_name_1, function_id + ) + taskqueue_services.create_new_cloud_task_model( + model_id_2, task_name_2, function_id + ) + + cloud_task_run = taskqueue_services.get_cloud_task_runs_by_model_ids( + [model_id_1, model_id_2] + ) + self.assertIsNotNone(cloud_task_run) + + fetched_model_ids = [] + fetched_task_names = [] + for task_run in cloud_task_run: + fetched_model_ids.append(task_run.task_run_id) + fetched_task_names.append(task_run.cloud_task_name) + + self.assertListEqual(fetched_model_ids, [model_id_1, model_id_2]) + self.assertListEqual(fetched_task_names, [task_name_1, task_name_2]) + def test_should_get_cloud_task_run_models_by_params(self) -> None: new_model_id = cloud_task_models.CloudTaskRunModel.get_new_id() project_id = 'dev-project-id' @@ -284,7 +328,7 @@ def test_should_get_cloud_task_run_models_by_params(self) -> None: queue_name, task_id, ) - function_id = 'delete_exps_from_user_models' + function_id = 'regenerate_voiceovers_after_accepting_suggestion' taskqueue_services.create_new_cloud_task_model( new_model_id, task_name, function_id @@ -308,3 +352,55 @@ def test_should_get_cloud_task_run_models_by_params(self) -> None: self.assertEqual(cloud_task_run.cloud_task_name, task_name) self.assertEqual(cloud_task_run.function_id, function_id) + + def test_voiceover_defer_makes_the_correct_request(self) -> None: + correct_fn_identifier = 'regenerate_voiceovers_for_batch_contents' + correct_args = (1, 2, 3) + correct_kwargs = {'a': 'b', 'c': 'd'} + parent_cloud_task_run_id = 'parent_cloud_task_run_id' + child_cloud_task_run_id = 'child_cloud_task_run_id' + + taskqueue_services.defer_voiceover_regeneration_task_in_batches( + correct_fn_identifier, + taskqueue_services.QUEUE_NAME_VOICEOVER_REGENERATION, + parent_cloud_task_run_id, + child_cloud_task_run_id, + *correct_args, + **correct_kwargs, + ) + + cloud_task_run: cloud_task_domain.CloudTaskRun = ( + taskqueue_services.get_all_cloud_task_runs() + )[0] + assert cloud_task_run is not None + self.assertEqual(cloud_task_run.function_id, correct_fn_identifier) + + def test_exception_raised_when_voiceover_deferred_payload_is_not_serializable( + self, + ) -> None: + class NonSerializableArgs: + """Object that is not JSON serializable.""" + + def __init__(self) -> None: + self.x = 1 + self.y = 2 + + arg1 = NonSerializableArgs() + serialization_exception = self.assertRaisesRegex( + ValueError, + 'The args or kwargs passed to the deferred call with ' + 'function_identifier, %s, are not json serializable.' + % feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[ + 'FUNCTION_ID_REGENERATE_VOICEOVERS_FOR_BATCH_CONTENTS' + ], + ) + with serialization_exception: + taskqueue_services.defer_voiceover_regeneration_task_in_batches( + feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[ + 'FUNCTION_ID_REGENERATE_VOICEOVERS_FOR_BATCH_CONTENTS' + ], + taskqueue_services.QUEUE_NAME_VOICEOVER_REGENERATION, + 'parent_cloud_task_run_id', + 'child_cloud_task_run_id', + arg1, + ) diff --git a/core/domain/topic_services.py b/core/domain/topic_services.py index 6096083e5e144..3d41dd8afa840 100644 --- a/core/domain/topic_services.py +++ b/core/domain/topic_services.py @@ -175,6 +175,51 @@ def save_new_topic(committer_id: str, topic: topic_domain.Topic) -> None: ) +def _collect_study_guide_changes( + change: change_domain.BaseChange, + topic: topic_domain.Topic, + topic_id: str, + existing_study_guide_ids_to_be_modified: List[int], + modified_study_guide_change_cmds: Dict[ + str, List[study_guide_domain.StudyGuideChange] + ], +) -> None: + """Collects study guide preprocessing changes from a change command. + + Args: + change: BaseChange. Incoming change command with study guide updates. + topic: Topic. The topic object being updated. + topic_id: str. ID of the topic. + existing_study_guide_ids_to_be_modified: list(int). List tracking ids + of study guides to be fetched and modified. + modified_study_guide_change_cmds: dict(str, list(StudyGuideChange)). + Study guide change commands grouped by study guide id. + """ + # Remove union and StudyGuideChange once the study + # guide logic when updating a subtopic page is + # removed from line 363. + update_study_guide_property_cmd: Union[ + study_guide_domain.UpdateStudyGuidePropertyCmd, + study_guide_domain.StudyGuideChange, + ] + # Here we use cast because we are narrowing down the type from + # TopicChange to a specific change command. + update_study_guide_property_cmd = cast( + study_guide_domain.UpdateStudyGuidePropertyCmd, change + ) + + if update_study_guide_property_cmd.subtopic_id < topic.next_subtopic_id: + existing_study_guide_ids_to_be_modified.append( + update_study_guide_property_cmd.subtopic_id + ) + study_guide_id = study_guide_domain.StudyGuide.get_study_guide_id( + topic_id, update_study_guide_property_cmd.subtopic_id + ) + modified_study_guide_change_cmds[study_guide_id].append( + update_study_guide_property_cmd + ) + + def apply_change_list( topic_id: str, change_list: Sequence[change_domain.BaseChange], @@ -268,34 +313,13 @@ def _ensure_study_guide_exists(subtopic_id: int) -> Optional[str]: for change in change_list: if change.cmd == study_guide_domain.CMD_UPDATE_STUDY_GUIDE_PROPERTY: - # Remove union and StudyGuideChange once the study - # guide logic when updating a subtopic page is - # removed from line 337. - update_study_guide_property_cmd: Union[ - study_guide_domain.UpdateStudyGuidePropertyCmd, - study_guide_domain.StudyGuideChange, - ] - # Here we use cast because we are narrowing down the type from - # TopicChange to a specific change command. - update_study_guide_property_cmd = cast( - study_guide_domain.UpdateStudyGuidePropertyCmd, change + _collect_study_guide_changes( + change, + topic, + topic_id, + existing_study_guide_ids_to_be_modified, + modified_study_guide_change_cmds, ) - - if ( - update_study_guide_property_cmd.subtopic_id - < topic.next_subtopic_id - ): - existing_study_guide_ids_to_be_modified.append( - update_study_guide_property_cmd.subtopic_id - ) - study_guide_id = ( - study_guide_domain.StudyGuide.get_study_guide_id( - topic_id, update_study_guide_property_cmd.subtopic_id - ) - ) - modified_study_guide_change_cmds[study_guide_id].append( - update_study_guide_property_cmd - ) # Remove this entire if block once study guides become standard. if change.cmd == subtopic_page_domain.CMD_UPDATE_SUBTOPIC_PAGE_PROPERTY: # Here we use cast because we are narrowing down the type from diff --git a/core/domain/translation_domain.py b/core/domain/translation_domain.py index 9acf0f4089418..a2c7a8998bc71 100644 --- a/core/domain/translation_domain.py +++ b/core/domain/translation_domain.py @@ -441,6 +441,26 @@ def get_content_count(self) -> int: """ return len(self.get_all_contents_which_need_translations()) + def get_reviewer_only_content_count(self) -> int: + """Returns the total number of content items in the exploration that + are only translatable by reviewers (e.g. content with + 'set_of_strings' data format). + + Returns: + int. The number of reviewer-only content items. + """ + count = 0 + content_id_to_translatable_content = ( + self.get_translatable_contents_collection().content_id_to_translatable_content + ) + for ( + _, + translatable_content, + ) in content_id_to_translatable_content.items(): + if translatable_content.is_data_format_list(): + count += 1 + return count + def get_all_html_content_strings(self) -> List[str]: """Gets all html content strings used in the object. diff --git a/core/domain/translation_fetchers.py b/core/domain/translation_fetchers.py index 07f2402eb19ae..6fc800e578c6f 100644 --- a/core/domain/translation_fetchers.py +++ b/core/domain/translation_fetchers.py @@ -83,7 +83,7 @@ def get_machine_translation( return get_translation_from_model(translation_model) -def _get_entity_translation_from_model( +def get_entity_translation_from_model( entity_translation_model: translation_models.EntityTranslationsModel, ) -> translation_domain.EntityTranslation: """Returns the EntityTranslation domain object from its model representation @@ -134,7 +134,7 @@ def get_all_entity_translations_for_entity( ) entity_translation_objects = [] for model in entity_translation_models: - domain_object = _get_entity_translation_from_model(model) + domain_object = get_entity_translation_from_model(model) entity_translation_objects.append(domain_object) return entity_translation_objects @@ -164,7 +164,7 @@ def get_entity_translation( ) if entity_translation_model: - domain_object = _get_entity_translation_from_model( + domain_object = get_entity_translation_from_model( entity_translation_model ) return domain_object @@ -194,7 +194,7 @@ def get_multiple_entity_translations( ) return [ ( - _get_entity_translation_from_model(entity_translation_model) + get_entity_translation_from_model(entity_translation_model) if entity_translation_model is not None else None ) diff --git a/core/domain/voiceover_cloud_task_services.py b/core/domain/voiceover_cloud_task_services.py index b6e778c19647c..af816464e5759 100644 --- a/core/domain/voiceover_cloud_task_services.py +++ b/core/domain/voiceover_cloud_task_services.py @@ -35,10 +35,10 @@ (cloud_task_models,) = models.Registry.import_models([models.Names.CLOUD_TASK]) -def get_voiceover_regeneration_task( +def get_voiceover_regeneration_job( exploration_id: str, cloud_task_run_id: str -) -> Optional[cloud_task_domain.VoiceoverRegenerationTaskMapping]: - """Returns the VoiceoverRegenerationTaskMapping instance for the given +) -> Optional[cloud_task_domain.VoiceoverRegenerationJob]: + """Returns the VoiceoverRegenerationJob instance for the given exploration id and cloud task run id. Args: @@ -46,35 +46,35 @@ def get_voiceover_regeneration_task( cloud_task_run_id: str. The id of the cloud task run. Returns: - VoiceoverRegenerationTaskMapping|None. The - VoiceoverRegenerationTaskMapping instance for the given exploration id + VoiceoverRegenerationJob|None. The + VoiceoverRegenerationJob instance for the given exploration id and cloud task run id. """ - voiceover_regeneration_task_id = '%s:%s' % ( + voiceover_regeneration_job_id = '%s:%s' % ( exploration_id, cloud_task_run_id, ) - voiceover_regeneration_task_run_model = ( - cloud_task_models.VoiceoverRegenerationTaskMappingModel.get( - voiceover_regeneration_task_id, strict=False + voiceover_regeneration_job_model = ( + cloud_task_models.VoiceoverRegenerationJobModel.get( + voiceover_regeneration_job_id, strict=False ) ) - if voiceover_regeneration_task_run_model is None: + if voiceover_regeneration_job_model is None: return None - return cloud_task_domain.VoiceoverRegenerationTaskMapping( - voiceover_regeneration_task_run_model.exploration_id, - voiceover_regeneration_task_run_model.cloud_task_run_id, - voiceover_regeneration_task_run_model.language_accent_to_content_status_map, + return cloud_task_domain.VoiceoverRegenerationJob( + voiceover_regeneration_job_model.exploration_id, + voiceover_regeneration_job_model.cloud_task_run_id, + voiceover_regeneration_job_model.language_accent_to_content_status_map, ) def get_existing_voiceover_regeneration_requests_in_task_queue( exploration_id: str, ) -> Dict[str, Dict[str, Dict[str, str]]]: - """Returns the existing voiceover regeneration cloud task run requests for - the given exploration ID. + """Returns the existing voiceover regeneration jobs for the given + exploration ID. Args: exploration_id: str. The id of the exploration. @@ -85,15 +85,15 @@ def get_existing_voiceover_regeneration_requests_in_task_queue( """ # Getting all the existing voiceover regeneration requests for the given # exploration ID. - voiceover_regeneration_task_request_models: List[ - cloud_task_models.VoiceoverRegenerationTaskMappingModel - ] = cloud_task_models.VoiceoverRegenerationTaskMappingModel.get_voiceover_regeneration_tasks_by_exploration_id( + voiceover_regeneration_job_models: List[ + cloud_task_models.VoiceoverRegenerationJobModel + ] = cloud_task_models.VoiceoverRegenerationJobModel.get_all_by_exp_id( exploration_id ) # Here we use cast because we are narrowing down the type from # Optional[datetime.datetime] to datetime.datetime for sorting purposes. - voiceover_regeneration_task_request_models.sort( + voiceover_regeneration_job_models.sort( key=lambda model: cast(datetime.datetime, model.created_on) ) @@ -102,16 +102,14 @@ def get_existing_voiceover_regeneration_requests_in_task_queue( # dictionary containing the latest status data. language_accent_to_content_status_map = ( resolve_multiple_cloud_task_runs_for_exploration( - voiceover_regeneration_task_request_models + voiceover_regeneration_job_models ) ) voiceover_regeneration_task_models_to_delete = [] - for ( - voiceover_regeneration_task_model - ) in voiceover_regeneration_task_request_models: - voiceover_regeneration_task = cloud_task_domain.VoiceoverRegenerationTaskMapping( + for voiceover_regeneration_task_model in voiceover_regeneration_job_models: + voiceover_regeneration_task = cloud_task_domain.VoiceoverRegenerationJob( voiceover_regeneration_task_model.exploration_id, voiceover_regeneration_task_model.cloud_task_run_id, voiceover_regeneration_task_model.language_accent_to_content_status_map, @@ -122,9 +120,9 @@ def get_existing_voiceover_regeneration_requests_in_task_queue( voiceover_regeneration_task_model ) - # Deleting the voiceover regeneration task run mapping if all - # voiceovers have been generated successfully. - cloud_task_models.VoiceoverRegenerationTaskMappingModel.delete_multi( + # Deleting the voiceover regeneration jobs if all voiceovers have been + # generated successfully. + cloud_task_models.VoiceoverRegenerationJobModel.delete_multi( voiceover_regeneration_task_models_to_delete ) @@ -135,11 +133,11 @@ def get_existing_voiceover_regeneration_requests_in_task_queue( } -def delete_voiceover_regeneration_task_run_mapping( +def delete_voiceover_regeneration_job( exploration_id: str, cloud_task_run_id: str, ) -> None: - """Deletes the VoiceoverRegenerationTaskMappingModel entry for the given + """Deletes the VoiceoverRegenerationJobModel entry for the given cloud task run id. Args: @@ -147,19 +145,17 @@ def delete_voiceover_regeneration_task_run_mapping( cloud_task_run_id: str. The id of the cloud task run. """ model_id = '%s:%s' % (exploration_id, cloud_task_run_id) - cloud_task_models.VoiceoverRegenerationTaskMappingModel.delete_by_id( - model_id - ) + cloud_task_models.VoiceoverRegenerationJobModel.delete_by_id(model_id) -def update_voiceover_regeneration_task_run_mapping_for_content( +def update_voiceover_regeneration_job_status( exploration_id: str, language_accent_code: str, content_id: str, regeneration_status: str, ) -> None: """Updates the regeneration status of a specific content in all existing - voiceover regeneration task run mappings for the given exploration ID. + voiceover regeneration job models for the given exploration ID. Args: exploration_id: str. The id of the exploration. @@ -169,38 +165,40 @@ def update_voiceover_regeneration_task_run_mapping_for_content( regeneration_status: str. The new regeneration status to be set for the specified content. """ - voiceover_regeneration_task_requests = cloud_task_models.VoiceoverRegenerationTaskMappingModel.get_voiceover_regeneration_tasks_by_exploration_id( - exploration_id + voiceover_regeneration_job_models = ( + cloud_task_models.VoiceoverRegenerationJobModel.get_all_by_exp_id( + exploration_id + ) ) - for task_mapping_model in voiceover_regeneration_task_requests: + for model_instance in voiceover_regeneration_job_models: if ( language_accent_code - in task_mapping_model.language_accent_to_content_status_map + in model_instance.language_accent_to_content_status_map and content_id - in task_mapping_model.language_accent_to_content_status_map[ + in model_instance.language_accent_to_content_status_map[ language_accent_code ] ): - task_mapping_model.language_accent_to_content_status_map[ + model_instance.language_accent_to_content_status_map[ language_accent_code ][content_id] = regeneration_status - task_mapping_model.update_timestamps() - task_mapping_model.put() + model_instance.update_timestamps() + model_instance.put() def resolve_multiple_cloud_task_runs_for_exploration( - voiceover_regeneration_task_request_models: List[ - cloud_task_models.VoiceoverRegenerationTaskMappingModel + voiceover_regeneration_job_models: List[ + cloud_task_models.VoiceoverRegenerationJobModel ], ) -> Dict[str, Dict[str, str]]: """Resolves multiple voiceover regeneration cloud task run requests for the same exploration by merging their content status. Args: - voiceover_regeneration_task_request_models: list( - VoiceoverRegenerationTaskMappingModel). A list of - VoiceoverRegenerationTaskMappingModel instances. + voiceover_regeneration_job_models: list( + VoiceoverRegenerationJobModel). A list of + VoiceoverRegenerationJobModel instances. Returns: dict. A mapping of language accents to their content regeneration @@ -210,23 +208,22 @@ def resolve_multiple_cloud_task_runs_for_exploration( str, Dict[str, str] ] = collections.defaultdict(dict) - number_of_models = len(voiceover_regeneration_task_request_models) + number_of_models = len(voiceover_regeneration_job_models) if number_of_models == 0: return {} if number_of_models == 1: language_accent_to_content_status_map: Dict[str, Dict[str, str]] = ( - voiceover_regeneration_task_request_models[ + voiceover_regeneration_job_models[ 0 ].language_accent_to_content_status_map ) return language_accent_to_content_status_map - # Number of models is more than 1. for index in range(number_of_models - 1): - earlier_model = voiceover_regeneration_task_request_models[index] - later_model = voiceover_regeneration_task_request_models[index + 1] + earlier_model = voiceover_regeneration_job_models[index] + later_model = voiceover_regeneration_job_models[index + 1] earlier_language_accent_to_content_status_map = ( earlier_model.language_accent_to_content_status_map @@ -292,7 +289,7 @@ def resolve_multiple_cloud_task_runs_for_exploration( ][content_id] later_language_accent_to_content_status_map = ( - voiceover_regeneration_task_request_models[ + voiceover_regeneration_job_models[ -1 ].language_accent_to_content_status_map ) @@ -325,42 +322,42 @@ def resolve_multiple_cloud_task_runs_for_exploration( return reference_language_accent_to_content_status_map -def save_voiceover_regeneration_task_run_mapping( - voiceover_regeneration_task: cloud_task_domain.VoiceoverRegenerationTaskMapping, +def save_voiceover_regeneration_job( + voiceover_regeneration_job: cloud_task_domain.VoiceoverRegenerationJob, ) -> None: - """Saves the VoiceoverRegenerationTaskMapping object to the datastore. + """Saves the VoiceoverRegenerationJob object to the datastore. Args: - voiceover_regeneration_task: VoiceoverRegenerationTaskMapping. The - VoiceoverRegenerationTaskMapping domain object to be saved. + voiceover_regeneration_job: VoiceoverRegenerationJob. The + VoiceoverRegenerationJob domain object to be saved. """ - voiceover_regeneration_task_model_id = '%s:%s' % ( - voiceover_regeneration_task.exploration_id, - voiceover_regeneration_task.task_run_id, + voiceover_regeneration_job_model_id = '%s:%s' % ( + voiceover_regeneration_job.exploration_id, + voiceover_regeneration_job.task_run_id, ) - voiceover_regeneration_task_model = ( - cloud_task_models.VoiceoverRegenerationTaskMappingModel.get( - voiceover_regeneration_task_model_id, strict=False + voiceover_regeneration_job_model = ( + cloud_task_models.VoiceoverRegenerationJobModel.get( + voiceover_regeneration_job_model_id, strict=False ) ) - if voiceover_regeneration_task_model is None: - voiceover_regeneration_task_model = ( - cloud_task_models.VoiceoverRegenerationTaskMappingModel( - id=voiceover_regeneration_task_model_id, - exploration_id=voiceover_regeneration_task.exploration_id, - cloud_task_run_id=voiceover_regeneration_task.task_run_id, + if voiceover_regeneration_job_model is None: + voiceover_regeneration_job_model = ( + cloud_task_models.VoiceoverRegenerationJobModel( + id=voiceover_regeneration_job_model_id, + exploration_id=voiceover_regeneration_job.exploration_id, + cloud_task_run_id=voiceover_regeneration_job.task_run_id, ) ) - voiceover_regeneration_task_model.language_accent_to_content_status_map = ( - voiceover_regeneration_task.language_accent_to_content_status_map + voiceover_regeneration_job_model.language_accent_to_content_status_map = ( + voiceover_regeneration_job.language_accent_to_content_status_map ) - voiceover_regeneration_task_model.update_timestamps() - voiceover_regeneration_task_model.put() + voiceover_regeneration_job_model.update_timestamps() + voiceover_regeneration_job_model.put() -def is_voiceover_regeneration_task_function(function_id: str) -> bool: +def is_voiceover_regeneration_defer_function(function_id: str) -> bool: """Returns whether the given function ID corresponds to a voiceover regeneration task. @@ -378,6 +375,15 @@ def is_voiceover_regeneration_task_function(function_id: str) -> bool: feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[ 'FUNCTION_ID_REGENERATE_VOICEOVERS_ON_EXP_UPDATE' ], + feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[ + 'FUNCTION_ID_REGENERATE_VOICEOVERS_BY_LANGUAGE_ACCENT' + ], + feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[ + 'FUNCTION_ID_REGENERATE_VOICEOVERS_AFTER_ACCEPTING_SUGGESTION' + ], + feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[ + 'FUNCTION_ID_REGENERATE_VOICEOVERS_FOR_BATCH_CONTENTS' + ], ] @@ -386,8 +392,8 @@ def create_voiceover_regeneration_task_with_status_generating( task_run_id: str, language_code_to_contents_mapping: Dict[str, Dict[str, str]], language_code_to_autogeneratable_accent_codes: Dict[str, List[str]], -) -> cloud_task_domain.VoiceoverRegenerationTaskMapping: - """Creates a VoiceoverRegenerationTaskMapping object with all contents set +) -> cloud_task_domain.VoiceoverRegenerationJob: + """Creates a VoiceoverRegenerationJob object with all contents set to 'GENERATING' status. Args: @@ -403,8 +409,8 @@ def create_voiceover_regeneration_task_with_status_generating( autogeneration. Returns: - VoiceoverRegenerationTaskMapping. An instance of - VoiceoverRegenerationTaskMapping with all contents set to + VoiceoverRegenerationJob. An instance of + VoiceoverRegenerationJob with all contents set to 'GENERATING' status. """ language_accent_to_content_status_map = {} @@ -421,12 +427,210 @@ def create_voiceover_regeneration_task_with_status_generating( for content_id in content_ids_to_content_values.keys() } - voiceover_regeneration_task_map = cloud_task_domain.VoiceoverRegenerationTaskMapping.create_default_voiceover_regeneration_task_mapping( - exploration_id, task_run_id + voiceover_regeneration_job = ( + cloud_task_domain.VoiceoverRegenerationJob.create_default( + exploration_id, task_run_id + ) ) - voiceover_regeneration_task_map.language_accent_to_content_status_map = ( + voiceover_regeneration_job.language_accent_to_content_status_map = ( language_accent_to_content_status_map ) - return voiceover_regeneration_task_map + return voiceover_regeneration_job + + +def create_voiceover_regeneration_task_batch_model( + voiceover_regeneration_task_batch: cloud_task_domain.VoiceoverRegenerationTaskBatch, +) -> cloud_task_models.VoiceoverRegenerationBatchExecutionModel: + """Creates a new instance of VoiceoverRegenerationBatchExecutionModel with the + given parent and child Cloud Task run IDs and exploration ID. + + Args: + voiceover_regeneration_task_batch: VoiceoverRegenerationTaskBatch. The + domain object containing the details of the voiceover regeneration + task batch for which the model instance needs to be created. + + Returns: + VoiceoverRegenerationBatchExecutionModel. The newly created instance of + VoiceoverRegenerationBatchExecutionModel. + """ + return cloud_task_models.VoiceoverRegenerationBatchExecutionModel.create_and_save_model( + voiceover_regeneration_task_batch.parent_cloud_task_run_id, + voiceover_regeneration_task_batch.child_cloud_task_run_id, + voiceover_regeneration_task_batch.exploration_id, + voiceover_regeneration_task_batch.exploration_version, + voiceover_regeneration_task_batch.language_accent_code, + voiceover_regeneration_task_batch.content_ids_to_contents_map, + ) + + +def create_voiceover_regeneration_task_batch_models( + voiceover_regeneration_task_batch_instances: List[ + cloud_task_domain.VoiceoverRegenerationTaskBatch + ], +) -> None: + """Creates new instances of VoiceoverRegenerationBatchExecutionModel for the + given list of VoiceoverRegenerationTaskBatch domain objects. + + Args: + voiceover_regeneration_task_batch_instances: list( + VoiceoverRegenerationTaskBatch). The domain objects containing the + details of the voiceover regeneration task batches for which the + model instances need to be created. + """ + model_instances = [] + for ( + voiceover_regeneration_task_batch + ) in voiceover_regeneration_task_batch_instances: + + model_id = '%s:%s' % ( + voiceover_regeneration_task_batch.parent_cloud_task_run_id, + voiceover_regeneration_task_batch.child_cloud_task_run_id, + ) + model_instance = cloud_task_models.VoiceoverRegenerationBatchExecutionModel( + id=model_id, + parent_cloud_task_run_id=voiceover_regeneration_task_batch.parent_cloud_task_run_id, + child_cloud_task_run_id=voiceover_regeneration_task_batch.child_cloud_task_run_id, + exploration_id=voiceover_regeneration_task_batch.exploration_id, + exploration_version=voiceover_regeneration_task_batch.exploration_version, + language_accent_code=voiceover_regeneration_task_batch.language_accent_code, + content_ids_to_contents_map=voiceover_regeneration_task_batch.content_ids_to_contents_map, + ) + + model_instances.append(model_instance) + + cloud_task_models.VoiceoverRegenerationBatchExecutionModel.put_multi( + model_instances + ) + + +def get_voiceover_regeneration_batch_instances_by_parent_task_run_id( + parent_cloud_task_run_id: str, +) -> List[cloud_task_domain.VoiceoverRegenerationTaskBatch]: + """Returns the list of VoiceoverRegenerationTaskBatch instances corresponding + to the given parent Cloud Task run ID. + + Args: + parent_cloud_task_run_id: str. The Cloud Task run ID of the parent task. + + Returns: + list(VoiceoverRegenerationTaskBatch). The list of + VoiceoverRegenerationTaskBatch instances corresponding to the given + parent Cloud Task run ID. + """ + model_instances = cloud_task_models.VoiceoverRegenerationBatchExecutionModel.get_models_by_parent_id( + parent_cloud_task_run_id + ) + + domain_instances = [] + for model_instance in model_instances: + domain_instance = ( + convert_voiceover_regeneration_task_batch_model_to_domain_instance( + model_instance + ) + ) + domain_instances.append(domain_instance) + + return domain_instances + + +def get_voiceover_regeneration_task_batch_model( + parent_cloud_task_run_id: str, child_cloud_task_run_id: str +) -> Optional[cloud_task_domain.VoiceoverRegenerationTaskBatch]: + """Returns the instance of VoiceoverRegenerationBatchExecutionModel corresponding + to the given parent and child Cloud Task run IDs. + + Args: + parent_cloud_task_run_id: str. The Cloud Task run ID of the parent task. + child_cloud_task_run_id: str. The Cloud Task run ID of the child task. + + Returns: + VoiceoverRegenerationTaskBatch|None. The instance of + VoiceoverRegenerationTaskBatch corresponding to the given parent and + child Cloud Task run IDs, or None if no such model exists. + """ + model_id = '%s:%s' % (parent_cloud_task_run_id, child_cloud_task_run_id) + model_instance = ( + cloud_task_models.VoiceoverRegenerationBatchExecutionModel.get( + model_id, strict=False + ) + ) + + if model_instance is None: + return None + + return convert_voiceover_regeneration_task_batch_model_to_domain_instance( + model_instance + ) + + +def convert_voiceover_regeneration_task_batch_model_to_domain_instance( + model_instance: cloud_task_models.VoiceoverRegenerationBatchExecutionModel, +) -> cloud_task_domain.VoiceoverRegenerationTaskBatch: + """Converts the given instance of VoiceoverRegenerationBatchExecutionModel to its + corresponding domain object. + + Args: + model_instance: VoiceoverRegenerationBatchExecutionModel. The instance of + VoiceoverRegenerationBatchExecutionModel to be converted. + + Returns: + VoiceoverRegenerationTaskBatch. The corresponding domain object for the + given instance of VoiceoverRegenerationBatchExecutionModel. + """ + return cloud_task_domain.VoiceoverRegenerationTaskBatch( + model_instance.parent_cloud_task_run_id, + model_instance.child_cloud_task_run_id, + model_instance.exploration_id, + model_instance.exploration_version, + model_instance.language_accent_code, + model_instance.content_ids_to_contents_map, + ) + + +def create_or_update_voiceover_regeneration_task_batch_model( + domain_instance: cloud_task_domain.VoiceoverRegenerationTaskBatch, +) -> None: + """Creates or updates the instance of VoiceoverRegenerationBatchExecutionModel + corresponding to the given domain object. + + Args: + domain_instance: VoiceoverRegenerationTaskBatch. The instance of + VoiceoverRegenerationTaskBatch to be converted. + """ + model_id = '%s:%s' % ( + domain_instance.parent_cloud_task_run_id, + domain_instance.child_cloud_task_run_id, + ) + model_instance = ( + cloud_task_models.VoiceoverRegenerationBatchExecutionModel.get( + model_id, strict=False + ) + ) + + if model_instance is None: + cloud_task_models.VoiceoverRegenerationBatchExecutionModel.create_and_save_model( + parent_cloud_task_run_id=domain_instance.parent_cloud_task_run_id, + child_cloud_task_run_id=domain_instance.child_cloud_task_run_id, + exploration_id=domain_instance.exploration_id, + exploration_version=domain_instance.exploration_version, + language_accent_code=domain_instance.language_accent_code, + content_ids_to_contents_map=domain_instance.content_ids_to_contents_map, + ) + return + + model_instance.parent_cloud_task_run_id = ( + domain_instance.parent_cloud_task_run_id + ) + model_instance.child_cloud_task_run_id = ( + domain_instance.child_cloud_task_run_id + ) + model_instance.exploration_id = domain_instance.exploration_id + model_instance.exploration_version = domain_instance.exploration_version + model_instance.language_accent_code = domain_instance.language_accent_code + model_instance.content_ids_to_contents_map = ( + domain_instance.content_ids_to_contents_map + ) + model_instance.update_timestamps() + model_instance.put() diff --git a/core/domain/voiceover_cloud_task_services_test.py b/core/domain/voiceover_cloud_task_services_test.py index 9653b84dc0c9a..aa5f4856bfbe7 100644 --- a/core/domain/voiceover_cloud_task_services_test.py +++ b/core/domain/voiceover_cloud_task_services_test.py @@ -33,21 +33,21 @@ class CloudTaskServicesTests(test_utils.GenericTestBase): """Unit tests for voiceover cloud task service functionalities.""" - def test_should_get_voiceover_regeneration_task(self) -> None: + def test_should_get_voiceover_regeneration_job(self) -> None: task_run_id = 'task_run_id' exploration_id = 'exploration_id' voiceover_regeneration_task_mapping = ( - cloud_task_domain.VoiceoverRegenerationTaskMapping( + cloud_task_domain.VoiceoverRegenerationJob( exploration_id, task_run_id, {} ) ) - voiceover_cloud_task_services.save_voiceover_regeneration_task_run_mapping( + voiceover_cloud_task_services.save_voiceover_regeneration_job( voiceover_regeneration_task_mapping ) retrieved_task = ( - voiceover_cloud_task_services.get_voiceover_regeneration_task( + voiceover_cloud_task_services.get_voiceover_regeneration_job( exploration_id, task_run_id ) ) @@ -68,7 +68,7 @@ def test_should_get_voiceover_regeneration_task(self) -> None: voiceover_regeneration_task_mapping.language_accent_to_content_status_map, ) - def test_should_get_voiceover_regeneration_tasks_by_exploration_id( + def test_should_get_voiceover_regeneration_job_models_by_exploration_id( self, ) -> None: task_run_id = 'task_run_id' @@ -77,14 +77,14 @@ def test_should_get_voiceover_regeneration_tasks_by_exploration_id( 'en-US': {'content_0': 'SUCCEEDED', 'content_1': 'SUCCEEDED'} } voiceover_regeneration_task_mapping = ( - cloud_task_domain.VoiceoverRegenerationTaskMapping( + cloud_task_domain.VoiceoverRegenerationJob( exploration_id, task_run_id, language_accent_to_content_status_map, ) ) - voiceover_cloud_task_services.save_voiceover_regeneration_task_run_mapping( + voiceover_cloud_task_services.save_voiceover_regeneration_job( voiceover_regeneration_task_mapping ) @@ -99,7 +99,7 @@ def test_should_get_voiceover_regeneration_tasks_by_exploration_id( language_accent_to_content_status_map, ) - def test_should_update_voiceover_regeneration_task_run_mapping_for_content( + def test_should_update_voiceover_regeneration_job_status( self, ) -> None: task_run_id = 'task_run_id' @@ -108,14 +108,14 @@ def test_should_update_voiceover_regeneration_task_run_mapping_for_content( 'en-US': {'content_0': 'GENERATING', 'content_1': 'SUCCEEDED'} } voiceover_regeneration_task_mapping = ( - cloud_task_domain.VoiceoverRegenerationTaskMapping( + cloud_task_domain.VoiceoverRegenerationJob( exploration_id, task_run_id, initial_language_accent_to_content_status_map, ) ) - voiceover_cloud_task_services.save_voiceover_regeneration_task_run_mapping( + voiceover_cloud_task_services.save_voiceover_regeneration_job( voiceover_regeneration_task_mapping ) @@ -123,12 +123,12 @@ def test_should_update_voiceover_regeneration_task_run_mapping_for_content( 'en-US': {'content_0': 'SUCCEEDED', 'content_1': 'SUCCEEDED'} } - voiceover_cloud_task_services.update_voiceover_regeneration_task_run_mapping_for_content( + voiceover_cloud_task_services.update_voiceover_regeneration_job_status( exploration_id, 'en-US', 'content_0', 'SUCCEEDED' ) retrieved_task = ( - voiceover_cloud_task_services.get_voiceover_regeneration_task( + voiceover_cloud_task_services.get_voiceover_regeneration_job( exploration_id, task_run_id ) ) @@ -140,7 +140,7 @@ def test_should_update_voiceover_regeneration_task_run_mapping_for_content( updated_language_accent_to_content_status_map, ) - def test_should_able_to_delete_voiceover_regeneration_task_run_mapping( + def test_should_able_to_delete_voiceover_regeneration_job_model( self, ) -> None: task_run_id = 'task_run_id' @@ -149,23 +149,23 @@ def test_should_able_to_delete_voiceover_regeneration_task_run_mapping( 'en-US': {'content_0': 'GENERATING', 'content_1': 'SUCCEEDED'} } voiceover_regeneration_task_mapping = ( - cloud_task_domain.VoiceoverRegenerationTaskMapping( + cloud_task_domain.VoiceoverRegenerationJob( exploration_id, task_run_id, initial_language_accent_to_content_status_map, ) ) - voiceover_cloud_task_services.save_voiceover_regeneration_task_run_mapping( + voiceover_cloud_task_services.save_voiceover_regeneration_job( voiceover_regeneration_task_mapping ) - voiceover_cloud_task_services.delete_voiceover_regeneration_task_run_mapping( + voiceover_cloud_task_services.delete_voiceover_regeneration_job( exploration_id, task_run_id ) self.assertIsNone( - voiceover_cloud_task_services.get_voiceover_regeneration_task( + voiceover_cloud_task_services.get_voiceover_regeneration_job( exploration_id, task_run_id ) ) @@ -197,7 +197,7 @@ def test_should_resolve_multiple_voiceover_regeneration_tasks(self) -> None: }, } voiceover_regeneration_task_mapping_1 = ( - cloud_task_models.VoiceoverRegenerationTaskMappingModel( + cloud_task_models.VoiceoverRegenerationJobModel( exploration_id=exploration_id, cloud_task_run_id=task_run_id_1, language_accent_to_content_status_map=( @@ -207,7 +207,7 @@ def test_should_resolve_multiple_voiceover_regeneration_tasks(self) -> None: ) voiceover_regeneration_task_mapping_2 = ( - cloud_task_models.VoiceoverRegenerationTaskMappingModel( + cloud_task_models.VoiceoverRegenerationJobModel( exploration_id=exploration_id, cloud_task_run_id=task_run_id_2, language_accent_to_content_status_map=( @@ -258,7 +258,7 @@ def test_verify_if_given_function_belongs_to_voiceover_regeneration_tasks( ] self.assertTrue( - voiceover_cloud_task_services.is_voiceover_regeneration_task_function( + voiceover_cloud_task_services.is_voiceover_regeneration_defer_function( function_name ) ) @@ -268,7 +268,7 @@ def test_verify_if_given_function_belongs_to_voiceover_regeneration_tasks( ] self.assertFalse( - voiceover_cloud_task_services.is_voiceover_regeneration_task_function( + voiceover_cloud_task_services.is_voiceover_regeneration_defer_function( function_name ) ) @@ -309,3 +309,341 @@ def test_should_create_voiceover_regeneration_task_with_status_generating( self.assertEqual( voiceover_cloud_task_run_mapping.task_run_id, cloud_task_id ) + + def test_should_create_voiceover_regeneration_task_batch_model( + self, + ) -> None: + parent_task_run_id = 'parent_task_run_id' + child_task_run_id = 'child_task_run_id' + exploration_id = 'exploration_id' + exploration_version = 1 + language_accent_code = 'en-US' + content_ids_to_contents_map = { + 'content_0': 'Hello world!', + 'content_1': 'First card.', + } + + voiceover_regeneration_task_batch = ( + cloud_task_domain.VoiceoverRegenerationTaskBatch( + parent_task_run_id, + child_task_run_id, + exploration_id, + exploration_version, + language_accent_code, + content_ids_to_contents_map, + ) + ) + + model_instance = voiceover_cloud_task_services.create_voiceover_regeneration_task_batch_model( + voiceover_regeneration_task_batch + ) + + self.assertEqual( + model_instance.parent_cloud_task_run_id, parent_task_run_id + ) + self.assertEqual( + model_instance.child_cloud_task_run_id, child_task_run_id + ) + self.assertEqual(model_instance.exploration_id, exploration_id) + self.assertEqual( + model_instance.exploration_version, exploration_version + ) + self.assertEqual( + model_instance.language_accent_code, language_accent_code + ) + self.assertEqual( + model_instance.content_ids_to_contents_map, + content_ids_to_contents_map, + ) + + def test_should_create_voiceover_regeneration_task_batch_models( + self, + ) -> None: + parent_task_run_id = 'parent_task_run_id' + exploration_id = 'exploration_id' + exploration_version = 1 + + batch_instances = [ + cloud_task_domain.VoiceoverRegenerationTaskBatch( + parent_task_run_id, + 'child_task_run_id_1', + exploration_id, + exploration_version, + 'en-US', + {'content_0': 'Hello world!', 'content_1': 'First card.'}, + ), + cloud_task_domain.VoiceoverRegenerationTaskBatch( + parent_task_run_id, + 'child_task_run_id_2', + exploration_id, + exploration_version, + 'hi-IN', + {'content_2': 'नमस्ते दुनिया!', 'content_3': 'पहला कार्ड.'}, + ), + ] + + voiceover_cloud_task_services.create_voiceover_regeneration_task_batch_models( + batch_instances + ) + + retrieved_batch_1 = voiceover_cloud_task_services.get_voiceover_regeneration_task_batch_model( + parent_task_run_id, 'child_task_run_id_1' + ) + self.assertIsNotNone(retrieved_batch_1) + assert retrieved_batch_1 is not None + self.assertEqual(retrieved_batch_1.language_accent_code, 'en-US') + + retrieved_batch_2 = voiceover_cloud_task_services.get_voiceover_regeneration_task_batch_model( + parent_task_run_id, 'child_task_run_id_2' + ) + self.assertIsNotNone(retrieved_batch_2) + assert retrieved_batch_2 is not None + self.assertEqual(retrieved_batch_2.language_accent_code, 'hi-IN') + + def test_should_get_voiceover_regeneration_batch_instances_by_parent_task_run_id( + self, + ) -> None: + parent_task_run_id = 'parent_task_run_id' + exploration_id = 'exploration_id' + exploration_version = 1 + + batch_instances = [ + cloud_task_domain.VoiceoverRegenerationTaskBatch( + parent_task_run_id, + 'child_task_run_id_1', + exploration_id, + exploration_version, + 'en-US', + {'content_0': 'Hello world!'}, + ), + cloud_task_domain.VoiceoverRegenerationTaskBatch( + parent_task_run_id, + 'child_task_run_id_2', + exploration_id, + exploration_version, + 'hi-IN', + {'content_1': 'नमस्ते दुनिया!'}, + ), + cloud_task_domain.VoiceoverRegenerationTaskBatch( + parent_task_run_id, + 'child_task_run_id_3', + exploration_id, + exploration_version, + 'es-ES', + {'content_2': '¡Hola mundo!'}, + ), + ] + + voiceover_cloud_task_services.create_voiceover_regeneration_task_batch_models( + batch_instances + ) + + retrieved_instances = voiceover_cloud_task_services.get_voiceover_regeneration_batch_instances_by_parent_task_run_id( + parent_task_run_id + ) + + self.assertEqual(len(retrieved_instances), 3) + self.assertEqual(retrieved_instances[0].language_accent_code, 'en-US') + self.assertEqual(retrieved_instances[1].language_accent_code, 'hi-IN') + self.assertEqual(retrieved_instances[2].language_accent_code, 'es-ES') + + def test_should_get_voiceover_regeneration_task_batch_model( + self, + ) -> None: + parent_task_run_id = 'parent_task_run_id' + child_task_run_id = 'child_task_run_id' + exploration_id = 'exploration_id' + exploration_version = 2 + language_accent_code = 'en-US' + content_ids_to_contents_map = { + 'content_0': 'Hello world!', + 'content_1': 'First card.', + } + + voiceover_regeneration_task_batch = ( + cloud_task_domain.VoiceoverRegenerationTaskBatch( + parent_task_run_id, + child_task_run_id, + exploration_id, + exploration_version, + language_accent_code, + content_ids_to_contents_map, + ) + ) + + voiceover_cloud_task_services.create_voiceover_regeneration_task_batch_model( + voiceover_regeneration_task_batch + ) + + retrieved_batch = voiceover_cloud_task_services.get_voiceover_regeneration_task_batch_model( + parent_task_run_id, child_task_run_id + ) + + self.assertIsNotNone(retrieved_batch) + assert retrieved_batch is not None + self.assertEqual( + retrieved_batch.parent_cloud_task_run_id, parent_task_run_id + ) + self.assertEqual( + retrieved_batch.child_cloud_task_run_id, child_task_run_id + ) + self.assertEqual(retrieved_batch.exploration_id, exploration_id) + self.assertEqual( + retrieved_batch.exploration_version, exploration_version + ) + self.assertEqual( + retrieved_batch.language_accent_code, language_accent_code + ) + self.assertEqual( + retrieved_batch.content_ids_to_contents_map, + content_ids_to_contents_map, + ) + + def test_should_return_none_for_non_existent_batch_model(self) -> None: + retrieved_batch = voiceover_cloud_task_services.get_voiceover_regeneration_task_batch_model( + 'non_existent_parent', 'non_existent_child' + ) + + self.assertIsNone(retrieved_batch) + + def test_should_convert_voiceover_regeneration_task_batch_model_to_domain_instance( + self, + ) -> None: + parent_task_run_id = 'parent_task_run_id' + child_task_run_id = 'child_task_run_id' + exploration_id = 'exploration_id' + exploration_version = 3 + language_accent_code = 'en-IN' + content_ids_to_contents_map = { + 'content_0': 'Test content 0', + 'content_1': 'Test content 1', + } + + model_instance = ( + cloud_task_models.VoiceoverRegenerationBatchExecutionModel( + parent_cloud_task_run_id=parent_task_run_id, + child_cloud_task_run_id=child_task_run_id, + exploration_id=exploration_id, + exploration_version=exploration_version, + language_accent_code=language_accent_code, + content_ids_to_contents_map=content_ids_to_contents_map, + ) + ) + + domain_instance = voiceover_cloud_task_services.convert_voiceover_regeneration_task_batch_model_to_domain_instance( + model_instance + ) + + self.assertEqual( + domain_instance.parent_cloud_task_run_id, parent_task_run_id + ) + self.assertEqual( + domain_instance.child_cloud_task_run_id, child_task_run_id + ) + self.assertEqual(domain_instance.exploration_id, exploration_id) + self.assertEqual( + domain_instance.exploration_version, exploration_version + ) + self.assertEqual( + domain_instance.language_accent_code, language_accent_code + ) + self.assertEqual( + domain_instance.content_ids_to_contents_map, + content_ids_to_contents_map, + ) + + def test_should_create_voiceover_regeneration_task_batch_model_if_not_exists( + self, + ) -> None: + parent_task_run_id = 'parent_task_run_id' + child_task_run_id = 'child_task_run_id' + exploration_id = 'exploration_id' + exploration_version = 1 + language_accent_code = 'en-US' + content_ids_to_contents_map = { + 'content_0': 'Hello world!', + } + + domain_instance = cloud_task_domain.VoiceoverRegenerationTaskBatch( + parent_task_run_id, + child_task_run_id, + exploration_id, + exploration_version, + language_accent_code, + content_ids_to_contents_map, + ) + + voiceover_cloud_task_services.create_or_update_voiceover_regeneration_task_batch_model( + domain_instance + ) + + retrieved_batch = voiceover_cloud_task_services.get_voiceover_regeneration_task_batch_model( + parent_task_run_id, child_task_run_id + ) + + self.assertIsNotNone(retrieved_batch) + assert retrieved_batch is not None + self.assertEqual( + retrieved_batch.language_accent_code, language_accent_code + ) + self.assertEqual( + retrieved_batch.content_ids_to_contents_map, + content_ids_to_contents_map, + ) + + def test_should_update_existing_voiceover_regeneration_task_batch_model( + self, + ) -> None: + parent_task_run_id = 'parent_task_run_id' + child_task_run_id = 'child_task_run_id' + exploration_id = 'exploration_id' + exploration_version = 1 + language_accent_code = 'en-US' + initial_content_map = {'content_0': 'Hello world!'} + + domain_instance = cloud_task_domain.VoiceoverRegenerationTaskBatch( + parent_task_run_id, + child_task_run_id, + exploration_id, + exploration_version, + language_accent_code, + initial_content_map, + ) + + voiceover_cloud_task_services.create_or_update_voiceover_regeneration_task_batch_model( + domain_instance + ) + + updated_content_map = { + 'content_0': 'Hello world!', + 'content_1': 'First card.', + } + updated_exploration_version = 2 + + updated_domain_instance = ( + cloud_task_domain.VoiceoverRegenerationTaskBatch( + parent_task_run_id, + child_task_run_id, + exploration_id, + updated_exploration_version, + language_accent_code, + updated_content_map, + ) + ) + + voiceover_cloud_task_services.create_or_update_voiceover_regeneration_task_batch_model( + updated_domain_instance + ) + + retrieved_batch = voiceover_cloud_task_services.get_voiceover_regeneration_task_batch_model( + parent_task_run_id, child_task_run_id + ) + + self.assertIsNotNone(retrieved_batch) + assert retrieved_batch is not None + self.assertEqual( + retrieved_batch.exploration_version, updated_exploration_version + ) + self.assertEqual( + retrieved_batch.content_ids_to_contents_map, updated_content_map + ) diff --git a/core/domain/voiceover_regeneration_services.py b/core/domain/voiceover_regeneration_services.py index 7a1c0c73d456c..9529fc2b8db68 100644 --- a/core/domain/voiceover_regeneration_services.py +++ b/core/domain/voiceover_regeneration_services.py @@ -24,6 +24,7 @@ import io import json import logging +import os import uuid from core import feconf, utils @@ -265,11 +266,29 @@ def get_text_with_delimiters(soup: bs4.BeautifulSoup, delimiter: str) -> str: return ''.join(text_segments) +def empty_voiceover_raw_audio_data() -> bytes: + """Provides the byte string to represent the raw audio data for an empty voiceover. + + Returns: + bytes. The byte string representing the raw audio data for an empty + voiceover. + """ + + voiceover_path = os.path.join( + feconf.SAMPLE_AUTO_VOICEOVERS_DATA_DIR, 'empty.mp3' + ) + + with open(voiceover_path, 'rb', encoding=None) as file: + binary_audio_data = file.read() + return binary_audio_data + + def synthesize_voiceover_for_html_string( exploration_id: str, content_html: str, language_accent_code: str, voiceover_filename: str, + oppia_project_id: Optional[str] = None, ) -> List[Dict[str, Union[str, float]]]: """The method generates automated voiceovers for the given HTML content using cloud service helper functions. @@ -281,6 +300,9 @@ def synthesize_voiceover_for_html_string( language_accent_code: str. The language accent code for generating the automated voiceover. voiceover_filename: str. The filename for the generated voiceover. + oppia_project_id: Optional[str]. The Google Cloud Project ID. Explicitly + required when running on Beam Dataflow, as workers cannot + retrieve the ID from environment variables. Returns: list(dict(str, str|float)). A list of dictionaries. Each dictionary @@ -295,12 +317,20 @@ def synthesize_voiceover_for_html_string( Raises: Exception. Error encountered during automatic voiceover regeneration. """ + # When voiceover regeneration is triggered through a Beam Dataflow job, + # the `oppia_project_id` is passed explicitly as an argument. This serves + # as a reliable indicator that the method is being invoked from a Beam job. + # Otherwise, the project ID is retrieved from the environment via + # `app_identity_service`. + is_called_from_beam_job = oppia_project_id is not None + # Audio files are stored to the datastore in the dev env, and to GCS # in production. fs = fs_services.GcsFileSystem( - feconf.ENTITY_TYPE_EXPLORATION, exploration_id + feconf.ENTITY_TYPE_EXPLORATION, + exploration_id, + oppia_project_id=oppia_project_id, ) - parsed_text = parse_html(content_html) content_hash_code = ( @@ -327,6 +357,10 @@ def synthesize_voiceover_for_html_string( filename = cached_model.voiceover_filename binary_audio_data = fs.get('%s/%s' % ('audio', filename)) is_cached_model_used_for_voiceovers = True + logging.info( + 'Voiceover synthesis log: Using cached voiceover for exploration ID: %s, content_html: %s' + % (exploration_id, content_html) + ) except Exception as e: cached_model = None logging.warning('Failed to retrieve voiceover from cache: %s' % e) @@ -337,26 +371,50 @@ def synthesize_voiceover_for_html_string( try: binary_audio_data, audio_offset_list, error_details = ( speech_synthesis_services.regenerate_speech_from_text( - parsed_text, language_accent_code + parsed_text, language_accent_code, oppia_project_id ) ) + logging.info( + 'Voiceover synthesis log: Generated new voiceover for exploration ID: %s, content_html: %s' + % (exploration_id, content_html) + ) except Exception as e: error_details = str(e) - if error_details: + logging.error( + 'Voiceover synthesis error: Error during speech synthesis for exploration ID: %s, content_html: %s. Error details: %s' + % (exploration_id, content_html, error_details) + ) raise Exception(error_details) - tempbuffer = io.BytesIO() - tempbuffer.write(binary_audio_data) - tempbuffer.seek(0) - audio = mp3.MP3(tempbuffer) - tempbuffer.close() + if not binary_audio_data: + logging.info( + 'Voiceover synthesis log: Empty voiceover generated for exploration ID: %s, content_html: %s' + % (exploration_id, content_html) + ) + audio_offset_list = [] + + # In the Beam environment, the default audio file for empty voiceovers + # cannot be accessed due to filesystem limitations. Since the binary + # data is empty in this scenario, it is safe to return an empty list. + if is_called_from_beam_job: + return audio_offset_list + + binary_audio_data = empty_voiceover_raw_audio_data() + + with io.BytesIO(binary_audio_data) as tempbuffer: + audio = mp3.MP3(tempbuffer) + mimetype = 'audio/mpeg' # For a strange, unknown reason, the audio variable must be # deleted before opening cloud storage. If not, cloud storage # throws a very mysterious error that entails a mutagen # object being recursively passed around in app engine. del audio + + logging.info( + 'Voiceover synthesis log: Voiceover filename: %s.' % voiceover_filename + ) fs.commit( '%s/%s' % ('audio', voiceover_filename), binary_audio_data, @@ -367,7 +425,7 @@ def synthesize_voiceover_for_html_string( # voiceovers in the cache. if cached_model is not None: if cached_model.plaintext != parsed_text: - if len(parsed_text) < len(cached_model.plaintext): + if len(str(parsed_text)) < len(str(cached_model.plaintext)): # Since the current text is shorter than the one in the cached # model, there is a higher likelihood of repetition in # other content. Thus, updating the cached model to store the @@ -517,7 +575,7 @@ def regenerate_voiceover_for_exploration_content( voiceover = fetch_voiceover_by_filename(exploration_id, voiceover_filename) - voiceover_cloud_task_services.update_voiceover_regeneration_task_run_mapping_for_content( + voiceover_cloud_task_services.update_voiceover_regeneration_job_status( exploration_id, language_accent_code, content_id, 'SUCCEEDED' ) @@ -542,30 +600,32 @@ def regenerate_voiceover_for_exploration_content( def fetch_voiceover_by_filename( - exploration_id: str, filename: str + exploration_id: str, filename: str, oppia_project_id: Optional[str] = None ) -> state_domain.Voiceover: """Fetches the voiceover by filename from the GCS file system. Args: exploration_id: str. The ID of the exploration. filename: str. The filename of the voiceover to be fetched. + oppia_project_id: Optional[str]. The Google Cloud Project ID. Explicitly + required when running on Beam Dataflow, as workers cannot + retrieve the ID from environment variables. Returns: Voiceover. The fetched voiceover object. """ fs = fs_services.GcsFileSystem( - feconf.ENTITY_TYPE_EXPLORATION, exploration_id + feconf.ENTITY_TYPE_EXPLORATION, + exploration_id, + oppia_project_id=oppia_project_id, ) binary_audio_data = fs.get('%s/%s' % ('audio', filename)) - tempbuffer = io.BytesIO() - tempbuffer.write(binary_audio_data) - tempbuffer.seek(0) - audio = mp3.MP3(tempbuffer) - - duration_secs = audio.info.length - audio_size_bytes = tempbuffer.getbuffer().nbytes + with io.BytesIO(binary_audio_data) as tempbuffer: + audio = mp3.MP3(tempbuffer) + duration_secs = audio.info.length + audio_size_bytes = tempbuffer.getbuffer().nbytes return state_domain.Voiceover( filename, audio_size_bytes, False, duration_secs diff --git a/core/domain/voiceover_regeneration_services_test.py b/core/domain/voiceover_regeneration_services_test.py index 92cc9a9fd5a7a..c45ac91f58557 100644 --- a/core/domain/voiceover_regeneration_services_test.py +++ b/core/domain/voiceover_regeneration_services_test.py @@ -34,10 +34,11 @@ voiceover_services, ) from core.platform import models +from core.platform.speech_synthesis import dev_mode_speech_synthesis_services from core.tests import test_utils import bs4 -from typing import Dict, List, Union +from typing import Dict, List, Optional, Tuple, Union MYPY = False if MYPY: # pragma: no cover @@ -256,6 +257,30 @@ def test_use_existing_cache_model_for_fetching_automatic_voiceover_data( self.assertEqual(audio_offset_list, generated_audio_offset_list) + def test_should_get_empty_audio_sucessfully(self) -> None: + content_html = '

This is a test text

' + exploration_id = 'exp_id' + language_accent_code = 'en-US' + filename = 'content_0-en-US-asdjytdyop.mp3' + + def _mock_regenerate_speech_from_text( + _text: str, + _language_accent_code: str, + _oppia_project_id: Optional[str] = None, + ) -> Tuple[bytes, List[Dict[str, Union[str, float]]], Optional[str]]: + + return (b'', [], '') + + with self.swap( + dev_mode_speech_synthesis_services, + 'regenerate_speech_from_text', + _mock_regenerate_speech_from_text, + ): + audio_offset_list = voiceover_regeneration_services.synthesize_voiceover_for_html_string( + exploration_id, content_html, language_accent_code, filename + ) + self.assertEqual(audio_offset_list, []) + @mock.patch( 'core.domain.fs_services.GcsFileSystem.get', side_effect=Exception('Mocked exception during voiceover retrieval'), @@ -419,6 +444,58 @@ def test_should_raise_exception_if_regeneration_failed( ) ) + def test_should_get_empty_audio_sucessfully_in_sync(self) -> None: + content_html = '

' + exploration_id = 'exp_id' + language_accent_code = 'en-US' + filename = 'content_0-en-US-asdjytdyop.mp3' + + def _mock_regenerate_speech_from_text( + _text: str, + _language_accent_code: str, + _oppia_project_id: Optional[str] = None, + ) -> Tuple[bytes, List[Dict[str, Union[str, float]]], Optional[str]]: + + return (b'', [], '') + + with self.swap( + dev_mode_speech_synthesis_services, + 'regenerate_speech_from_text', + _mock_regenerate_speech_from_text, + ): + audio_offset_list = voiceover_regeneration_services.synthesize_voiceover_for_html_string( + exploration_id, content_html, language_accent_code, filename + ) + self.assertEqual(audio_offset_list, []) + + def test_should_get_empty_audio_sucessfully_in_async(self) -> None: + content_html = '

' + exploration_id = 'exp_id' + language_accent_code = 'en-US' + filename = 'content_0-en-US-asdjytdyop.mp3' + + def _mock_regenerate_speech_from_text( + _text: str, + _language_accent_code: str, + _oppia_project_id: Optional[str] = None, + ) -> Tuple[bytes, List[Dict[str, Union[str, float]]], Optional[str]]: + + return (b'', [], '') + + with self.swap( + dev_mode_speech_synthesis_services, + 'regenerate_speech_from_text', + _mock_regenerate_speech_from_text, + ): + audio_offset_list = voiceover_regeneration_services.synthesize_voiceover_for_html_string( + exploration_id, + content_html, + language_accent_code, + filename, + 'dev-project-id', + ) + self.assertEqual(audio_offset_list, []) + def test_should_be_able_to_get_new_voiceover_filename(self) -> None: content_id = 'content_0' language_accent_code = 'en-US' diff --git a/core/domain/voiceover_services.py b/core/domain/voiceover_services.py index 23376eaf43d08..5bb44860917bd 100644 --- a/core/domain/voiceover_services.py +++ b/core/domain/voiceover_services.py @@ -18,17 +18,20 @@ from __future__ import annotations +import collections import datetime import json -import os +import logging -from core import feconf -from core.constants import constants +from core import constants, feconf from core.domain import ( + cloud_task_domain, email_manager, exp_domain, exp_fetchers, + exp_services, state_domain, + suggestion_services, taskqueue_services, translation_domain, translation_fetchers, @@ -40,7 +43,7 @@ from core.platform import models from core.storage.voiceover import gae_models -from typing import Dict, List, Optional, Tuple, cast +from typing import Dict, List, Optional, Set, Tuple, cast MYPY = False if MYPY: # pragma: no cover @@ -57,7 +60,7 @@ MAX_SAMPLE_VOICEOVERS_FOR_GIVEN_VOICE_ARTIST = 5 -def _get_entity_voiceovers_from_model( +def get_entity_voiceovers_from_model( entity_voiceovers_model: voiceover_models.EntityVoiceoversModel, ) -> voiceover_domain.EntityVoiceovers: """Returns the EntityVoiceovers domain object from its model representation @@ -108,7 +111,7 @@ def get_voiceovers_for_given_language_accent_code( ) if entity_voiceovers_model: - return _get_entity_voiceovers_from_model(entity_voiceovers_model) + return get_entity_voiceovers_from_model(entity_voiceovers_model) return voiceover_domain.EntityVoiceovers.create_empty( entity_type=entity_type, entity_id=entity_id, @@ -141,7 +144,7 @@ def get_entity_voiceovers_for_given_exploration( for model_instance in entity_voiceovers_models: entity_voiceovers_objects.append( - _get_entity_voiceovers_from_model(model_instance) + get_entity_voiceovers_from_model(model_instance) ) return entity_voiceovers_objects @@ -305,7 +308,7 @@ def compute_voiceover_related_change( # English content was modified, so all associated # voiceovers must be marked as needing update. if ( - language_code != constants.DEFAULT_LANGUAGE_CODE + language_code != constants.constants.DEFAULT_LANGUAGE_CODE and entity_voiceovers.language_accent_code not in language_accent_codes ): @@ -332,7 +335,7 @@ def compute_voiceover_related_change( # English content was modified, so all associated # voiceovers must be removed. if ( - language_code != constants.DEFAULT_LANGUAGE_CODE + language_code != constants.constants.DEFAULT_LANGUAGE_CODE and entity_voiceovers.language_accent_code not in language_accent_codes ): @@ -554,6 +557,97 @@ def save_language_accent_support( voiceover_autogeneration_policy_model.put() +def is_accent_code_valid_for_autogeneration(language_accent_code: str) -> bool: + """The method validates whether the provided language accent code is valid + for Oppia's voiceover autogeneration. + + Args: + language_accent_code: str. The language accent code to be validated. + + Returns: + bool. True if the provided language accent code is valid for Oppia's + voiceover autogeneration, False otherwise. + """ + autogeneratable_language_accents = ( + get_autogeneratable_language_accent_codes() + ) + return ( + isinstance(language_accent_code, str) + and language_accent_code in autogeneratable_language_accents + ) + + +def get_new_auto_voiceover_accent( + updated_language_accent_mapping: Dict[str, Dict[str, bool]], +) -> Optional[str]: + """Returns the newly added language-accent code enabled for automatic + voiceover regeneration, if any. + + Args: + updated_language_accent_mapping: dict(str, dict(str, bool)). Mapping of + language codes to their accent configurations after the update. + Each accent code maps to a boolean indicating whether automatic + voiceover generation is enabled. + + Returns: + Optional[str]. The newly added language-accent code enabled for automatic + voiceover regeneration, or None if no such accent was added. + """ + retrieved_voiceover_autogeneration_policy_model = ( + voiceover_models.VoiceoverAutogenerationPolicyModel.get( + voiceover_models.VOICEOVER_AUTOGENERATION_POLICY_ID, strict=False + ) + ) + voiceover_autogeneration_policy_model = ( + retrieved_voiceover_autogeneration_policy_model + if retrieved_voiceover_autogeneration_policy_model is not None + else voiceover_models.VoiceoverAutogenerationPolicyModel( + id=voiceover_models.VOICEOVER_AUTOGENERATION_POLICY_ID + ) + ) + + existing_language_accent_mapping = ( + voiceover_autogeneration_policy_model.language_codes_mapping + if voiceover_autogeneration_policy_model.language_codes_mapping + is not None + else {} + ) + existing_autogeneratable_accents: Set[str] = set() + updated_autogeneratable_accents: Set[str] = set() + + for accent_mapping in existing_language_accent_mapping.values(): + for ( + language_accent_code, + supports_autogeneration, + ) in accent_mapping.items(): + if supports_autogeneration: + existing_autogeneratable_accents.add(language_accent_code) + + for accent_mapping in updated_language_accent_mapping.values(): + for ( + language_accent_code, + supports_autogeneration, + ) in accent_mapping.items(): + if supports_autogeneration: + updated_autogeneratable_accents.add(language_accent_code) + + new_accents_set = ( + updated_autogeneratable_accents - existing_autogeneratable_accents + ) + + # Since the UI triggers a backend request immediately whenever a language + # accent code is updated, the new_accents_set can contain at most one element. + # Therefore, we can safely use pop() to retrieve the newly added language + # accent code. + if new_accents_set: + assert len(new_accents_set) == 1, ( + 'Expected only one new language-accent code to be added for automatic ' + 'voiceover regeneration, but found multiple: %s' % new_accents_set + ) + return new_accents_set.pop() + return None + + def get_language_accent_master_list() -> Dict[str, Dict[str, str]]: """The method returns the lanaguage accent master list stored in the JSON file. @@ -565,14 +659,10 @@ def get_language_accent_master_list() -> Dict[str, Dict[str, str]]: language-accent pairs that Oppia may support for voiceovers (manual and auto). """ - file_path = os.path.join( - feconf.VOICEOVERS_DATA_DIR, 'language_accent_master_list.json' + language_accent_master_list: Dict[str, Dict[str, str]] = ( + constants.language_accent_master_list_constants ) - with open(file_path, 'r', encoding='utf-8') as f: - language_accent_master_list: Dict[str, Dict[str, str]] = json.loads( - f.read() - ) - return language_accent_master_list + return language_accent_master_list def get_language_accent_codes_to_descriptions() -> Dict[str, str]: @@ -633,14 +723,10 @@ def get_autogeneratable_language_accent_list() -> Dict[str, Dict[str, str]]: for voiceover generation, while 'voice_code' signifies the desired voice type. """ - file_path = os.path.join( - feconf.VOICEOVERS_DATA_DIR, 'autogeneratable_language_accent_list.json' + autogeneratable_language_accent_list: Dict[str, Dict[str, str]] = ( + constants.autogeneratable_language_accent_constants ) - with open(file_path, 'r', encoding='utf-8') as f: - autogeneratable_language_accent_list: Dict[str, Dict[str, str]] = ( - json.loads(f.read()) - ) - return autogeneratable_language_accent_list + return autogeneratable_language_accent_list def get_autogeneratable_language_accent_codes() -> List[str]: @@ -805,29 +891,6 @@ def send_email_to_voiceover_admins_and_tech_leads_after_regeneration( ) -def _remove_empty_contents_for_voiceover_regeneration( - language_code_to_contents_mapping: Dict[str, Dict[str, str]], -) -> None: - """Removes empty contents from the provided input. - - Args: - language_code_to_contents_mapping: dict. A dictionary mapping language - codes to the corresponding content IDs and their associated HTML - that require voiceover regeneration. - """ - for ( - _, - content_ids_to_content_values, - ) in language_code_to_contents_mapping.items(): - content_ids_to_remove = [ - content_id - for content_id, html in (content_ids_to_content_values.items()) - if not html.strip() - ] - for content_id in content_ids_to_remove: - del content_ids_to_content_values[content_id] - - def extract_english_voiceover_texts_from_exploration( exploration: exp_domain.Exploration, ) -> Dict[str, Dict[str, str]]: @@ -906,68 +969,35 @@ def extract_translated_voiceover_texts_from_entity_translations( return language_code_to_contents_mapping -def _regenerate_voiceovers_for_given_contents( +def regenerate_voiceovers_for_given_contents( exploration_id: str, - exploration_title: str, exploration_version: int, language_code_to_contents_mapping: Dict[str, Dict[str, str]], - date_time: str, - author_id: str, + task_run_id: str, specific_language_accent_code: Optional[str] = None, - task_run_id: Optional[str] = None, ) -> None: - """Private helper method to regenerate voiceovers for specified contents + """Helper method to regenerate voiceovers for specified contents of an exploration. Args: exploration_id: str. The ID of the exploration for which voiceovers need to be regenerated. - exploration_title: str. The title of the exploration for which - voiceovers need to be regenerated. exploration_version: int. The version of the exploration for which voiceovers need to be regenerated. language_code_to_contents_mapping: dict. A dictionary mapping language codes to the corresponding content IDs and their associated HTML that require voiceover regeneration. - date_time: str. The ISO-formatted timestamp indicating when the - regeneration process was initiated. - author_id: str. The ID of the user who triggered the voiceover - regeneration, either directly or indirectly. + task_run_id: str. The unique identifier for the voiceover + regeneration task. specific_language_accent_code: Optional[str]. The specific language accent code to use for voiceover regeneration, if provided. - task_run_id: str|None. The unique identifier for the voiceover - regeneration task. If None, the method is invoked by a - synchronous process and task-tracking is not required. """ - # A dictionary mapping each language code to a list of accent codes that - # support autogeneration. - language_code_to_autogeneratable_accent_codes = {} - - # Remove empty contents from the voiceover regeneration mapping. - _remove_empty_contents_for_voiceover_regeneration( - language_code_to_contents_mapping - ) - - # A list of error collections that occurred during the - # voiceover regeneration. - error_collections_during_voiceover_regeneration: List[ - Dict[str, List[Tuple[str, str]] | str] - ] = [] - # Get all language codes that need voiceover regeneration in this request. language_codes = list(language_code_to_contents_mapping.keys()) - language_accent_codes_to_descriptions = ( - get_language_accent_codes_to_descriptions() - ) - - # Counter to track the number of contents for which voiceover regeneration - # is triggered. - number_of_contents_for_voiceover_regeneration = 0 - - # Counter to track the number of contents that failed to regenerate - # voiceovers. - number_of_contents_failed_to_regenerate = 0 + # A dictionary mapping each language code to a list of accent codes that + # support autogeneration. + language_code_to_autogeneratable_accent_codes = {} # Retrieve all Oppia-supported language accents, grouped by language code, # for which voiceovers need to be regenerated for the given contents. @@ -975,158 +1005,436 @@ def _regenerate_voiceovers_for_given_contents( language_accent_codes = ( get_supported_autogeneratable_accents_by_language(language_code) ) + if not language_accent_codes: continue + + if specific_language_accent_code: + language_code_to_autogeneratable_accent_codes[language_code] = [ + specific_language_accent_code + ] + break + language_code_to_autogeneratable_accent_codes[language_code] = ( language_accent_codes ) - # A list of language accents for which voiceovers are regenerated. - language_accents_used_for_voiceover_regeneration = [] + voiceover_regeneration_job = voiceover_cloud_task_services.create_voiceover_regeneration_task_with_status_generating( + exploration_id, + task_run_id, + language_code_to_contents_mapping, + language_code_to_autogeneratable_accent_codes, + ) - requested_task_is_async: bool = task_run_id is not None + # Ruling out the possibility of None for mypy type checking. + assert voiceover_regeneration_job is not None - if requested_task_is_async: - # Ruling out the possibility of None for mypy type checking. - assert task_run_id is not None - voiceover_regeneration_task = ( - voiceover_cloud_task_services.get_voiceover_regeneration_task( - exploration_id, task_run_id - ) - ) + voiceover_cloud_task_services.save_voiceover_regeneration_job( + voiceover_regeneration_job + ) - if requested_task_is_async and voiceover_regeneration_task is None: - # Ruling out the possibility of None for mypy type checking. - assert task_run_id is not None - voiceover_regeneration_task = voiceover_cloud_task_services.create_voiceover_regeneration_task_with_status_generating( - exploration_id, - task_run_id, - language_code_to_contents_mapping, - language_code_to_autogeneratable_accent_codes, - ) + # Voiceover regeneration for a large number of contents within a single + # Cloud Task run (deferred request) significantly increases the workload and + # may lead to timeout failures due to Gunicorn limitations of 60 seconds. + # To mitigate this issue, a single deferred regeneration task is split into + # multiple smaller batches. + divide_and_enqueue_voiceover_regeneration_tasks_in_smaller_batches( + language_code_to_contents_mapping, + language_code_to_autogeneratable_accent_codes, + exploration_id, + exploration_version, + task_run_id, + ) - # Ruling out the possibility of None for mypy type checking. - assert voiceover_regeneration_task is not None - voiceover_cloud_task_services.save_voiceover_regeneration_task_run_mapping( - voiceover_regeneration_task +def divide_and_enqueue_voiceover_regeneration_tasks_in_smaller_batches( + language_code_to_contents_mapping: Dict[str, Dict[str, str]], + language_code_to_autogeneratable_accent_codes: Dict[str, List[str]], + exploration_id: str, + exploration_version: int, + parent_cloud_task_run_id: str, +) -> None: + """It divides the voiceover regeneration process for an exploration into + smaller batches and enqueues a separate task for each batch in the + Google Cloud Task Queue. This approach prevents asynchronous deferred + requests from timing out when processing a large number of contents + in one request, thereby avoiding the 60-second Gunicorn timeout limit. + + Args: + language_code_to_contents_mapping: dict. A dictionary mapping language + codes to the corresponding content IDs and their associated HTML + that require voiceover regeneration. + language_code_to_autogeneratable_accent_codes: dict. A dictionary mapping + language codes to a list of accent codes that support autogeneration. + exploration_id: str. The ID of the exploration for which voiceovers + need to be regenerated. + exploration_version: int. The version of the exploration for which + voiceovers need to be regenerated. + parent_cloud_task_run_id: str. The unique identifier for the parent + cloud task run, which is responsible for regenerating voiceovers + for all the contents of the exploration in batches. + """ + logging.info( + 'Voiceover regeneration logs: Starting to divide and enqueue voiceover ' + 'regeneration tasks in smaller batches for exploration_id: %s, ' + 'parent_cloud_task_run_id: %s' + % ( + exploration_id, + parent_cloud_task_run_id, ) + ) + # Based on testing data, regenerating a voiceover for each state content + # takes approximately 6 seconds. Therefore, to avoid hitting the timeout + # limit, we can process about 8 contents per batch. This would take roughly + # 48 seconds, leaving sufficient buffer time to handle any variations + # in processing. + batch_size = 8 - errors_while_voiceover_regeneration = [] + batch_counter = 0 + child_cloud_task_model_ids = [] - for language_code in language_codes: + for ( + language_code, + content_ids_to_content_values, + ) in language_code_to_contents_mapping.items(): language_accent_codes = ( language_code_to_autogeneratable_accent_codes.get(language_code, []) ) + for language_accent_code in language_accent_codes: + content_id_value_pairs = list(content_ids_to_content_values.items()) + + for i in range(0, len(content_id_value_pairs), batch_size): + batch_content_id_value_pairs = content_id_value_pairs[ + i : i + batch_size + ] + batch_content_ids_to_content_values = dict( + batch_content_id_value_pairs + ) + batch_counter += 1 + + child_cloud_task_model_id = ( + taskqueue_services.get_new_cloud_task_run_id() + ) + + logging.info( + 'Voiceover regeneration logs: Enqueuing batch %d for ' + 'exploration_id: %s, parent_cloud_task_run_id: %s, ' + 'child_cloud_task_run_id: %s' + % ( + batch_counter, + exploration_id, + parent_cloud_task_run_id, + child_cloud_task_model_id, + ) + ) - content_ids_to_content_values = language_code_to_contents_mapping.get( - language_code, {} + voiceover_regeneration_task_batch_instance = ( + cloud_task_domain.VoiceoverRegenerationTaskBatch( + parent_cloud_task_run_id, + child_cloud_task_model_id, + exploration_id, + exploration_version, + language_accent_code, + batch_content_ids_to_content_values, + ) + ) + + voiceover_cloud_task_services.create_voiceover_regeneration_task_batch_model( + voiceover_regeneration_task_batch_instance + ) + + # Enqueue to Google cloud task queue. + taskqueue_services.defer_voiceover_regeneration_task_in_batches( + feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[ + 'FUNCTION_ID_REGENERATE_VOICEOVERS_FOR_BATCH_CONTENTS' + ], + taskqueue_services.QUEUE_NAME_VOICEOVER_REGENERATION, + parent_cloud_task_run_id, + child_cloud_task_model_id, + exploration_id, + ) + + child_cloud_task_model_ids.append(child_cloud_task_model_id) + + logging.info( + 'Voiceover regeneration logs: Number of batches: %s, Parent Cloud Task Run ID: %s, Child Cloud Task Run IDs: %s' + % ( + batch_counter, + parent_cloud_task_run_id, + child_cloud_task_model_ids, ) + ) + + +def regenerate_voiceovers_for_batch_contents( + exploration_id: str, + parent_cloud_task_run_id: str, + child_cloud_task_run_id: str, +) -> None: + """Regenerates automatic voiceovers for some contents of an exploration, so + that we can't hit the state where in an async deferred request due to large + numbers of contents to regenerate in one go we have a timeout becuase of + Gunicorn's timeout of 60 secs. + + Args: + exploration_id: str. The ID of the exploration for which voiceovers + need to be regenerated. + parent_cloud_task_run_id: str. The unique identifier for the parent + cloud task run, which is responsible for regenerating voiceovers + for all the contents of the exploration in batches. + child_cloud_task_run_id: str. The unique identifier for the child + cloud task run, which is responsible for regenerating voiceovers + for a batch of contents of the exploration in a language accent. + + Raises: + Exception. Raised when there is an error during the voiceover + regeneration process for the batch of contents. + """ + logging.info( + 'Voiceover regeneration logs: Starting to regenerate voiceovers for ' + 'batch contents for exploration_id: %s, parent_cloud_task_run_id: %s, ' + 'child_cloud_task_run_id: %s' + % (exploration_id, parent_cloud_task_run_id, child_cloud_task_run_id) + ) + + voiceover_regeneration_batch_execution_job = voiceover_cloud_task_services.get_voiceover_regeneration_task_batch_model( + parent_cloud_task_run_id, child_cloud_task_run_id + ) + + logging.info( + 'Voiceover regeneration logs: Trying to fetch voiceover regeneration batch execution job, ' + 'parent_cloud_task_run_id: %s, child_cloud_task_run_id: %s.' + % (parent_cloud_task_run_id, child_cloud_task_run_id) + ) + + # Ruling out the possibility of None for mypy type checking. + assert voiceover_regeneration_batch_execution_job is not None + + exploration_id = voiceover_regeneration_batch_execution_job.exploration_id + exploration_version = ( + voiceover_regeneration_batch_execution_job.exploration_version + ) + language_accent_code = ( + voiceover_regeneration_batch_execution_job.language_accent_code + ) + content_ids_to_content_values = ( + voiceover_regeneration_batch_execution_job.content_ids_to_contents_map + ) + + try: + errors_while_voiceover_regeneration = voiceover_regeneration_services.regenerate_voiceovers_of_exploration( + exploration_id, + exploration_version, + content_ids_to_content_values, + language_accent_code, + ) + except Exception as e: + errors_while_voiceover_regeneration = [ + (content_id, str(e)) + for content_id in content_ids_to_content_values.keys() + ] + + error_collections_during_voiceover_regeneration = [] + + error_collections_during_voiceover_regeneration.append( + json.dumps( + { + 'exploration_id': exploration_id, + 'language_accent_code': language_accent_code, + 'error_messages': errors_while_voiceover_regeneration, + } + ) + ) + + child_cloud_task_run = taskqueue_services.get_cloud_task_run_by_model_id( + child_cloud_task_run_id + ) + # Ruling out the possibility of None for mypy type checking. + assert child_cloud_task_run is not None + + child_cloud_task_run.exception_messages_for_failed_runs.extend( + error_collections_during_voiceover_regeneration + ) + if len(errors_while_voiceover_regeneration) > 0: + child_cloud_task_run.latest_job_state = 'PERMANENTLY_FAILED' + else: + child_cloud_task_run.latest_job_state = 'SUCCEEDED' + + taskqueue_services.update_cloud_task_run_model(child_cloud_task_run) + + wrap_up_voiceover_regeneration_task( + exploration_id, parent_cloud_task_run_id + ) - for language_accent_code in language_accent_codes: - if ( - specific_language_accent_code is not None - and language_accent_code != specific_language_accent_code - ): - continue - language_accents_used_for_voiceover_regeneration.append( - language_accent_codes_to_descriptions.get( - language_accent_code, '' +def wrap_up_voiceover_regeneration_task( + exploration_id: str, + parent_cloud_task_run_id: str, +) -> None: + """Wraps up the voiceover regeneration task by sending a summary email to + voiceover admins and tech leads, which includes the details of the + voiceover regeneration process. + + Args: + exploration_id: str. The ID of the exploration for which voiceovers + were regenerated. + parent_cloud_task_run_id: str. The unique identifier for the parent + cloud task run, which is responsible for regenerating voiceovers + for all the contents of the exploration in batches. + """ + child_cloud_task_run_ids = [] + language_accent_codes = [] + number_of_contents_for_voiceover_regeneration = 0 + + voiceover_regeneration_batch_instances = voiceover_cloud_task_services.get_voiceover_regeneration_batch_instances_by_parent_task_run_id( + parent_cloud_task_run_id + ) + + for batch_instance in voiceover_regeneration_batch_instances: + child_cloud_task_run_ids.append(batch_instance.child_cloud_task_run_id) + language_accent_codes.append(batch_instance.language_accent_code) + number_of_contents_for_voiceover_regeneration += len( + batch_instance.content_ids_to_contents_map + ) + + child_cloud_task_runs = taskqueue_services.get_cloud_task_runs_by_model_ids( + child_cloud_task_run_ids + ) + + # Verify first if all the task runs are completed i.e., their status must + # be either 'SUCCEEDED' or 'PERMANENTLY_FAILED'. If not, we should not + # proceed with wrapping up the voiceover regeneration task, as it indicates + # that some batches are still being processed. + for child_cloud_task_run in child_cloud_task_runs: + if child_cloud_task_run.latest_job_state not in [ + 'SUCCEEDED', + 'PERMANENTLY_FAILED', + ]: + logging.info( + 'Voiceover regeneration logs: Not wrapping up the voiceover ' + 'regeneration task for parent_cloud_task_run_id: %s, because ' + 'child_cloud_task_run_id: %s is still in processing with status: %s' + % ( + parent_cloud_task_run_id, + child_cloud_task_run.task_id, + child_cloud_task_run.latest_job_state, ) ) + return - number_of_contents_for_voiceover_regeneration += len( - content_ids_to_content_values - ) + error_collections_during_voiceover_regeneration: List[ + Dict[str, str | List[Tuple[str, str]]] + ] = [] + language_accent_code_to_error: Dict[str, List[Tuple[str, str]]] = ( + collections.defaultdict(list) + ) + number_of_contents_failed_to_regenerate = 0 - errors_while_voiceover_regeneration = voiceover_regeneration_services.regenerate_voiceovers_of_exploration( - exploration_id, - exploration_version, - content_ids_to_content_values, - language_accent_code, + voiceover_regeneration_job_status = ( + voiceover_cloud_task_services.get_voiceover_regeneration_job( + exploration_id, parent_cloud_task_run_id + ) + ) + # Ruling out the possibility of None for mypy type checking. + assert voiceover_regeneration_job_status is not None + + parent_cloud_task_run = taskqueue_services.get_cloud_task_run_by_model_id( + parent_cloud_task_run_id + ) + # Ruling out the possibility of None for mypy type checking. + assert parent_cloud_task_run is not None + + for cloud_task_run in child_cloud_task_runs: + for error_details in cloud_task_run.exception_messages_for_failed_runs: + error_collections_during_voiceover_regeneration.append( + json.loads(error_details) ) - failed_content_ids = [ - error[0] for error in errors_while_voiceover_regeneration - ] + final_error_string = 'Exploration ID: %s\n' % exploration_id - if requested_task_is_async: - # Ruling out the possibility of None for mypy type checking. - assert voiceover_regeneration_task is not None - voiceover_regeneration_task.update_final_content_status_for_cloud_task_run( - language_accent_code, failed_content_ids - ) + for error_collection in error_collections_during_voiceover_regeneration: + # Here we use cast because we are narrowing down the type of + # 'language_accent_code' from Union of str and List to a str. + language_accent_code: str = cast( + str, error_collection['language_accent_code'] + ) - if errors_while_voiceover_regeneration: - error_collections_during_voiceover_regeneration.append( - { - 'exploration_id': exploration_id, - 'language_accent_code': language_accent_code, - 'error_messages': errors_while_voiceover_regeneration, - } - ) - number_of_contents_failed_to_regenerate += len( - errors_while_voiceover_regeneration - ) + # Here we use cast because we are narrowing down the type of + # 'error_messages' from Union of str and List to a List of Tuples. + content_id_and_error_message_tuple = cast( + List[Tuple[str, str]], error_collection['error_messages'] + ) - if requested_task_is_async: - # Ruling out the possibility of None for mypy type checking. - assert voiceover_regeneration_task is not None - voiceover_cloud_task_services.save_voiceover_regeneration_task_run_mapping( - voiceover_regeneration_task + language_accent_code_to_error[language_accent_code].extend( + content_id_and_error_message_tuple ) + for ( + language_accent_code, + content_id_and_error_message_tuple, + ) in language_accent_code_to_error.items(): + final_error_string += 'Language Accent Code: %s\nErrors: %s \n' % ( + language_accent_code, + content_id_and_error_message_tuple, + ) + failed_content_ids = [ + error[0] for error in content_id_and_error_message_tuple + ] + voiceover_regeneration_job_status.update_failed_content_status( + language_accent_code, failed_content_ids + ) + number_of_contents_failed_to_regenerate += len(failed_content_ids) + + if number_of_contents_failed_to_regenerate > 0: + parent_cloud_task_run.exception_messages_for_failed_runs.append( + final_error_string + ) + parent_cloud_task_run.latest_job_state = 'PERMANENTLY_FAILED' + taskqueue_services.update_cloud_task_run_model(parent_cloud_task_run) + + voiceover_regeneration_job_status.update_remaining_content_status_as_succeeded() + voiceover_cloud_task_services.save_voiceover_regeneration_job( + voiceover_regeneration_job_status + ) + logging.info( + 'Voiceover regeneration logs: %s' + % voiceover_regeneration_job_status.to_dict() + ) + + exploration = exp_fetchers.get_exploration_by_id( + exploration_id, strict=False + ) + exploration_title = exploration.title if exploration else '' + + language_accent_codes_to_descriptions = ( + get_language_accent_codes_to_descriptions() + ) + language_accent_descriptions_used_for_regeneration = [ + language_accent_codes_to_descriptions.get(language_accent_code, '') + for language_accent_code in language_accent_codes + ] + + logging.info( + 'Voiceover regeneration logs: Finished regenerating voiceovers for ' + 'all batches for exploration_id: %s, now sending summary email to ' + 'voiceover admins and tech leads.' % exploration_id + ) send_email_to_voiceover_admins_and_tech_leads_after_regeneration( exploration_id, exploration_title, - date_time, - language_accents_used_for_voiceover_regeneration, + parent_cloud_task_run.created_on.isoformat(), + language_accent_descriptions_used_for_regeneration, error_collections_during_voiceover_regeneration, number_of_contents_for_voiceover_regeneration, number_of_contents_failed_to_regenerate, - author_id, + feconf.SYSTEM_COMMITTER_ID, ) - if requested_task_is_async: - error_string = '' - for error_collection in error_collections_during_voiceover_regeneration: - error_string += ( - 'Exploration ID: %s\nLanguage Accent Code: %s\nErrors: %s \n' - % ( - error_collection['exploration_id'], - error_collection['language_accent_code'], - error_collection['error_messages'], - ) - ) - - # Ruling out the possibility of None for mypy type checking. - assert task_run_id is not None - cloud_task_run_domain_instance = ( - taskqueue_services.get_cloud_task_run_by_model_id(task_run_id) - ) - # Ruling out the possibility of None for mypy type checking. - assert cloud_task_run_domain_instance is not None - - if errors_while_voiceover_regeneration: - cloud_task_run_domain_instance.latest_job_state = ( - 'PERMANENTLY_FAILED' - ) - cloud_task_run_domain_instance.exception_messages_for_failed_runs.append( - error_string - ) - taskqueue_services.update_cloud_task_run_model( - cloud_task_run_domain_instance - ) - def regenerate_voiceovers_on_exploration_update( exploration_id: str, - exploration_title: str, exploration_version: int, - author_id: str, - date_time: str, - task_run_id: Optional[str] = None, + task_run_id: str, ) -> None: """Regenerates voiceovers for the updated exploration based on the changes made in the exploration content (in English) or translations (in other @@ -1138,20 +1446,21 @@ def regenerate_voiceovers_on_exploration_update( Args: exploration_id: str. The ID of the exploration for which voiceovers need to be regenerated. - exploration_title: str. The title of the exploration. exploration_version: int. The version of the exploration for which voiceovers need to be regenerated. - author_id: str. The ID of the author who made the changes to the - exploration. - date_time: str. The date and time when the changes were - made to the exploration. - task_run_id: str|None. The unique identifier for the voiceover + task_run_id: str. The unique identifier for the voiceover regeneration task. Raises: Exception. If the voiceover regeneration fails for any of the content IDs or language-accent codes. """ + logging.info( + 'Voiceover regeneration logs: Started regenerating voiceovers for ' + 'exploration with ID: %s and version: %s on exploration update.' + % (exploration_id, exploration_version) + ) + # Fetches the exploration change diff for the given exploration ID and # exploration version from the ExplorationCommitLogEntryModel. exploration_commit_log_entry_model_id = 'exploration-%s-%s' % ( @@ -1181,13 +1490,26 @@ def regenerate_voiceovers_on_exploration_update( for change in exploration_change_diff: cmd = change.get('cmd') if cmd == exp_domain.CMD_EDIT_STATE_PROPERTY: - # CMD_EDIT_STATE_PROPERTY is used to fetch the updated content for - # the English language. - updated_content = change['new_value']['html'] - content_id = change['new_value']['content_id'] - language_code_to_contents_mapping.setdefault('en', {})[ - content_id - ] = updated_content + # Here we use cast because the from_dict() method returns a object of + # type BaseChange, which is a parent class for ExplorationChange. + # This cast assures the static type checker that the 'change_object' + # variable is of type ExplorationChange, allowing us to access its + # specific attributes and methods without type errors. + change_object = cast( + exp_domain.ExplorationChange, + exp_domain.ExplorationChange.from_dict(change), + ) + content_id_to_content_values = exp_services.get_content_updates_from_cmd_edit_state_property_change( + change_object + ) + + for ( + content_id, + content_value, + ) in content_id_to_content_values.items(): + language_code_to_contents_mapping.setdefault('en', {})[ + content_id + ] = content_value elif cmd == exp_domain.CMD_EDIT_TRANSLATION: # CMD_EDIT_TRANSLATION is used to fetch the updated content for # the translations in other languages. @@ -1197,23 +1519,20 @@ def regenerate_voiceovers_on_exploration_update( language_code_to_contents_mapping.setdefault(language_code, {})[ content_id ] = updated_content - - _regenerate_voiceovers_for_given_contents( + logging.info( + 'Voiceover regeneration logs: %s' % language_code_to_contents_mapping + ) + regenerate_voiceovers_for_given_contents( exploration_id, - exploration_title, exploration_version, language_code_to_contents_mapping, - date_time, - author_id, - task_run_id=task_run_id, + task_run_id, ) def regenerate_voiceovers_on_exploration_added_to_topic( exploration_id: str, - date_time: str, - author_id: str, - task_run_id: Optional[str] = None, + task_run_id: str, ) -> None: """Regenerates all voiceovers (in English and in all the available translated languages) for the given exploration when it is curated — i.e., @@ -1225,11 +1544,13 @@ def regenerate_voiceovers_on_exploration_added_to_topic( Args: exploration_id: str. The ID of the exploration to regenerate voiceovers for. - date_time: str. The timestamp when the exploration was curated. - author_id: str. The ID of the user who curated the exploration. - task_run_id: str|None. The unique identifier for the voiceover + task_run_id: str. The unique identifier for the voiceover regeneration task. """ + logging.info( + 'Voiceover regeneration logs: Started regenerating voiceovers for ' + 'exploration with ID: %s when it is added to topic.' % exploration_id + ) # A dictionary where each key is a language code, and each value is a # content mapping dictionary. The content mapping dictionary contains # content IDs as keys and their corresponding HTML content as values. @@ -1239,7 +1560,6 @@ def regenerate_voiceovers_on_exploration_added_to_topic( assert exploration is not None exploration_version = exploration.version - exploration_title = exploration.title # Retrieve all English-language contents from the exploration. language_code_to_contents_mapping.update( @@ -1259,23 +1579,22 @@ def regenerate_voiceovers_on_exploration_added_to_topic( entity_translations ) ) + logging.info( + 'Voiceover regeneration logs: %s' % language_code_to_contents_mapping + ) - _regenerate_voiceovers_for_given_contents( + regenerate_voiceovers_for_given_contents( exploration_id, - exploration_title, exploration_version, language_code_to_contents_mapping, - date_time, - author_id, - task_run_id=task_run_id, + task_run_id, ) def regenerate_voiceovers_of_exploration_for_given_language_accent( exploration_id: str, language_accent_code: str, - author_id: str, - date_time: str, + cloud_task_run_id: str, ) -> None: """Regenerates voiceovers of the provided exploration for the given language accent code. @@ -1288,14 +1607,20 @@ def regenerate_voiceovers_of_exploration_for_given_language_accent( need to be regenerated. language_accent_code: str. The language accent code for which voiceovers need to be regenerated. - author_id: str. The ID of the user who initiated the voiceover - regeneration. - date_time: str. The timestamp when the voiceover regeneration was - initiated. + cloud_task_run_id: str. The unique identifier for the voiceover + regeneration task. Raises: Exception. If the provided language accent code is invalid. """ + logging.info( + 'Voiceover regeneration logs: Started regenerating voiceovers for ' + 'exploration with ID: %s and language accent code: %s.' + % ( + exploration_id, + language_accent_code, + ) + ) # A dictionary where each key is a language code, and each value is a # content mapping dictionary. The content mapping dictionary contains # content IDs as keys and their corresponding HTML content as values. @@ -1314,9 +1639,8 @@ def regenerate_voiceovers_of_exploration_for_given_language_accent( assert exploration is not None exploration_version = exploration.version - exploration_title = exploration.title - if language_code == constants.DEFAULT_LANGUAGE_CODE: + if language_code == constants.constants.DEFAULT_LANGUAGE_CODE: # Retrieve all English-language contents from the exploration. language_code_to_contents_mapping.update( extract_english_voiceover_texts_from_exploration(exploration) @@ -1335,49 +1659,48 @@ def regenerate_voiceovers_of_exploration_for_given_language_accent( [entity_translation] ) ) - - _regenerate_voiceovers_for_given_contents( + logging.info( + 'Voiceover regeneration logs: %s' % language_code_to_contents_mapping + ) + regenerate_voiceovers_for_given_contents( exploration_id, - exploration_title, exploration_version, language_code_to_contents_mapping, - date_time, - author_id, + cloud_task_run_id, specific_language_accent_code=language_accent_code, ) -def generate_voiceover_from_translated_content( - exploration_id: str, - exploration_version: int, - translation_content: str, - content_id: str, - language_code: str, +def regenerate_voiceovers_after_accepting_suggestion( + suggestion_id: str, + task_run_id: str, ) -> None: - """Generates a new voiceover for translated content once translation - suggestions are approved by reviewers. + """Regenerates voiceover for the given content ID and language code after + accepting a translation suggestion. Args: - exploration_id: str. The ID of the exploration. - exploration_version: int. The version of the exploration. - translation_content: str. The translated content for which the - voiceover needs to be generated. - content_id: str. The content ID for which the voiceover is being - generated. - language_code: str. The language code for the voiceover. + suggestion_id: str. The ID of the suggestion. + task_run_id: str. The ID of the task run. """ + logging.info( + 'Voiceover regeneration logs: Started regenerating voiceovers after ' + 'accepting suggestion with ID: %s.' % suggestion_id, + ) + suggestion = suggestion_services.get_suggestion_by_id(suggestion_id) + translated_html_content = suggestion.change_cmd.translation_html + content_id = suggestion.change_cmd.content_id + language_code = suggestion.language_code + exploration_id = suggestion.target_id + exploration_version = suggestion.target_version_at_submission language_code_to_contents_mapping = { - language_code: {content_id: translation_content} + language_code: {content_id: translated_html_content} } - exploration = exp_fetchers.get_exploration_by_id(exploration_id) - assert exploration is not None - exploration_title = exploration.title - - _regenerate_voiceovers_for_given_contents( + logging.info( + 'Voiceover regeneration logs: %s' % language_code_to_contents_mapping + ) + regenerate_voiceovers_for_given_contents( exploration_id, - exploration_title, exploration_version, language_code_to_contents_mapping, - datetime.datetime.utcnow().isoformat(), - feconf.SYSTEM_COMMITTER_ID, + task_run_id, ) diff --git a/core/domain/voiceover_services_test.py b/core/domain/voiceover_services_test.py index c4a81cc0b4917..b219489302975 100644 --- a/core/domain/voiceover_services_test.py +++ b/core/domain/voiceover_services_test.py @@ -18,9 +18,9 @@ from __future__ import annotations -import datetime import json import os +import uuid from core import feconf, schema_utils from core.constants import constants @@ -28,6 +28,8 @@ from core.domain import platform_parameter_list as param_list from core.domain import ( state_domain, + suggestion_services, + taskqueue_services, translation_domain, translation_fetchers, voiceover_domain, @@ -781,6 +783,67 @@ def test_should_successfully_get_autogeneratable_accents(self) -> None: ) self.assertItemsEqual(autogeneratable_accents_for_hindi, []) + def test_get_new_auto_voiceover_accent_returns_new_enabled_accent( + self, + ) -> None: + existing_language_accent_mapping: Dict[str, Dict[str, bool]] = { + 'en': {'en-US': True}, + 'hi': {'hi-IN': False}, + } + voiceover_services.save_language_accent_support( + language_codes_mapping=existing_language_accent_mapping + ) + updated_language_accent_mapping: Dict[str, Dict[str, bool]] = { + 'en': {'en-US': True, 'en-IN': True}, + 'hi': {'hi-IN': False}, + } + + new_accent_code = voiceover_services.get_new_auto_voiceover_accent( + updated_language_accent_mapping + ) + + self.assertEqual(new_accent_code, 'en-IN') + + def test_get_new_auto_voiceover_accent_returns_enabled_existing_accent( + self, + ) -> None: + existing_language_accent_mapping: Dict[str, Dict[str, bool]] = { + 'en': {'en-US': True}, + 'hi': {'hi-IN': False}, + } + voiceover_services.save_language_accent_support( + language_codes_mapping=existing_language_accent_mapping + ) + updated_language_accent_mapping: Dict[str, Dict[str, bool]] = { + 'en': {'en-US': True}, + 'hi': {'hi-IN': True}, + } + + new_accent_code = voiceover_services.get_new_auto_voiceover_accent( + updated_language_accent_mapping + ) + + self.assertEqual(new_accent_code, 'hi-IN') + + def test_get_new_auto_voiceover_accent_returns_none_when_no_new_enabled_accent( + self, + ) -> None: + existing_language_accent_mapping: Dict[str, Dict[str, bool]] = { + 'en': {'en-US': True}, + } + voiceover_services.save_language_accent_support( + language_codes_mapping=existing_language_accent_mapping + ) + updated_language_accent_mapping: Dict[str, Dict[str, bool]] = { + 'en': {'en-US': True, 'en-IN': False}, + } + + new_accent_code = voiceover_services.get_new_auto_voiceover_accent( + updated_language_accent_mapping + ) + + self.assertIsNone(new_accent_code) + class VoiceoversLanguageAccentConstantsTests(test_utils.GenericTestBase): """Unit tests to validate the language-accent information saved as @@ -788,9 +851,8 @@ class VoiceoversLanguageAccentConstantsTests(test_utils.GenericTestBase): """ def test_get_language_accent_master_list_works_correctly(self) -> None: - file_path = os.path.join( - feconf.VOICEOVERS_DATA_DIR, 'language_accent_master_list.json' - ) + file_path = os.path.join('assets', 'language_accent_master_list.json') + with open(file_path, 'r', encoding='utf-8') as f: language_accent_master_list: Dict[str, Dict[str, str]] = json.loads( f.read() @@ -808,7 +870,7 @@ def test_get_autogeneratable_language_accent_list_works_correctly( self, ) -> None: file_path = os.path.join( - feconf.VOICEOVERS_DATA_DIR, + 'assets', 'autogeneratable_language_accent_list.json', ) with open(file_path, 'r', encoding='utf-8') as f: @@ -933,6 +995,31 @@ def test_should_get_correct_language_code_for_given_accent(self) -> None: expected_language_code, ) + def test_validate_language_accent_code_for_autogeneration(self) -> None: + invalid_accent_code = 'en-XX' + self.assertFalse( + voiceover_services.is_accent_code_valid_for_autogeneration( + invalid_accent_code + ) + ) + + # Here we use MyPy ignore because here we assign type int to + # type str. This is done to test the validation of the + # is_accent_code_valid_for_autogeneration method. + invalid_accent_code = 5 # type: ignore[assignment] + self.assertFalse( + voiceover_services.is_accent_code_valid_for_autogeneration( + invalid_accent_code + ) + ) + + valid_accent_code = 'en-US' + self.assertTrue( + voiceover_services.is_accent_code_valid_for_autogeneration( + valid_accent_code + ) + ) + class VoiceoverRegenerationTests(test_utils.GenericTestBase): """Test class to verify voiceover regeneration across various scenarios, @@ -987,12 +1074,8 @@ def test_should_regenerate_voiceover_for_curated_exploration_content_update( self, ) -> None: exploration_id = 'exp_id_1' - exploration_title = 'Test Exploration' exploration_version = 2 self.signup('tester@org.com', 'tester') - author_id = self.get_user_id_from_email('tester@org.com') - - date_time = datetime.datetime.utcnow().isoformat() commit1 = exp_models.ExplorationCommitLogEntryModel.create( exploration_id, @@ -1030,18 +1113,34 @@ def test_should_regenerate_voiceover_for_curated_exploration_content_update( ) self.assertEqual(len(entity_voiceovers_models), 0) - with self.swap( - voiceover_services, - 'send_email_to_voiceover_admins_and_tech_leads_after_regeneration', - self.mock_send_email_to_voiceover_admins_and_tech_leads, - ): - voiceover_services.regenerate_voiceovers_on_exploration_update( - exploration_id=exploration_id, - exploration_title=exploration_title, - exploration_version=exploration_version, - author_id=author_id, - date_time=date_time, - ) + parent_cloud_task_model_id = 'cloud_task_model_id' + task_name = 'projects/%s/locations/%s/queues/%s/tasks/%s' % ( + 'dev-project-id', + 'us-central', + 'voiceover-regeneration', + uuid.uuid4().hex, + ) + function_id = 'regenerate_voiceovers_on_exploration_update' + taskqueue_services.create_new_cloud_task_model( + parent_cloud_task_model_id, task_name, function_id + ) + voiceover_services.regenerate_voiceovers_on_exploration_update( + exploration_id, exploration_version, parent_cloud_task_model_id + ) + updated_cloud_task_runs = sorted( + taskqueue_services.get_all_cloud_task_runs(), + key=lambda task_run: task_run.created_on, + ) + for cloud_run in updated_cloud_task_runs: + if ( + cloud_run.function_id + == 'regenerate_voiceovers_for_batch_contents' + ): + voiceover_services.regenerate_voiceovers_for_batch_contents( + exploration_id, + parent_cloud_task_model_id, + cloud_run.task_run_id, + ) entity_voiceovers_models = ( voiceover_services.get_entity_voiceovers_for_given_exploration( @@ -1059,11 +1158,8 @@ def test_should_regenerate_voiceover_for_curated_exploration_content_update( def test_should_regenerate_voiceover_for_translation_update(self) -> None: exploration_id = 'exp_id_1' - exploration_title = 'Test Exploration' exploration_version = 2 self.signup('tester@org.com', 'tester') - author_id = self.get_user_id_from_email('tester@org.com') - date_time = datetime.datetime.utcnow().isoformat() commit1 = exp_models.ExplorationCommitLogEntryModel.create( exploration_id, @@ -1098,18 +1194,34 @@ def test_should_regenerate_voiceover_for_translation_update(self) -> None: ) self.assertEqual(len(entity_voiceovers_models), 0) - with self.swap( - voiceover_services, - 'send_email_to_voiceover_admins_and_tech_leads_after_regeneration', - self.mock_send_email_to_voiceover_admins_and_tech_leads, - ): - voiceover_services.regenerate_voiceovers_on_exploration_update( - exploration_id=exploration_id, - exploration_title=exploration_title, - exploration_version=exploration_version, - author_id=author_id, - date_time=date_time, - ) + parent_cloud_task_model_id = 'cloud_task_model_id' + task_name = 'projects/%s/locations/%s/queues/%s/tasks/%s' % ( + 'dev-project-id', + 'us-central', + 'voiceover-regeneration', + uuid.uuid4().hex, + ) + function_id = 'regenerate_voiceovers_on_exploration_update' + taskqueue_services.create_new_cloud_task_model( + parent_cloud_task_model_id, task_name, function_id + ) + voiceover_services.regenerate_voiceovers_on_exploration_update( + exploration_id, exploration_version, parent_cloud_task_model_id + ) + updated_cloud_task_runs = sorted( + taskqueue_services.get_all_cloud_task_runs(), + key=lambda task_run: task_run.created_on, + ) + for cloud_run in updated_cloud_task_runs: + if ( + cloud_run.function_id + == 'regenerate_voiceovers_for_batch_contents' + ): + voiceover_services.regenerate_voiceovers_for_batch_contents( + exploration_id, + parent_cloud_task_model_id, + cloud_run.task_run_id, + ) entity_voiceovers_models = ( voiceover_services.get_entity_voiceovers_for_given_exploration( @@ -1125,10 +1237,7 @@ def test_should_raise_exception_when_change_diff_is_not_accessible( self, ) -> None: exploration_id = 'exp_id_1' - exploration_title = 'Test Exploration' exploration_version = 2 - author_id = 'nik' - date_time = datetime.datetime.utcnow().isoformat() error = ( 'Could not fetch change diff for exploration %s, version %s during ' @@ -1136,24 +1245,29 @@ def test_should_raise_exception_when_change_diff_is_not_accessible( % (exploration_id, str(exploration_version)) ) + parent_cloud_task_model_id = 'cloud_task_model_id' + task_name = 'projects/%s/locations/%s/queues/%s/tasks/%s' % ( + 'dev-project-id', + 'us-central', + 'voiceover-regeneration', + uuid.uuid4().hex, + ) + function_id = 'regenerate_voiceovers_on_exploration_update' + taskqueue_services.create_new_cloud_task_model( + parent_cloud_task_model_id, task_name, function_id + ) + with self.assertRaisesRegex(Exception, error): voiceover_services.regenerate_voiceovers_on_exploration_update( - exploration_id=exploration_id, - exploration_title=exploration_title, - exploration_version=exploration_version, - author_id=author_id, - date_time=date_time, + exploration_id, exploration_version, parent_cloud_task_model_id ) def test_should_not_regenerate_voiceover_for_non_supported_accents( self, ) -> None: exploration_id = 'exp_id_1' - exploration_title = 'Test Exploration' exploration_version = 2 self.signup('tester@org.com', 'tester') - author_id = self.get_user_id_from_email('tester@org.com') - date_time = datetime.datetime.utcnow().isoformat() commit1 = exp_models.ExplorationCommitLogEntryModel.create( exploration_id, @@ -1195,18 +1309,34 @@ def test_should_not_regenerate_voiceover_for_non_supported_accents( ) self.assertEqual(len(entity_voiceovers_models), 0) - with self.swap( - voiceover_services, - 'send_email_to_voiceover_admins_and_tech_leads_after_regeneration', - self.mock_send_email_to_voiceover_admins_and_tech_leads, - ): - voiceover_services.regenerate_voiceovers_on_exploration_update( - exploration_id=exploration_id, - exploration_title=exploration_title, - exploration_version=exploration_version, - author_id=author_id, - date_time=date_time, - ) + parent_cloud_task_model_id = 'cloud_task_model_id' + task_name = 'projects/%s/locations/%s/queues/%s/tasks/%s' % ( + 'dev-project-id', + 'us-central', + 'voiceover-regeneration', + uuid.uuid4().hex, + ) + function_id = 'regenerate_voiceovers_on_exploration_update' + taskqueue_services.create_new_cloud_task_model( + parent_cloud_task_model_id, task_name, function_id + ) + voiceover_services.regenerate_voiceovers_on_exploration_update( + exploration_id, exploration_version, parent_cloud_task_model_id + ) + updated_cloud_task_runs = sorted( + taskqueue_services.get_all_cloud_task_runs(), + key=lambda task_run: task_run.created_on, + ) + for cloud_run in updated_cloud_task_runs: + if ( + cloud_run.function_id + == 'regenerate_voiceovers_for_batch_contents' + ): + voiceover_services.regenerate_voiceovers_for_batch_contents( + exploration_id, + parent_cloud_task_model_id, + cloud_run.task_run_id, + ) entity_voiceovers_models = ( voiceover_services.get_entity_voiceovers_for_given_exploration( @@ -1297,25 +1427,7 @@ def test_should_send_emails_to_voiceover_admins_and_tech_leads( ) def test_should_raise_error_while_regenerating_voiceover(self) -> None: exploration_id = 'exp_id_1' - exploration_title = 'Test Exploration' exploration_version = 2 - self.signup('tester@org.com', 'tester') - author_id = self.get_user_id_from_email('tester@org.com') - date_time = '2025-08-01T08:35:05.864077' - cloud_task_run_model_id = ( - cloud_task_models.CloudTaskRunModel.get_new_id() - ) - cloud_task_models.CloudTaskRunModel.create_cloud_task_run_model( - cloud_task_run_model_id=cloud_task_run_model_id, - cloud_task_name=( - 'projects/dev-project-id/locations/us-central1/queues/' - 'voiceover-regeneration/tasks/task1' - ), - latest_job_state='RUNNING', - function_id='update_stats', - current_retry_attempt=1, - ) - commit1 = exp_models.ExplorationCommitLogEntryModel.create( exploration_id, 2, @@ -1363,28 +1475,52 @@ def mock_regenerate_voiceovers_of_exploration( _language_accent_code: str, ) -> List[Tuple[str, str]]: errors_while_voiceover_regeneration = [ - ('content5', 'Error 1 occurred'), + ('content_5', 'Error 1 occurred'), ] return errors_while_voiceover_regeneration + parent_cloud_task_model_id = 'cloud_task_model_id' + task_name = 'projects/%s/locations/%s/queues/%s/tasks/%s' % ( + 'dev-project-id', + 'us-central', + 'voiceover-regeneration', + uuid.uuid4().hex, + ) + function_id = 'regenerate_voiceovers_on_exploration_update' + taskqueue_services.create_new_cloud_task_model( + parent_cloud_task_model_id, task_name, function_id + ) + + voiceover_services.regenerate_voiceovers_on_exploration_update( + exploration_id, exploration_version, parent_cloud_task_model_id + ) + + updated_cloud_task_runs = sorted( + taskqueue_services.get_all_cloud_task_runs(), + key=lambda task_run: task_run.created_on, + ) + with self.swap( voiceover_regeneration_services, 'regenerate_voiceovers_of_exploration', mock_regenerate_voiceovers_of_exploration, ): - voiceover_services.regenerate_voiceovers_on_exploration_update( - exploration_id=exploration_id, - exploration_title=exploration_title, - exploration_version=exploration_version, - author_id=author_id, - date_time=date_time, - task_run_id=cloud_task_run_model_id, - ) + for cloud_run in updated_cloud_task_runs: + if ( + cloud_run.function_id + == 'regenerate_voiceovers_for_batch_contents' + ): + voiceover_services.regenerate_voiceovers_for_batch_contents( + exploration_id, + parent_cloud_task_model_id, + cloud_run.task_run_id, + ) all_models: Sequence[email_models.SentEmailModel] = ( email_models.SentEmailModel.get_all().fetch() ) - self.assertEqual(len(all_models), 3) + + self.assertEqual(len(all_models), 2) expected_html_body = ( 'Hi Voiceover Admins,

tester has initiated the generation ' @@ -1407,13 +1543,104 @@ def mock_regenerate_voiceovers_of_exploration( self.assertEqual(email_model.html_body, expected_html_body) updated_cloud_task_run_model = cloud_task_models.CloudTaskRunModel.get( - cloud_task_run_model_id + parent_cloud_task_model_id ) assert updated_cloud_task_run_model is not None self.assertEqual( updated_cloud_task_run_model.latest_job_state, 'PERMANENTLY_FAILED' ) + def test_should_raise_exception_while_regenerating_voiceovers_in_batch( + self, + ) -> None: + exploration_id = 'exp_id_1' + exploration_version = 2 + commit1 = exp_models.ExplorationCommitLogEntryModel.create( + exploration_id, + 2, + self.committer_1_id, + 'msg', + 'create', + [ + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'property_name': exp_domain.STATE_PROPERTY_CONTENT, + 'state_name': 'State 1', + 'old_value': { + 'content_id': 'content_5', + 'html': self.old_content_html, + }, + 'new_value': { + 'content_id': 'content_5', + 'html': self.new_content_html, + }, + } + ], + constants.ACTIVITY_STATUS_PRIVATE, + False, + ) + + commit1.exploration_id = exploration_id + commit1.update_timestamps() + commit1.put() + + self.voiceover_autogeneration_policy_model.language_codes_mapping = { + 'en': {'en-US': True} + } + + entity_voiceovers_models = ( + voiceover_services.get_entity_voiceovers_for_given_exploration( + exploration_id, 'exploration', exploration_version + ) + ) + self.assertEqual(len(entity_voiceovers_models), 0) + + def mock_regenerate_voiceovers_of_exploration( + _exploration_id: str, + _exploration_version: int, + _content_id_to_content_html: Dict[str, str], + _language_accent_code: str, + ) -> List[Tuple[str, str]]: + raise Exception( + 'Expected error raised during voiceover regeneration!' + ) + + parent_cloud_task_model_id = 'cloud_task_model_id' + task_name = 'projects/%s/locations/%s/queues/%s/tasks/%s' % ( + 'dev-project-id', + 'us-central', + 'voiceover-regeneration', + uuid.uuid4().hex, + ) + function_id = 'regenerate_voiceovers_on_exploration_update' + taskqueue_services.create_new_cloud_task_model( + parent_cloud_task_model_id, task_name, function_id + ) + + voiceover_services.regenerate_voiceovers_on_exploration_update( + exploration_id, exploration_version, parent_cloud_task_model_id + ) + + updated_cloud_task_runs = sorted( + taskqueue_services.get_all_cloud_task_runs(), + key=lambda task_run: task_run.created_on, + ) + with self.swap( + voiceover_regeneration_services, + 'regenerate_voiceovers_of_exploration', + mock_regenerate_voiceovers_of_exploration, + ): + for cloud_run in updated_cloud_task_runs: + if ( + cloud_run.function_id + == 'regenerate_voiceovers_for_batch_contents' + ): + voiceover_services.regenerate_voiceovers_for_batch_contents( + exploration_id, + parent_cloud_task_model_id, + cloud_run.task_run_id, + ) + def _create_exploration_and_arabic_translation( self, exploration_id: str, language_code: str ) -> None: @@ -1423,6 +1650,9 @@ def _create_exploration_and_arabic_translation( exploration_id: str. The ID of the exploration to create. language_code: str. The language code for the translation. """ + self.signup(self.OWNER_EMAIL, self.OWNER_USERNAME) + owner_id = self.get_user_id_from_email(self.OWNER_EMAIL) + exploration = exp_domain.Exploration.create_default_exploration( exploration_id, title='A Title', @@ -1470,7 +1700,7 @@ def _create_exploration_and_arabic_translation( ] exploration.add_state('Second', 'content_2', 'content-3') - exp_services.save_new_exploration(exploration_id, exploration) + exp_services.save_new_exploration(owner_id, exploration) arabic_translation = 'المحتوى المترجم' translations_mapping: Dict[str, feconf.TranslatedContentDict] = { @@ -1510,8 +1740,6 @@ def test_should_regenerate_voiceover_for_arabic_language(self) -> None: exploration_id = 'exp_id_1' exploration_version = 1 self.signup('tester@org.com', 'tester') - author_id = self.get_user_id_from_email('tester@org.com') - date_time = datetime.datetime.utcnow().isoformat() self._create_exploration_and_arabic_translation( exploration_id, language_code @@ -1535,16 +1763,34 @@ def test_should_regenerate_voiceover_for_arabic_language(self) -> None: self.assertEqual(entity_translation.language_code, language_code) self.assertEqual(entity_voiceovers.voiceovers_mapping, {}) - with self.swap( - voiceover_services, - 'send_email_to_voiceover_admins_and_tech_leads_after_regeneration', - self.mock_send_email_to_voiceover_admins_and_tech_leads, - ): - ( - voiceover_services.regenerate_voiceovers_of_exploration_for_given_language_accent( - exploration_id, language_accent_code, author_id, date_time + parent_cloud_task_model_id = 'cloud_task_model_id' + task_name = 'projects/%s/locations/%s/queues/%s/tasks/%s' % ( + 'dev-project-id', + 'us-central', + 'voiceover-regeneration', + uuid.uuid4().hex, + ) + function_id = 'regenerate_voiceovers_on_exploration_update' + taskqueue_services.create_new_cloud_task_model( + parent_cloud_task_model_id, task_name, function_id + ) + voiceover_services.regenerate_voiceovers_of_exploration_for_given_language_accent( + exploration_id, language_accent_code, parent_cloud_task_model_id + ) + updated_cloud_task_runs = sorted( + taskqueue_services.get_all_cloud_task_runs(), + key=lambda task_run: task_run.created_on, + ) + for cloud_run in updated_cloud_task_runs: + if ( + cloud_run.function_id + == 'regenerate_voiceovers_for_batch_contents' + ): + voiceover_services.regenerate_voiceovers_for_batch_contents( + exploration_id, + parent_cloud_task_model_id, + cloud_run.task_run_id, ) - ) entity_voiceovers = ( voiceover_services.get_voiceovers_for_given_language_accent_code( @@ -1585,15 +1831,13 @@ def test_should_raise_exception_when_language_accent_code_is_not_supported( language_accent_code = 'ar-non-existent' exploration_id = 'exp_id_1' self.signup('tester@org.com', 'tester') - author_id = self.get_user_id_from_email('tester@org.com') - date_time = datetime.datetime.utcnow().isoformat() with self.assertRaisesRegex( Exception, 'Invalid language accent code: %s' % language_accent_code ): ( voiceover_services.regenerate_voiceovers_of_exploration_for_given_language_accent( - exploration_id, language_accent_code, author_id, date_time + exploration_id, language_accent_code, 'cloud_task_run_id' ) ) @@ -1603,8 +1847,6 @@ def test_should_regenerate_voiceover_for_english_language(self) -> None: exploration_id = 'exp_id_1' exploration_version = 1 self.signup('tester@org.com', 'tester') - author_id = self.get_user_id_from_email('tester@org.com') - date_time = datetime.datetime.utcnow().isoformat() self._create_exploration_and_arabic_translation( exploration_id, language_code @@ -1620,16 +1862,34 @@ def test_should_regenerate_voiceover_for_english_language(self) -> None: ) self.assertEqual(entity_voiceovers.voiceovers_mapping, {}) - with self.swap( - voiceover_services, - 'send_email_to_voiceover_admins_and_tech_leads_after_regeneration', - self.mock_send_email_to_voiceover_admins_and_tech_leads, - ): - ( - voiceover_services.regenerate_voiceovers_of_exploration_for_given_language_accent( - exploration_id, language_accent_code, author_id, date_time + parent_cloud_task_model_id = 'cloud_task_model_id' + task_name = 'projects/%s/locations/%s/queues/%s/tasks/%s' % ( + 'dev-project-id', + 'us-central', + 'voiceover-regeneration', + uuid.uuid4().hex, + ) + function_id = 'regenerate_voiceovers_on_exploration_update' + taskqueue_services.create_new_cloud_task_model( + parent_cloud_task_model_id, task_name, function_id + ) + voiceover_services.regenerate_voiceovers_of_exploration_for_given_language_accent( + exploration_id, language_accent_code, parent_cloud_task_model_id + ) + updated_cloud_task_runs = sorted( + taskqueue_services.get_all_cloud_task_runs(), + key=lambda task_run: task_run.created_on, + ) + for cloud_run in updated_cloud_task_runs: + if ( + cloud_run.function_id + == 'regenerate_voiceovers_for_batch_contents' + ): + voiceover_services.regenerate_voiceovers_for_batch_contents( + exploration_id, + parent_cloud_task_model_id, + cloud_run.task_run_id, ) - ) entity_voiceovers = ( voiceover_services.get_voiceovers_for_given_language_accent_code( @@ -1644,23 +1904,22 @@ def test_should_regenerate_voiceover_for_english_language(self) -> None: entity_voiceovers.language_accent_code, language_accent_code ) self.assertNotEqual(entity_voiceovers.voiceovers_mapping, {}) - self.assertEqual( + default_audio_offset = [ + {'token': 'This', 'audio_offset_msecs': 0.0}, + {'token': 'is', 'audio_offset_msecs': 100.0}, + {'token': 'a', 'audio_offset_msecs': 200.0}, + {'token': 'test', 'audio_offset_msecs': 300.0}, + {'token': 'text', 'audio_offset_msecs': 400.0}, + ] + self.assertDictEqual( entity_voiceovers.automated_voiceovers_audio_offsets_msecs, { - 'content_0': [ - {'token': 'This', 'audio_offset_msecs': 0.0}, - {'token': 'is', 'audio_offset_msecs': 100.0}, - {'token': 'a', 'audio_offset_msecs': 200.0}, - {'token': 'test', 'audio_offset_msecs': 300.0}, - {'token': 'text', 'audio_offset_msecs': 400.0}, - ], - 'feedback_1': [ - {'token': 'This', 'audio_offset_msecs': 0.0}, - {'token': 'is', 'audio_offset_msecs': 100.0}, - {'token': 'a', 'audio_offset_msecs': 200.0}, - {'token': 'test', 'audio_offset_msecs': 300.0}, - {'token': 'text', 'audio_offset_msecs': 400.0}, - ], + 'content_0': default_audio_offset, + 'feedback_1': default_audio_offset, + 'content_2': default_audio_offset, + 'default_outcome_1': default_audio_offset, + 'content-3': default_audio_offset, + 'ca_placeholder_2': default_audio_offset, }, ) @@ -1671,8 +1930,6 @@ def test_should_regenerate_voiceover_when_exploration_is_curated( exploration_id = 'exp_id_1' exploration_version = 1 self.signup('tester@org.com', 'tester') - author_id = self.get_user_id_from_email('tester@org.com') - date_time = datetime.datetime.utcnow().isoformat() self._create_exploration_and_arabic_translation( exploration_id, language_code @@ -1686,11 +1943,11 @@ def test_should_regenerate_voiceover_when_exploration_is_curated( ) self.assertEqual(len(entity_voiceovers_list), 0) - cloud_task_run_model_id = ( + parent_cloud_task_run_model_id = ( cloud_task_models.CloudTaskRunModel.get_new_id() ) cloud_task_models.CloudTaskRunModel.create_cloud_task_run_model( - cloud_task_run_model_id=cloud_task_run_model_id, + cloud_task_run_model_id=parent_cloud_task_run_model_id, cloud_task_name=( 'projects/dev-project-id/locations/us-central1/queues/' 'voiceover-regeneration/tasks/task1' @@ -1702,10 +1959,10 @@ def test_should_regenerate_voiceover_when_exploration_is_curated( voiceover_regeneration_task_mapping_model_id = '%s:%s' % ( exploration_id, - cloud_task_run_model_id, + parent_cloud_task_run_model_id, ) voiceover_regeneration_task_mapping_model = ( - cloud_task_models.VoiceoverRegenerationTaskMappingModel.get( + cloud_task_models.VoiceoverRegenerationJobModel.get( voiceover_regeneration_task_mapping_model_id, strict=False ) ) @@ -1720,11 +1977,23 @@ def test_should_regenerate_voiceover_when_exploration_is_curated( ( voiceover_services.regenerate_voiceovers_on_exploration_added_to_topic( exploration_id, - date_time, - author_id, - cloud_task_run_model_id, + parent_cloud_task_run_model_id, ) ) + updated_cloud_task_runs = sorted( + taskqueue_services.get_all_cloud_task_runs(), + key=lambda task_run: task_run.created_on, + ) + for cloud_run in updated_cloud_task_runs: + if ( + cloud_run.function_id + == 'regenerate_voiceovers_for_batch_contents' + ): + voiceover_services.regenerate_voiceovers_for_batch_contents( + exploration_id, + parent_cloud_task_run_model_id, + cloud_run.task_run_id, + ) entity_voiceovers_list = ( voiceover_services.get_entity_voiceovers_for_given_exploration( @@ -1739,13 +2008,27 @@ def test_should_regenerate_voiceover_when_exploration_is_curated( self.assertEqual(len(entity_voiceovers_list), 3) voiceover_regeneration_task_mapping_model = ( - cloud_task_models.VoiceoverRegenerationTaskMappingModel.get( + cloud_task_models.VoiceoverRegenerationJobModel.get( voiceover_regeneration_task_mapping_model_id, strict=False ) ) expected_language_accent_to_content_status_map = { - 'en-US': {'content_0': 'SUCCEEDED', 'feedback_1': 'SUCCEEDED'}, - 'en-IN': {'content_0': 'SUCCEEDED', 'feedback_1': 'SUCCEEDED'}, + 'en-US': { + 'ca_placeholder_2': 'SUCCEEDED', + 'content-3': 'SUCCEEDED', + 'content_0': 'SUCCEEDED', + 'content_2': 'SUCCEEDED', + 'default_outcome_1': 'SUCCEEDED', + 'feedback_1': 'SUCCEEDED', + }, + 'en-IN': { + 'ca_placeholder_2': 'SUCCEEDED', + 'content-3': 'SUCCEEDED', + 'content_0': 'SUCCEEDED', + 'content_2': 'SUCCEEDED', + 'default_outcome_1': 'SUCCEEDED', + 'feedback_1': 'SUCCEEDED', + }, 'ar-AE': {'content_0': 'SUCCEEDED', 'feedback_1': 'SUCCEEDED'}, } @@ -1764,16 +2047,24 @@ def test_should_regenerate_voiceover_when_exploration_is_curated( ) self.assertEqual( voiceover_regeneration_task_mapping_model.cloud_task_run_id, - cloud_task_run_model_id, + parent_cloud_task_run_model_id, ) def test_should_generate_voiceover_for_translated_content(self) -> None: - language_code = 'ar' + language_code = 'hi' exploration_id = 'exp_id_1' + language_codes_mapping: Dict[str, Dict[str, bool]] = { + 'en': {'en-US': True}, + 'hi': {'hi-IN': True}, + } + voiceover_services.save_language_accent_support( + language_codes_mapping=language_codes_mapping + ) exploration_version = 1 - language_accent_code = 'ar-AE' - translation_content = 'المحتوى المترجم' - content_id = 'content_id_0' + language_accent_code = 'hi-IN' + + self.signup(self.OWNER_EMAIL, self.OWNER_USERNAME) + owner_id = self.get_user_id_from_email(self.OWNER_EMAIL) exploration = exp_domain.Exploration.create_default_exploration( exploration_id, @@ -1782,7 +2073,27 @@ def test_should_generate_voiceover_for_translated_content(self) -> None: objective='An Objective', ) exploration.states['Introduction'].content.html = 'First Card!' - exp_services.save_new_exploration(exploration_id, exploration) + exp_services.save_new_exploration(owner_id, exploration) + + add_translation_change_dict = { + 'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION, + 'state_name': 'Introduction', + 'content_id': 'content_0', + 'language_code': language_code, + 'content_html': 'First Card!', + 'translation_html': 'पहला कार्ड', + 'data_format': 'html', + } + + translation_suggestion = suggestion_services.create_suggestion( + feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT, + feconf.ENTITY_TYPE_EXPLORATION, + exploration_id, + exploration.version, + owner_id, + add_translation_change_dict, + 'test description', + ) entity_voiceovers = ( voiceover_services.get_voiceovers_for_given_language_accent_code( @@ -1794,21 +2105,45 @@ def test_should_generate_voiceover_for_translated_content(self) -> None: ) self.assertEqual(entity_voiceovers.voiceovers_mapping, {}) + parent_cloud_task_model_id = 'cloud_task_model_id' + task_name = 'projects/%s/locations/%s/queues/%s/tasks/%s' % ( + 'dev-project-id', + 'us-central', + 'voiceover-regeneration', + uuid.uuid4().hex, + ) + function_id = 'regenerate_voiceovers_on_exploration_update' + taskqueue_services.create_new_cloud_task_model( + parent_cloud_task_model_id, task_name, function_id + ) + with self.swap( voiceover_services, 'send_email_to_voiceover_admins_and_tech_leads_after_regeneration', self.mock_send_email_to_voiceover_admins_and_tech_leads, ): ( - voiceover_services.generate_voiceover_from_translated_content( - exploration_id, - exploration_version, - translation_content, - content_id, - language_code, + voiceover_services.regenerate_voiceovers_after_accepting_suggestion( + translation_suggestion.suggestion_id, + parent_cloud_task_model_id, ) ) + updated_cloud_task_runs = sorted( + taskqueue_services.get_all_cloud_task_runs(), + key=lambda task_run: task_run.created_on, + ) + for cloud_run in updated_cloud_task_runs: + if ( + cloud_run.function_id + == 'regenerate_voiceovers_for_batch_contents' + ): + voiceover_services.regenerate_voiceovers_for_batch_contents( + exploration_id, + parent_cloud_task_model_id, + cloud_run.task_run_id, + ) + entity_voiceovers = ( voiceover_services.get_voiceovers_for_given_language_accent_code( feconf.ENTITY_TYPE_EXPLORATION, @@ -1817,4 +2152,3 @@ def test_should_generate_voiceover_for_translated_content(self) -> None: language_accent_code, ) ) - self.assertNotEqual(entity_voiceovers.voiceovers_mapping, {}) diff --git a/core/feature_flag_list.py b/core/feature_flag_list.py index 0d34afcc8ca24..5baedfed0575f 100644 --- a/core/feature_flag_list.py +++ b/core/feature_flag_list.py @@ -69,6 +69,9 @@ class FeatureNames(enum.Enum): SHOW_VOICEOVER_TAB_FOR_NON_CURATED_EXPLORATIONS = ( 'show_voiceover_tab_for_non_curated_explorations' ) + HIGHLIGHT_SENTENCES_DURING_AUTOMATIC_VOICEOVER_PLAYBACK = ( + 'highlight_sentences_during_automatic_voiceover_playback' + ) SHOW_RESTRUCTURED_STUDY_GUIDES = 'show_restructured_study_guides' ENABLE_TRANSLATION_OPPORTUNITIES_WITH_NEW_OPP_MODELS = ( 'enable_translation_opps_with_new_opp_models' @@ -83,6 +86,19 @@ class FeatureNames(enum.Enum): 'enable_background_voiceover_synthesis' ) ENABLE_READY_FOR_REVIEW_TEST = 'enable_ready_for_review_test' + ENABLE_FINANCIAL_LITERACY_CAMPAIGN_BANNER = ( + 'enable_financial_literacy_campaign_banner' + ) + # A separate flag is used for testing the financial literacy campaign banner with early dates. + # This allows testing the feature before the actual campaign dates that will + # be used in production. Without a separate test flag, we would need to change + # the campaign date values for testing and then update them again before + # releasing to production. That process would require additional PRs, + # cherry-picks, or hotfixes. Using a dedicated test-mode flag avoids that + # overhead and keeps testing and production configurations separate. + ENABLE_FINANCIAL_LITERACY_CAMPAIGN_BANNER_TEST_MODE = ( + 'enable_financial_literacy_campaign_banner_test_mode' + ) # Names of feature objects defined in FeatureNames should be added @@ -123,8 +139,10 @@ class FeatureNames(enum.Enum): FeatureNames.SHOW_VOICEOVER_TAB_FOR_NON_CURATED_EXPLORATIONS, FeatureNames.NEW_LESSON_PLAYER, FeatureNames.AUTOMATIC_VOICEOVER_REGENERATION_FROM_EXP, + FeatureNames.HIGHLIGHT_SENTENCES_DURING_AUTOMATIC_VOICEOVER_PLAYBACK, FeatureNames.SHOW_REGENERATED_VOICEOVERS_TO_LEARNERS, FeatureNames.ENABLE_BACKGROUND_VOICEOVER_SYNTHESIS, + FeatureNames.ENABLE_FINANCIAL_LITERACY_CAMPAIGN_BANNER_TEST_MODE, ] # Names of features in prod stage, the corresponding feature flag instances must @@ -136,8 +154,8 @@ class FeatureNames(enum.Enum): FeatureNames.EXPLORATION_EDITOR_CAN_MODIFY_TRANSLATIONS, FeatureNames.EXPLORATION_EDITOR_CAN_TAG_MISCONCEPTIONS, FeatureNames.SHOW_REDESIGNED_LEARNER_DASHBOARD, - FeatureNames.ENABLE_WORKED_EXAMPLES_RTE_COMPONENT, FeatureNames.SHOW_RESTRUCTURED_STUDY_GUIDES, + FeatureNames.ENABLE_FINANCIAL_LITERACY_CAMPAIGN_BANNER, ] # Names of features that should not be used anymore, e.g. features that are @@ -154,6 +172,7 @@ class FeatureNames(enum.Enum): FeatureNames.AUTO_UPDATE_EXP_VOICE_ARTIST_LINK, FeatureNames.LABEL_ACCENT_TO_VOICE_ARTIST, FeatureNames.ADD_VOICEOVER_WITH_ACCENT, + FeatureNames.ENABLE_WORKED_EXAMPLES_RTE_COMPONENT, ] FEATURE_FLAG_NAME_TO_DESCRIPTION_AND_FEATURE_STAGE = { @@ -285,13 +304,6 @@ class FeatureNames(enum.Enum): feature_flag_domain.ServerMode.DEV, ) ), - FeatureNames.ENABLE_WORKED_EXAMPLES_RTE_COMPONENT.value: ( - ( - 'Allows creators to add worked examples to the review material ' - 'section of skills and explanation of the study guides.', - feature_flag_domain.ServerMode.PROD, - ) - ), FeatureNames.SHOW_REGENERATED_VOICEOVERS_TO_LEARNERS.value: ( ( 'This flag allows learners to see the regenerated voiceovers ' @@ -299,6 +311,14 @@ class FeatureNames(enum.Enum): feature_flag_domain.ServerMode.TEST, ) ), + FeatureNames.HIGHLIGHT_SENTENCES_DURING_AUTOMATIC_VOICEOVER_PLAYBACK.value: ( + ( + 'This flag enables the highlighting of sentences during the ' + 'automatic voiceover playback in the exploration player and ' + 'editor pages.', + feature_flag_domain.ServerMode.TEST, + ) + ), FeatureNames.ENABLE_BACKGROUND_VOICEOVER_SYNTHESIS.value: ( ( 'The flag enables the asynchronous voiceover synthesis for the ' @@ -312,4 +332,16 @@ class FeatureNames(enum.Enum): feature_flag_domain.ServerMode.DEV, ) ), + FeatureNames.ENABLE_FINANCIAL_LITERACY_CAMPAIGN_BANNER.value: ( + ( + 'This flag enables the financial literacy campaign banner for the fundraising campaign.', + feature_flag_domain.ServerMode.PROD, + ) + ), + FeatureNames.ENABLE_FINANCIAL_LITERACY_CAMPAIGN_BANNER_TEST_MODE.value: ( + ( + 'This flag enables the financial literacy campaign banner for the fundraising campaign in test mode.', + feature_flag_domain.ServerMode.TEST, + ) + ), } diff --git a/core/feconf.py b/core/feconf.py index 5f4dea17f7fcd..c5fc0e0e3a198 100644 --- a/core/feconf.py +++ b/core/feconf.py @@ -84,7 +84,6 @@ def check_dev_mode_is_true() -> None: SAMPLE_EXPLORATIONS_DIR = os.path.join('data', 'explorations') SAMPLE_COLLECTIONS_DIR = os.path.join('data', 'collections') CONTENT_VALIDATION_DIR = os.path.join('core', 'domain') -VOICEOVERS_DATA_DIR = os.path.join('data', 'voiceovers') SAMPLE_AUTO_VOICEOVERS_DATA_DIR = os.path.join( 'assets', 'sample-autogenerated-voiceovers-for-dev' ) @@ -543,7 +542,7 @@ def get_empty_ratings() -> Dict[str, int]: DATAFLOW_TEMP_LOCATION_TEMPLATE = 'gs://%s-beam-jobs-temp/' DATAFLOW_STAGING_LOCATION_TEMPLATE = 'gs://%s-beam-jobs-staging/' -OPPIA_VERSION = '3.5.0' +OPPIA_VERSION = '3.5.1' OPPIA_PYTHON_PACKAGE_PATH = './build/oppia_beam_job-%s.tar.gz' % OPPIA_VERSION # Committer id for system actions. The username for the system committer @@ -852,6 +851,9 @@ def get_empty_ratings() -> Dict[str, int]: '%s/email/contributordashboardachievementnotificationemailhandler' % (TASKQUEUE_URL_PREFIX) ) +TASK_URL_RETRY_FAILED_EMAIL = ( + '%s/email/retryemailhandler' % TASKQUEUE_URL_PREFIX +) TASK_URL_DEFERRED = '%s/deferredtaskshandler' % TASKQUEUE_URL_PREFIX # TODO(sll): Add all other URLs here. @@ -1077,8 +1079,7 @@ def get_empty_ratings() -> Dict[str, int]: '/regenerate_automatic_voiceover/' ) REGENERATE_VOICEOVER_ON_EXP_UPDATE_URL = ( - '/regenerate_voiceover_on_exp_update//' - '/' + '/regenerate_voiceover_on_exp_update//' ) REGENERATE_VOICEOVERS_FOR_EXPLORATION_URL = ( '/regenerate_voiceovers_for_exploration/' @@ -1797,7 +1798,13 @@ class VoiceoverType(enum.Enum): 'FUNCTION_ID_REGENERATE_VOICEOVERS_ON_EXP_CURATION': ( 'regenerate_voiceovers_on_exploration_added_to_topic' ), - 'FUNCTION_ID_REGENERATE_VOICEOVERS_OF_EXPLORATION_FOR_GIVEN_LANGUAGE_ACCENT': ( + 'FUNCTION_ID_REGENERATE_VOICEOVERS_AFTER_ACCEPTING_SUGGESTION': ( + 'regenerate_voiceovers_after_accepting_suggestion' + ), + 'FUNCTION_ID_REGENERATE_VOICEOVERS_BY_LANGUAGE_ACCENT': ( 'regenerate_voiceovers_of_exploration_for_given_language_accent' ), + 'FUNCTION_ID_REGENERATE_VOICEOVERS_FOR_BATCH_CONTENTS': ( + 'regenerate_voiceovers_for_batch_contents' + ), } diff --git a/core/handler_schema_constants.py b/core/handler_schema_constants.py index 81bbbe109f7da..7bc07e0979811 100644 --- a/core/handler_schema_constants.py +++ b/core/handler_schema_constants.py @@ -50,7 +50,6 @@ # Oppia root page is the unified entry for page routes to the frontend. # So, it should exempted from schema validation. 'OppiaRootPage', - 'OppiaLightweightRootPage', ] # HANDLER_CLASS_NAMES_WITH_NO_SCHEMA is addressed everywhere in the diff --git a/core/jobs/batch_jobs/blog_author_details_migration_jobs.py b/core/jobs/batch_jobs/blog_author_details_migration_jobs.py new file mode 100644 index 0000000000000..d6ad884457f1c --- /dev/null +++ b/core/jobs/batch_jobs/blog_author_details_migration_jobs.py @@ -0,0 +1,276 @@ +# coding: utf-8 +# +# Copyright 2026 The Oppia Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS-IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Audit and migration jobs for blog author details models. + +These jobs address the issue where blog posts authored by deleted users +(whose author_ids have been pseudonymized to pid_*) cause 500 errors +during blog homepage pagination, because the rendering code attempts to +look up or create a BlogAuthorDetailsModel for the deleted user and fails. + +The audit job identifies published blog post summary models whose +author_id belongs to a deleted user (no UserSettingsModel) and has no +corresponding BlogAuthorDetailsModel. + +The migration job creates BlogAuthorDetailsModel entries with a fallback +display name for those truly-deleted author_ids, ensuring that blog +homepage pagination works correctly even when an author has been deleted. + +Note: Authors who still have a UserSettingsModel but lack a +BlogAuthorDetailsModel are NOT treated as deleted — the runtime code in +blog_services.get_blog_author_details() auto-creates their model using +the user's actual display name. +""" + +from __future__ import annotations + +from core import utils +from core.jobs import base_jobs +from core.jobs.io import ndb_io +from core.jobs.transforms import job_result_transforms +from core.jobs.types import job_run_result +from core.platform import models + +import apache_beam as beam +from typing import Tuple + +MYPY = False +if MYPY: # pragma: no cover + from mypy_imports import ( + base_models, + blog_models, + datastore_services, + user_models, + ) + +(base_models, blog_models, user_models) = models.Registry.import_models( + [models.Names.BASE_MODEL, models.Names.BLOG, models.Names.USER] +) +datastore_services = models.Registry.import_datastore_services() + +DELETED_USER_FALLBACK_AUTHOR_NAME = 'Deleted User' +DELETED_USER_FALLBACK_AUTHOR_BIO = '' + + +class MigrateBlogAuthorDetailsForDeletedUsersJob(base_jobs.JobBase): + """Job that creates BlogAuthorDetailsModel entries for deleted users. + + For each published blog post summary whose author_id has no + corresponding BlogAuthorDetailsModel AND no UserSettingsModel (the + user was deleted), this job creates a new BlogAuthorDetailsModel with + a fallback display name and empty bio. This prevents 500 errors + during blog homepage pagination when the rendering code encounters + posts by deleted authors. + + Authors who still have a UserSettingsModel are skipped — the runtime + code in blog_services.get_blog_author_details() handles creating + their BlogAuthorDetailsModel using the user's actual display name. + """ + + DATASTORE_UPDATES_ALLOWED = True + + def _create_author_details_model( + self, + author_id: str, + ) -> blog_models.BlogAuthorDetailsModel: + """Creates a new BlogAuthorDetailsModel for the given author_id. + + Args: + author_id: str. The author_id of a deleted user who has no + BlogAuthorDetailsModel. + + Returns: + BlogAuthorDetailsModel. The newly created model instance. + """ + instance_id = utils.convert_to_hash( + str(utils.get_random_int(base_models.RAND_RANGE)), + base_models.ID_LENGTH, + ) + + with datastore_services.get_ndb_context(): + model = blog_models.BlogAuthorDetailsModel( + id=instance_id, + author_id=author_id, + displayed_author_name=DELETED_USER_FALLBACK_AUTHOR_NAME, + author_bio=DELETED_USER_FALLBACK_AUTHOR_BIO, + ) + model.update_timestamps() + return model + + def _get_orphaned_author_ids( + self, + ) -> Tuple[beam.PCollection[str], beam.PCollection[str]]: + """Returns blog post author pairs and orphaned author_ids. + + An author_id is orphaned when it appears in at least one published + BlogPostSummary, has no BlogAuthorDetailsModel, and has no + UserSettingsModel (i.e. the user was deleted). + + Returns: + tuple(PCollection[str], PCollection[str]). A tuple of + (blog_post_author_pairs, orphaned_author_ids). + """ + blog_post_author_pairs = ( + self.pipeline + | 'Get all BlogPostSummaryModels' + >> ndb_io.GetModels( + blog_models.BlogPostSummaryModel.get_all(include_deleted=False) + ) + | 'Filter published summaries' + >> beam.Filter(lambda model: model.published_on is not None) + | 'Key by author_id from summaries' + >> beam.Map(lambda model: (model.author_id, None)) + | 'Deduplicate author_id pairs' + >> beam.Distinct() # pylint: disable=no-value-for-parameter + ) + + existing_author_detail_pairs = ( + self.pipeline + | 'Get all BlogAuthorDetailsModels' + >> ndb_io.GetModels( + blog_models.BlogAuthorDetailsModel.get_all( + include_deleted=False + ) + ) + | 'Key by author_id from details' + >> beam.Map(lambda model: (model.author_id, None)) + ) + + existing_user_pairs = ( + self.pipeline + | 'Get all UserSettingsModels' + >> ndb_io.GetModels( + user_models.UserSettingsModel.get_all(include_deleted=False) + ) + | 'Key by user_id from user settings' + >> beam.Map(lambda model: (model.id, None)) + ) + + orphaned_author_ids = ( + { + 'blog_post_authors': blog_post_author_pairs, + 'existing_author_details': existing_author_detail_pairs, + 'existing_users': existing_user_pairs, + } + | 'CoGroup by author_id' >> beam.CoGroupByKey() + | 'Filter deleted-user orphaned author_ids' + >> beam.Filter( + lambda item: ( + len(list(item[1]['blog_post_authors'])) > 0 + and len(list(item[1]['existing_author_details'])) == 0 + and len(list(item[1]['existing_users'])) == 0 + ) + ) + | 'Extract orphaned author_id' >> beam.Map(lambda item: item[0]) + ) + + return (blog_post_author_pairs, orphaned_author_ids) + + def run(self) -> beam.PCollection[job_run_result.JobRunResult]: + """Returns a PCollection of migration results. + + Returns: + PCollection. A PCollection of JobRunResult instances reporting + the migration outcomes. + """ + _, orphaned_author_ids = self._get_orphaned_author_ids() + + new_author_details_models = ( + orphaned_author_ids + | 'Create BlogAuthorDetailsModel' + >> beam.Map(self._create_author_details_model) + ) + + if self.DATASTORE_UPDATES_ALLOWED: + unused_put_result = ( + new_author_details_models + | 'Save BlogAuthorDetailsModels to Datastore' + >> ndb_io.PutModels() + ) + + migration_results = ( + new_author_details_models + | 'Report migrated author_ids' + >> beam.Map( + lambda model: job_run_result.JobRunResult.as_stdout( + f'MIGRATED AUTHOR ID: {model.author_id}' + ) + ) + ) + + migration_count_results = ( + new_author_details_models + | 'Count migrated author_ids' + >> job_result_transforms.CountObjectsToJobRunResult( + 'MIGRATED AUTHOR DETAILS COUNT' + ) + ) + + return ( + migration_results, + migration_count_results, + ) | 'Combine migration results' >> beam.Flatten() + + +class AuditBlogAuthorDetailsForDeletedUsersJob( + MigrateBlogAuthorDetailsForDeletedUsersJob +): + """Job that audits MigrateBlogAuthorDetailsForDeletedUsersJob.""" + + DATASTORE_UPDATES_ALLOWED = False + + def run(self) -> beam.PCollection[job_run_result.JobRunResult]: + """Returns a PCollection of audit results. + + Returns: + PCollection. A PCollection of JobRunResult instances reporting + the orphaned author_ids and related counts. + """ + blog_post_author_pairs, orphaned_author_ids = ( + self._get_orphaned_author_ids() + ) + + orphaned_author_id_results = ( + orphaned_author_ids + | 'Report orphaned author_ids' + >> beam.Map( + lambda author_id: job_run_result.JobRunResult.as_stdout( + f'ORPHANED AUTHOR ID: {author_id}' + ) + ) + ) + + orphaned_count_results = ( + orphaned_author_ids + | 'Count orphaned author_ids' + >> job_result_transforms.CountObjectsToJobRunResult( + 'ORPHANED AUTHOR IDS COUNT' + ) + ) + + total_blog_author_count_results = ( + blog_post_author_pairs + | 'Count total blog post author_ids' + >> job_result_transforms.CountObjectsToJobRunResult( + 'TOTAL BLOG POST AUTHOR IDS COUNT' + ) + ) + + return ( + orphaned_author_id_results, + orphaned_count_results, + total_blog_author_count_results, + ) | 'Combine audit results' >> beam.Flatten() diff --git a/core/jobs/batch_jobs/blog_author_details_migration_jobs_test.py b/core/jobs/batch_jobs/blog_author_details_migration_jobs_test.py new file mode 100644 index 0000000000000..126fa78fc1dda --- /dev/null +++ b/core/jobs/batch_jobs/blog_author_details_migration_jobs_test.py @@ -0,0 +1,667 @@ +# coding: utf-8 +# +# Copyright 2026 The Oppia Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS-IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for jobs.batch_jobs.blog_author_details_migration_jobs.""" + +from __future__ import annotations + +import datetime + +from core.jobs import job_test_utils +from core.jobs.batch_jobs import blog_author_details_migration_jobs +from core.jobs.types import job_run_result +from core.platform import models + +from typing import Final, Type + +MYPY = False +if MYPY: # pragma: no cover + from mypy_imports import blog_models, user_models + +(blog_models, user_models) = models.Registry.import_models( + [models.Names.BLOG, models.Names.USER] +) + + +class AuditBlogAuthorDetailsForDeletedUsersJobTests(job_test_utils.JobTestBase): + """Tests for AuditBlogAuthorDetailsForDeletedUsersJob.""" + + JOB_CLASS: Type[ + blog_author_details_migration_jobs.AuditBlogAuthorDetailsForDeletedUsersJob + ] = ( + blog_author_details_migration_jobs.AuditBlogAuthorDetailsForDeletedUsersJob + ) + + # Active user with a UserSettingsModel. + AUTHOR_ID_1: Final = 'uid_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + # Deleted user (pseudonymized, no UserSettingsModel). + AUTHOR_ID_2: Final = 'pid_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + # Another deleted user (no UserSettingsModel). + AUTHOR_ID_3: Final = 'uid_cccccccccccccccccccccccccccccccccc' + + def test_empty_storage_produces_no_output(self) -> None: + """Tests that the job produces no output when the datastore is + empty. + """ + self.assert_job_output_is_empty() + + def test_no_orphaned_authors_produces_count_only(self) -> None: + """Tests that the job reports only the total count when every + published blog post has a matching BlogAuthorDetailsModel. + """ + blog_post_summary = self.create_model( + blog_models.BlogPostSummaryModel, + id='blogpost1aaa', + author_id=self.AUTHOR_ID_1, + title='Test Blog Post', + summary='A test blog post summary.', + url_fragment='test-blog-post', + tags=['test'], + thumbnail_filename='thumbnail.svg', + published_on=datetime.datetime(2025, 1, 1), + ) + author_details = self.create_model( + blog_models.BlogAuthorDetailsModel, + id='authordetail1', + author_id=self.AUTHOR_ID_1, + displayed_author_name='Test Author', + author_bio='A test author bio.', + ) + user_settings = self.create_model( + user_models.UserSettingsModel, + id=self.AUTHOR_ID_1, + email='author1@example.com', + ) + self.put_multi([blog_post_summary, author_details, user_settings]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'TOTAL BLOG POST AUTHOR IDS COUNT SUCCESS: 1' + ), + ] + ) + + def test_deleted_user_without_author_details_is_reported(self) -> None: + """Tests that the job correctly identifies and reports an + author_id whose user has been deleted (no UserSettingsModel) + and has no BlogAuthorDetailsModel. + """ + # Published blog post by a deleted user (no UserSettingsModel). + blog_post_summary = self.create_model( + blog_models.BlogPostSummaryModel, + id='blogpost2aaa', + author_id=self.AUTHOR_ID_2, + title='Deleted Author Post', + summary='Post by a deleted author.', + url_fragment='deleted-author-post', + tags=['test'], + thumbnail_filename='thumbnail.svg', + published_on=datetime.datetime(2025, 1, 1), + ) + self.put_multi([blog_post_summary]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + f'ORPHANED AUTHOR ID: {self.AUTHOR_ID_2}' + ), + job_run_result.JobRunResult.as_stdout( + 'ORPHANED AUTHOR IDS COUNT SUCCESS: 1' + ), + job_run_result.JobRunResult.as_stdout( + 'TOTAL BLOG POST AUTHOR IDS COUNT SUCCESS: 1' + ), + ] + ) + + def test_active_user_without_author_details_is_not_reported( + self, + ) -> None: + """Tests that an active user (with UserSettingsModel) who lacks + a BlogAuthorDetailsModel is NOT flagged as orphaned, because the + runtime code will auto-create one using the user's real name. + """ + blog_post_summary = self.create_model( + blog_models.BlogPostSummaryModel, + id='blogpost10aa', + author_id=self.AUTHOR_ID_1, + title='Active Author Post', + summary='Post by an active author without details model.', + url_fragment='active-author-post', + tags=['test'], + thumbnail_filename='thumbnail.svg', + published_on=datetime.datetime(2025, 1, 1), + ) + # The user still exists, just lacks a BlogAuthorDetailsModel. + user_settings = self.create_model( + user_models.UserSettingsModel, + id=self.AUTHOR_ID_1, + email='author1@example.com', + ) + self.put_multi([blog_post_summary, user_settings]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'TOTAL BLOG POST AUTHOR IDS COUNT SUCCESS: 1' + ), + ] + ) + + def test_draft_blog_posts_are_excluded(self) -> None: + """Tests that draft blog posts (published_on is None) are not + included in the orphan detection. + """ + # Draft blog post by a deleted user — should not be flagged. + draft_summary = self.create_model( + blog_models.BlogPostSummaryModel, + id='blogpost3aaa', + author_id=self.AUTHOR_ID_2, + title='Draft Post', + summary='A draft post.', + url_fragment='draft-post', + tags=['draft'], + thumbnail_filename='thumbnail.svg', + published_on=None, + ) + self.put_multi([draft_summary]) + + self.assert_job_output_is_empty() + + def test_mixed_deleted_active_and_valid_authors(self) -> None: + """Tests that the job correctly differentiates between: + - a deleted user with no BlogAuthorDetailsModel (orphaned), + - an active user with a BlogAuthorDetailsModel (not orphaned), + - an active user without a BlogAuthorDetailsModel (not orphaned, + runtime handles it). + """ + # Active user with BlogAuthorDetailsModel. + blog_post_summary_1 = self.create_model( + blog_models.BlogPostSummaryModel, + id='blogpost4aaa', + author_id=self.AUTHOR_ID_1, + title='Valid Author Post', + summary='Post by an existing author.', + url_fragment='valid-author-post', + tags=['test'], + thumbnail_filename='thumbnail.svg', + published_on=datetime.datetime(2025, 1, 1), + ) + author_details_1 = self.create_model( + blog_models.BlogAuthorDetailsModel, + id='authordetail2', + author_id=self.AUTHOR_ID_1, + displayed_author_name='Valid Author', + author_bio='A valid author bio.', + ) + user_settings_1 = self.create_model( + user_models.UserSettingsModel, + id=self.AUTHOR_ID_1, + email='author1@example.com', + ) + # Deleted user without BlogAuthorDetailsModel. + blog_post_summary_2 = self.create_model( + blog_models.BlogPostSummaryModel, + id='blogpost5aaa', + author_id=self.AUTHOR_ID_2, + title='Orphaned Author Post', + summary='Post by a deleted author.', + url_fragment='orphaned-author-post', + tags=['test'], + thumbnail_filename='thumbnail.svg', + published_on=datetime.datetime(2025, 2, 1), + ) + self.put_multi( + [ + blog_post_summary_1, + blog_post_summary_2, + author_details_1, + user_settings_1, + ] + ) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + f'ORPHANED AUTHOR ID: {self.AUTHOR_ID_2}' + ), + job_run_result.JobRunResult.as_stdout( + 'ORPHANED AUTHOR IDS COUNT SUCCESS: 1' + ), + job_run_result.JobRunResult.as_stdout( + 'TOTAL BLOG POST AUTHOR IDS COUNT SUCCESS: 2' + ), + ] + ) + + def test_multiple_posts_by_same_orphaned_author_counted_once( + self, + ) -> None: + """Tests that multiple published blog posts by the same deleted + author_id are deduplicated and reported as a single orphan. + """ + blog_post_summary_1 = self.create_model( + blog_models.BlogPostSummaryModel, + id='blogpost6aaa', + author_id=self.AUTHOR_ID_2, + title='Orphan Post One', + summary='First post by deleted author.', + url_fragment='orphan-post-one', + tags=['test'], + thumbnail_filename='thumbnail.svg', + published_on=datetime.datetime(2025, 1, 1), + ) + blog_post_summary_2 = self.create_model( + blog_models.BlogPostSummaryModel, + id='blogpost7aaa', + author_id=self.AUTHOR_ID_2, + title='Orphan Post Two', + summary='Second post by deleted author.', + url_fragment='orphan-post-two', + tags=['test'], + thumbnail_filename='thumbnail.svg', + published_on=datetime.datetime(2025, 2, 1), + ) + self.put_multi([blog_post_summary_1, blog_post_summary_2]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + f'ORPHANED AUTHOR ID: {self.AUTHOR_ID_2}' + ), + job_run_result.JobRunResult.as_stdout( + 'ORPHANED AUTHOR IDS COUNT SUCCESS: 1' + ), + job_run_result.JobRunResult.as_stdout( + 'TOTAL BLOG POST AUTHOR IDS COUNT SUCCESS: 1' + ), + ] + ) + + +class MigrateBlogAuthorDetailsForDeletedUsersJobTests( + job_test_utils.JobTestBase +): + """Tests for MigrateBlogAuthorDetailsForDeletedUsersJob.""" + + JOB_CLASS: Type[ + blog_author_details_migration_jobs.MigrateBlogAuthorDetailsForDeletedUsersJob + ] = ( + blog_author_details_migration_jobs.MigrateBlogAuthorDetailsForDeletedUsersJob + ) + + # Active user with a UserSettingsModel. + AUTHOR_ID_1: Final = 'uid_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + # Deleted user (pseudonymized, no UserSettingsModel). + AUTHOR_ID_2: Final = 'pid_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + # Another deleted user (no UserSettingsModel). + AUTHOR_ID_3: Final = 'uid_cccccccccccccccccccccccccccccccccc' + + def test_empty_storage_produces_no_output(self) -> None: + """Tests that the job produces no output when the datastore is + empty. + """ + self.assert_job_output_is_empty() + + def test_no_orphaned_authors_produces_no_migration(self) -> None: + """Tests that no migration occurs when all published blog posts + have matching BlogAuthorDetailsModels. + """ + blog_post_summary = self.create_model( + blog_models.BlogPostSummaryModel, + id='blogpost1aaa', + author_id=self.AUTHOR_ID_1, + title='Test Blog Post', + summary='A test blog post summary.', + url_fragment='test-blog-post', + tags=['test'], + thumbnail_filename='thumbnail.svg', + published_on=datetime.datetime(2025, 1, 1), + ) + author_details = self.create_model( + blog_models.BlogAuthorDetailsModel, + id='authordetail1', + author_id=self.AUTHOR_ID_1, + displayed_author_name='Test Author', + author_bio='A test author bio.', + ) + user_settings = self.create_model( + user_models.UserSettingsModel, + id=self.AUTHOR_ID_1, + email='author1@example.com', + ) + self.put_multi([blog_post_summary, author_details, user_settings]) + + self.assert_job_output_is_empty() + + def test_deleted_user_gets_migrated(self) -> None: + """Tests that a BlogAuthorDetailsModel is created for a deleted + user's author_id with the correct fallback display name. + """ + blog_post_summary = self.create_model( + blog_models.BlogPostSummaryModel, + id='blogpost2aaa', + author_id=self.AUTHOR_ID_2, + title='Deleted Author Post', + summary='Post by a deleted author.', + url_fragment='deleted-author-post', + tags=['test'], + thumbnail_filename='thumbnail.svg', + published_on=datetime.datetime(2025, 1, 1), + ) + self.put_multi([blog_post_summary]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + f'MIGRATED AUTHOR ID: {self.AUTHOR_ID_2}' + ), + job_run_result.JobRunResult.as_stdout( + 'MIGRATED AUTHOR DETAILS COUNT SUCCESS: 1' + ), + ] + ) + + created_model = blog_models.BlogAuthorDetailsModel.get_by_author( + self.AUTHOR_ID_2 + ) + self.assertIsNotNone(created_model) + assert created_model is not None + self.assertEqual( + created_model.displayed_author_name, + blog_author_details_migration_jobs.DELETED_USER_FALLBACK_AUTHOR_NAME, + ) + self.assertEqual( + created_model.author_bio, + blog_author_details_migration_jobs.DELETED_USER_FALLBACK_AUTHOR_BIO, + ) + + def test_active_user_without_author_details_is_not_migrated( + self, + ) -> None: + """Tests that an active user (with UserSettingsModel) who lacks + a BlogAuthorDetailsModel is NOT migrated, because the runtime + code will auto-create the model using the user's real name. + """ + blog_post_summary = self.create_model( + blog_models.BlogPostSummaryModel, + id='blogpost11aa', + author_id=self.AUTHOR_ID_1, + title='Active Author Post', + summary='Post by an active author.', + url_fragment='active-author-post', + tags=['test'], + thumbnail_filename='thumbnail.svg', + published_on=datetime.datetime(2025, 1, 1), + ) + # User exists but has no BlogAuthorDetailsModel. + user_settings = self.create_model( + user_models.UserSettingsModel, + id=self.AUTHOR_ID_1, + email='author1@example.com', + ) + self.put_multi([blog_post_summary, user_settings]) + + self.assert_job_output_is_empty() + + def test_migration_creates_model_with_correct_fallback_values( + self, + ) -> None: + """Tests that the created BlogAuthorDetailsModel has the + expected fallback display name and bio. + """ + blog_post_summary = self.create_model( + blog_models.BlogPostSummaryModel, + id='blogpost3aaa', + author_id=self.AUTHOR_ID_2, + title='Deleted Author Post', + summary='Post by a deleted author.', + url_fragment='deleted-author-post', + tags=['test'], + thumbnail_filename='thumbnail.svg', + published_on=datetime.datetime(2025, 1, 1), + ) + self.put_multi([blog_post_summary]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + f'MIGRATED AUTHOR ID: {self.AUTHOR_ID_2}' + ), + job_run_result.JobRunResult.as_stdout( + 'MIGRATED AUTHOR DETAILS COUNT SUCCESS: 1' + ), + ] + ) + + # Verify that the model was persisted with correct values. + created_model = blog_models.BlogAuthorDetailsModel.get_by_author( + self.AUTHOR_ID_2 + ) + self.assertIsNotNone(created_model) + assert created_model is not None + self.assertEqual( + created_model.displayed_author_name, + blog_author_details_migration_jobs.DELETED_USER_FALLBACK_AUTHOR_NAME, + ) + self.assertEqual( + created_model.author_bio, + blog_author_details_migration_jobs.DELETED_USER_FALLBACK_AUTHOR_BIO, + ) + + def test_mixed_deleted_and_active_authors_only_migrates_deleted( + self, + ) -> None: + """Tests that only deleted users are migrated, while active + users with or without BlogAuthorDetailsModels are left + unchanged. + """ + # Active user with BlogAuthorDetailsModel. + blog_post_summary_1 = self.create_model( + blog_models.BlogPostSummaryModel, + id='blogpost4aaa', + author_id=self.AUTHOR_ID_1, + title='Valid Author Post', + summary='Post by an existing author.', + url_fragment='valid-author-post', + tags=['test'], + thumbnail_filename='thumbnail.svg', + published_on=datetime.datetime(2025, 1, 1), + ) + author_details_1 = self.create_model( + blog_models.BlogAuthorDetailsModel, + id='authordetail2', + author_id=self.AUTHOR_ID_1, + displayed_author_name='Valid Author', + author_bio='A valid author bio.', + ) + user_settings_1 = self.create_model( + user_models.UserSettingsModel, + id=self.AUTHOR_ID_1, + email='author1@example.com', + ) + # Deleted user without BlogAuthorDetailsModel. + blog_post_summary_2 = self.create_model( + blog_models.BlogPostSummaryModel, + id='blogpost5aaa', + author_id=self.AUTHOR_ID_2, + title='Orphaned Author Post', + summary='Post by a deleted author.', + url_fragment='orphaned-author-post', + tags=['test'], + thumbnail_filename='thumbnail.svg', + published_on=datetime.datetime(2025, 2, 1), + ) + self.put_multi( + [ + blog_post_summary_1, + blog_post_summary_2, + author_details_1, + user_settings_1, + ] + ) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + f'MIGRATED AUTHOR ID: {self.AUTHOR_ID_2}' + ), + job_run_result.JobRunResult.as_stdout( + 'MIGRATED AUTHOR DETAILS COUNT SUCCESS: 1' + ), + ] + ) + + migrated_model = blog_models.BlogAuthorDetailsModel.get_by_author( + self.AUTHOR_ID_2 + ) + self.assertIsNotNone(migrated_model) + assert migrated_model is not None + self.assertEqual( + migrated_model.displayed_author_name, + blog_author_details_migration_jobs.DELETED_USER_FALLBACK_AUTHOR_NAME, + ) + self.assertEqual( + migrated_model.author_bio, + blog_author_details_migration_jobs.DELETED_USER_FALLBACK_AUTHOR_BIO, + ) + + existing_model = blog_models.BlogAuthorDetailsModel.get_by_author( + self.AUTHOR_ID_1 + ) + self.assertIsNotNone(existing_model) + assert existing_model is not None + self.assertEqual(existing_model.displayed_author_name, 'Valid Author') + self.assertEqual(existing_model.author_bio, 'A valid author bio.') + + def test_multiple_deleted_authors_are_all_migrated(self) -> None: + """Tests that all deleted author_ids get migrated when multiple + deleted users have published blog posts. + """ + blog_post_summary_1 = self.create_model( + blog_models.BlogPostSummaryModel, + id='blogpost6aaa', + author_id=self.AUTHOR_ID_2, + title='Deleted Author Post One', + summary='First deleted author post.', + url_fragment='deleted-author-post-one', + tags=['test'], + thumbnail_filename='thumbnail.svg', + published_on=datetime.datetime(2025, 1, 1), + ) + blog_post_summary_2 = self.create_model( + blog_models.BlogPostSummaryModel, + id='blogpost7aaa', + author_id=self.AUTHOR_ID_3, + title='Deleted Author Post Two', + summary='Second deleted author post.', + url_fragment='deleted-author-post-two', + tags=['test'], + thumbnail_filename='thumbnail.svg', + published_on=datetime.datetime(2025, 2, 1), + ) + self.put_multi([blog_post_summary_1, blog_post_summary_2]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + f'MIGRATED AUTHOR ID: {self.AUTHOR_ID_2}' + ), + job_run_result.JobRunResult.as_stdout( + f'MIGRATED AUTHOR ID: {self.AUTHOR_ID_3}' + ), + job_run_result.JobRunResult.as_stdout( + 'MIGRATED AUTHOR DETAILS COUNT SUCCESS: 2' + ), + ] + ) + + model_2 = blog_models.BlogAuthorDetailsModel.get_by_author( + self.AUTHOR_ID_2 + ) + self.assertIsNotNone(model_2) + assert model_2 is not None + self.assertEqual( + model_2.displayed_author_name, + blog_author_details_migration_jobs.DELETED_USER_FALLBACK_AUTHOR_NAME, + ) + self.assertEqual( + model_2.author_bio, + blog_author_details_migration_jobs.DELETED_USER_FALLBACK_AUTHOR_BIO, + ) + model_3 = blog_models.BlogAuthorDetailsModel.get_by_author( + self.AUTHOR_ID_3 + ) + self.assertIsNotNone(model_3) + assert model_3 is not None + self.assertEqual( + model_3.displayed_author_name, + blog_author_details_migration_jobs.DELETED_USER_FALLBACK_AUTHOR_NAME, + ) + self.assertEqual( + model_3.author_bio, + blog_author_details_migration_jobs.DELETED_USER_FALLBACK_AUTHOR_BIO, + ) + + def test_draft_blog_posts_are_excluded_from_migration(self) -> None: + """Tests that draft blog posts (published_on is None) do not + trigger migration of their author_ids. + """ + draft_summary = self.create_model( + blog_models.BlogPostSummaryModel, + id='blogpost8aaa', + author_id=self.AUTHOR_ID_2, + title='Draft Post', + summary='A draft post.', + url_fragment='draft-post', + tags=['draft'], + thumbnail_filename='thumbnail.svg', + published_on=None, + ) + self.put_multi([draft_summary]) + + self.assert_job_output_is_empty() + + def test_already_migrated_deleted_user_is_not_migrated_again( + self, + ) -> None: + """Tests that a deleted user who already has a + BlogAuthorDetailsModel (from a prior migration run) is NOT + migrated again. + """ + blog_post_summary = self.create_model( + blog_models.BlogPostSummaryModel, + id='blogpost12aa', + author_id=self.AUTHOR_ID_2, + title='Previously Migrated Post', + summary='Post whose author was already migrated.', + url_fragment='already-migrated-post', + tags=['test'], + thumbnail_filename='thumbnail.svg', + published_on=datetime.datetime(2025, 1, 1), + ) + # BlogAuthorDetailsModel already exists from prior migration. + author_details = self.create_model( + blog_models.BlogAuthorDetailsModel, + id='authordetail3', + author_id=self.AUTHOR_ID_2, + displayed_author_name='Deleted User', + author_bio='', + ) + self.put_multi([blog_post_summary, author_details]) + + self.assert_job_output_is_empty() diff --git a/core/jobs/batch_jobs/cleanup_duplicate_translation_suggestions_jobs.py b/core/jobs/batch_jobs/cleanup_duplicate_translation_suggestions_jobs.py new file mode 100644 index 0000000000000..e08aa051b75d2 --- /dev/null +++ b/core/jobs/batch_jobs/cleanup_duplicate_translation_suggestions_jobs.py @@ -0,0 +1,190 @@ +# coding: utf-8 +# +# Copyright 2026 The Oppia Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS-IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Beam jobs for cleaning up duplicate translation suggestions.""" + +from __future__ import annotations + +from core import feconf +from core.jobs import base_jobs +from core.jobs.io import ndb_io +from core.jobs.transforms import job_result_transforms +from core.jobs.types import job_run_result +from core.platform import models + +import apache_beam as beam # pylint: disable=import-error +from typing import Any, Callable, Iterable, List, Tuple + +MYPY = False +if MYPY: # pragma: no cover + from mypy_imports import suggestion_models + +(suggestion_models,) = models.Registry.import_models([models.Names.SUGGESTION]) + + +class CleanupDuplicateTranslationSuggestionsJob(base_jobs.JobBase): + """Job that cleans up duplicate translation suggestions.""" + + DATASTORE_UPDATES_ALLOWED = True + + @staticmethod + def _reject_extra_suggestions( + grouped_suggestions: Tuple[ + Tuple[str, str, str], + Iterable[suggestion_models.GeneralSuggestionModel], + ], + ) -> List[suggestion_models.GeneralSuggestionModel]: + """Keep the oldest suggestion and reject the others. + + Args: + grouped_suggestions: tuple. A tuple containing the key and + an iterable of suggestion models. + + Returns: + list(GeneralSuggestionModel). A list of suggestions to be updated. + """ + _, models_list = grouped_suggestions + # The key for sorting is defined separately because of a mypy bug. + # A [no-any-return] is thrown if key is defined in the sort() method + # instead. Reference: https://github.com/python/mypy/issues/9590. + # Here we use type Any because the type of the created_on attribute + # is not known to mypy at this point. + by_created_on: Callable[ + [suggestion_models.GeneralSuggestionModel], Any + ] = lambda m: m.created_on + sorted_suggestions = sorted(models_list, key=by_created_on) + # Keep the oldest one, reject the others. + suggestions_to_reject = sorted_suggestions[1:] + for suggestion in suggestions_to_reject: + suggestion.status = suggestion_models.STATUS_REJECTED + suggestion.final_reviewer_id = feconf.SUGGESTION_BOT_USER_ID + return suggestions_to_reject + + def run(self) -> beam.PCollection[job_run_result.JobRunResult]: + """Returns a PCollection of cleanup results. + + Returns: + PCollection. A PCollection of the cleanup results. + """ + suggestions_grouped_by_content = ( + self.pipeline + | 'Get all translation suggestions in review' + >> ndb_io.GetModels( + suggestion_models.GeneralSuggestionModel.get_all( + include_deleted=False + ) + .filter( + suggestion_models.GeneralSuggestionModel.suggestion_type + == feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT + ) + .filter( + suggestion_models.GeneralSuggestionModel.status + == suggestion_models.STATUS_IN_REVIEW + ) + ) + | 'Key by target_id, language_code, and content_id' + >> beam.Map( + lambda model: ( + ( + model.target_id, + model.language_code, + model.change_cmd['content_id'], + ), + model, + ) + ) + | 'Group by content' >> beam.GroupByKey() + | 'Convert values to list' + >> beam.Map(lambda item: (item[0], list(item[1]))) + ) + + duplicate_suggestions = ( + suggestions_grouped_by_content + | 'Filter only duplicate groups' + >> beam.Filter(lambda item: len(item[1]) > 1) + ) + + duplicate_suggestions_report = ( + duplicate_suggestions + | 'Report duplicates' + >> beam.Map( + lambda item: job_run_result.JobRunResult.as_stdout( + f'Duplicates found for exploration {item[0][0]}, ' + f'language {item[0][1]}, content_id {item[0][2]}. ' + f'Suggestion IDs: {[m.id for m in item[1]]}' + ) + ) + ) + + duplicate_groups_count = ( + duplicate_suggestions + | 'Count duplicate groups' + >> ( + job_result_transforms.CountObjectsToJobRunResult( + 'DUPLICATE GROUPS COUNT' + ) + ) + | 'Filter non-zero counts' + >> beam.Filter(lambda result: 'SUCCESS: 0' not in result.stdout) + ) + + suggestions_to_update = ( + duplicate_suggestions + | 'Reject extra suggestions' + >> beam.FlatMap(self._reject_extra_suggestions) + ) + + total_suggestions_count = ( + suggestions_grouped_by_content + | 'Extract suggestions' >> beam.FlatMap(lambda item: item[1]) + | 'Count total suggestions' + >> ( + job_result_transforms.CountObjectsToJobRunResult( + 'TOTAL SUGGESTIONS COUNT' + ) + ) + ) + + rejected_suggestions_count = ( + suggestions_to_update + | 'Count rejected suggestions' + >> ( + job_result_transforms.CountObjectsToJobRunResult( + 'REJECTED DUPLICATE SUGGESTIONS COUNT' + ) + ) + ) + + if self.DATASTORE_UPDATES_ALLOWED: + unused_put_results = ( + suggestions_to_update + | 'Put models into the datastore' >> ndb_io.PutModels() + ) + + return ( + duplicate_suggestions_report, + duplicate_groups_count, + total_suggestions_count, + rejected_suggestions_count, + ) | 'Combine results' >> beam.Flatten() + + +class AuditDuplicateTranslationSuggestionsJob( + CleanupDuplicateTranslationSuggestionsJob +): + """Job that audits duplicate translation suggestions.""" + + DATASTORE_UPDATES_ALLOWED = False diff --git a/core/jobs/batch_jobs/cleanup_duplicate_translation_suggestions_jobs_test.py b/core/jobs/batch_jobs/cleanup_duplicate_translation_suggestions_jobs_test.py new file mode 100644 index 0000000000000..5d14fde9bc168 --- /dev/null +++ b/core/jobs/batch_jobs/cleanup_duplicate_translation_suggestions_jobs_test.py @@ -0,0 +1,237 @@ +# coding: utf-8 +# +# Copyright 2026 The Oppia Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS-IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for cleanup_duplicate_translation_suggestions_jobs.""" + +from __future__ import annotations + +import datetime + +from core import feconf +from core.jobs import job_test_utils +from core.jobs.batch_jobs import cleanup_duplicate_translation_suggestions_jobs +from core.jobs.types import job_run_result +from core.platform import models + +MYPY = False +if MYPY: # pragma: no cover + from mypy_imports import suggestion_models + +(suggestion_models,) = models.Registry.import_models([models.Names.SUGGESTION]) + + +class AuditDuplicateTranslationSuggestionsJobTests(job_test_utils.JobTestBase): + + JOB_CLASS = ( + cleanup_duplicate_translation_suggestions_jobs.AuditDuplicateTranslationSuggestionsJob + ) + + def test_empty_datastore_returns_empty_report(self) -> None: + self.assert_job_output_is_empty() + + def test_no_duplicates_returns_empty_report(self) -> None: + suggestion1 = self.create_model( + suggestion_models.GeneralSuggestionModel, + id='suggestion1', + suggestion_type=feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT, + author_id='user1', + change_cmd={'content_id': 'content1'}, + score_category='translation.category', + status=suggestion_models.STATUS_IN_REVIEW, + target_type='exploration', + target_id='exp1', + target_version_at_submission=1, + language_code='hi', + ) + suggestion2 = self.create_model( + suggestion_models.GeneralSuggestionModel, + id='suggestion2', + suggestion_type=feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT, + author_id='user2', + change_cmd={'content_id': 'content2'}, + score_category='translation.category', + status=suggestion_models.STATUS_IN_REVIEW, + target_type='exploration', + target_id='exp1', + target_version_at_submission=1, + language_code='hi', + ) + self.put_multi([suggestion1, suggestion2]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult( + stdout='TOTAL SUGGESTIONS COUNT SUCCESS: 2' + ), + ] + ) + + def test_duplicates_are_reported(self) -> None: + suggestion1 = self.create_model( + suggestion_models.GeneralSuggestionModel, + id='suggestion1', + suggestion_type=feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT, + status=suggestion_models.STATUS_IN_REVIEW, + target_id='exp1', + language_code='hi', + change_cmd={'content_id': 'content1'}, + author_id='user1', + score_category='translation.category', + target_type='exploration', + target_version_at_submission=1, + ) + suggestion2 = self.create_model( + suggestion_models.GeneralSuggestionModel, + id='suggestion2', + suggestion_type=feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT, + status=suggestion_models.STATUS_IN_REVIEW, + target_id='exp1', + language_code='hi', + change_cmd={'content_id': 'content1'}, + author_id='user2', + score_category='translation.category', + target_type='exploration', + target_version_at_submission=1, + ) + self.put_multi([suggestion1, suggestion2]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult( + stdout='DUPLICATE GROUPS COUNT SUCCESS: 1' + ), + job_run_result.JobRunResult.as_stdout( + 'Duplicates found for exploration exp1, language hi, ' + 'content_id content1. Suggestion IDs: [\'suggestion1\', \'suggestion2\']' + ), + job_run_result.JobRunResult( + stdout='REJECTED DUPLICATE SUGGESTIONS COUNT SUCCESS: 1' + ), + job_run_result.JobRunResult( + stdout='TOTAL SUGGESTIONS COUNT SUCCESS: 2' + ), + ] + ) + + +class CleanupDuplicateTranslationSuggestionsJobTests( + job_test_utils.JobTestBase +): + + JOB_CLASS = ( + cleanup_duplicate_translation_suggestions_jobs.CleanupDuplicateTranslationSuggestionsJob + ) + + def test_empty_datastore_returns_empty_report(self) -> None: + self.assert_job_output_is_empty() + + def test_no_duplicates_leaves_suggestions_unchanged(self) -> None: + suggestion1 = self.create_model( + suggestion_models.GeneralSuggestionModel, + id='suggestion1', + suggestion_type=feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT, + author_id='user1', + change_cmd={'content_id': 'content1'}, + score_category='translation.category', + status=suggestion_models.STATUS_IN_REVIEW, + target_type='exploration', + target_id='exp1', + target_version_at_submission=1, + language_code='hi', + ) + self.put_multi([suggestion1]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult( + stdout='TOTAL SUGGESTIONS COUNT SUCCESS: 1' + ), + ] + ) + + model1 = suggestion_models.GeneralSuggestionModel.get_by_id( + 'suggestion1' + ) + self.assertEqual(model1.status, suggestion_models.STATUS_IN_REVIEW) + + def test_duplicates_are_cleaned_up(self) -> None: + # Created 1 hour ago. + created_on_1 = datetime.datetime.utcnow() - datetime.timedelta(hours=1) + # Created 2 hours ago (oldest). + created_on_2 = datetime.datetime.utcnow() - datetime.timedelta(hours=2) + + suggestion1 = self.create_model( + suggestion_models.GeneralSuggestionModel, + id='suggestion1', + suggestion_type=feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT, + status=suggestion_models.STATUS_IN_REVIEW, + target_id='exp1', + language_code='hi', + change_cmd={'content_id': 'content1'}, + author_id='user1', + score_category='translation.category', + target_type='exploration', + target_version_at_submission=1, + created_on=created_on_1, + ) + suggestion2 = self.create_model( + suggestion_models.GeneralSuggestionModel, + id='suggestion2', + suggestion_type=feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT, + status=suggestion_models.STATUS_IN_REVIEW, + target_id='exp1', + language_code='hi', + change_cmd={'content_id': 'content1'}, + author_id='user2', + score_category='translation.category', + target_type='exploration', + target_version_at_submission=1, + created_on=created_on_2, + ) + self.put_multi([suggestion1, suggestion2]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult( + stdout='DUPLICATE GROUPS COUNT SUCCESS: 1' + ), + job_run_result.JobRunResult.as_stdout( + 'Duplicates found for exploration exp1, language hi, ' + 'content_id content1. Suggestion IDs: [\'suggestion1\', \'suggestion2\']' + ), + job_run_result.JobRunResult( + stdout='REJECTED DUPLICATE SUGGESTIONS COUNT SUCCESS: 1' + ), + job_run_result.JobRunResult( + stdout='TOTAL SUGGESTIONS COUNT SUCCESS: 2' + ), + ] + ) + + # Suggestion 2 should still be in review (it was the oldest). + model2 = suggestion_models.GeneralSuggestionModel.get_by_id( + 'suggestion2' + ) + self.assertEqual(model2.status, suggestion_models.STATUS_IN_REVIEW) + + # Suggestion 1 should be rejected. + model1 = suggestion_models.GeneralSuggestionModel.get_by_id( + 'suggestion1' + ) + self.assertEqual(model1.status, suggestion_models.STATUS_REJECTED) + self.assertEqual( + model1.final_reviewer_id, feconf.SUGGESTION_BOT_USER_ID + ) diff --git a/core/jobs/batch_jobs/cloud_task_run_migration_jobs.py b/core/jobs/batch_jobs/cloud_task_run_migration_jobs.py new file mode 100644 index 0000000000000..87c8815c47f04 --- /dev/null +++ b/core/jobs/batch_jobs/cloud_task_run_migration_jobs.py @@ -0,0 +1,312 @@ +# coding: utf-8 +# +# Copyright 2026 The Oppia Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS-IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Jobs used to mark the CloudTaskRunModel entries as PERMENENTLY_FAILED that +have been stuck in the RUNNING or PENDING state for more than three days.""" + +from __future__ import annotations + +import datetime +import logging + +from core import feconf +from core.jobs import base_jobs +from core.jobs.io import ndb_io +from core.jobs.types import job_run_result +from core.platform import models + +import apache_beam as beam + +MYPY = False +if MYPY: # pragma: no cover + from mypy_imports import cloud_task_models, datastore_services + +(cloud_task_models,) = models.Registry.import_models([models.Names.CLOUD_TASK]) +datastore_services = models.Registry.import_datastore_services() + +# CloudTaskRunModel and VoiceoverRegenerationJobModel entries that remain +# in RUNNING or PENDING states beyond the allowed threshold are considered stale. +# Such entries are likely stuck due to unforeseen issues, so they are transitioned +# to PERMANENTLY_FAILED and FAILED respectively to ensure accurate tracking and recovery. +STALE_TASK_THRESHOLD_DAYS = 3 + + +class MarkStaleCloudTaskRunModelsAsFailedJob(base_jobs.JobBase): + """One-off job to mark CloudTaskRunModel entries as PERMANENTLY_FAILED if they + have been stuck in the RUNNING or PENDING state for more than three days.""" + + DATASTORE_UPDATES_ALLOWED = True + + def mark_stale_model_as_permanently_failed( + self, cloud_task_run_model: cloud_task_models.CloudTaskRunModel + ) -> cloud_task_models.CloudTaskRunModel: + """Marks the given CloudTaskRunModel's latest_job_state as + PERMANENTLY_FAILED and adds the exception message. + + Args: + cloud_task_run_model: CloudTaskRunModel. The model to be marked as + PERMANENTLY_FAILED. + + Returns: + CloudTaskRunModel. The updated CloudTaskRunModel with its + latest_job_state marked as PERMANENTLY_FAILED. + """ + with datastore_services.get_ndb_context(): + exception_message = ( + 'This CloudTaskRunModel was marked as PERMANENTLY_FAILED ' + 'automatically since it has been in the %s state for more than ' + 'three days.' % cloud_task_run_model.latest_job_state + ) + cloud_task_run_model.latest_job_state = ( + cloud_task_models.CloudTaskState.PERMANENTLY_FAILED.value + ) + cloud_task_run_model.exception_messages_for_failed_runs.append( + exception_message + ) + cloud_task_run_model.last_updated = datetime.datetime.now( + datetime.timezone.utc + ).replace(tzinfo=None) + + logging.info( + 'Marking the state of CloudTaskRunModel with id %s as PERMANENTLY_FAILED.' + % cloud_task_run_model.id + ) + + return cloud_task_run_model + + def run(self) -> beam.PCollection[job_run_result.JobRunResult]: + """Runs the MarkStaleCloudTaskRunModelsAsFailedJob. + + This job marks CloudTaskRunModel entries as PERMANENTLY_FAILED if they + have remained in the RUNNING or PENDING state for more than three days. + + Returns: + JobRunResult. Contains the total number of CloudTaskRunModel entries + marked as PERMANENTLY_FAILED, along with the IDs of those entries. + """ + # Stale CloudTaskRunModels are those that have been in the + # RUNNING or PENDING state for more than three days. + stale_cloud_task_run_models = ( + self.pipeline + | 'Get CloudTaskRunModels from the datastore' + >> ndb_io.GetModels(cloud_task_models.CloudTaskRunModel.get_all()) + | 'Filter CloudTaskRunModels in hanging state for more than three days' + >> beam.Filter( + lambda model: ( + model.latest_job_state + in [ + cloud_task_models.CloudTaskState.PENDING.value, + cloud_task_models.CloudTaskState.RUNNING.value, + ] + and ( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + >= model.last_updated + + datetime.timedelta(days=STALE_TASK_THRESHOLD_DAYS) + ) + ) + ) + ) + + updated_cloud_task_run_models = ( + stale_cloud_task_run_models + | 'Mark stale CloudTaskRunModel state as PERMANENTLY_FAILED' + >> beam.Map(self.mark_stale_model_as_permanently_failed) + ) + + count_run_result = ( + updated_cloud_task_run_models + | 'Count updated CloudTaskRunModels' + >> beam.combiners.Count.Globally() + | 'Format count to JobRunResult' + >> beam.Map( + lambda count: job_run_result.JobRunResult.as_stdout( + 'Number of CloudTaskRunModels updated to PERMANENTLY_FAILED: %d.' + % count + ) + ) + ) + + updated_model_ids_result = ( + updated_cloud_task_run_models + | 'Adds updated CloudTaskRunModel IDs to job run result' + >> beam.Map( + lambda model: job_run_result.JobRunResult.as_stdout( + 'Updated state of CloudTaskRunModel with ID: %s.' % model.id + ) + ) + ) + + if self.DATASTORE_UPDATES_ALLOWED: + _ = ( + updated_cloud_task_run_models + | 'Write updated CloudTaskRunModels to datastore' + >> ndb_io.PutModels() + ) + + return ( + count_run_result, + updated_model_ids_result, + ) | beam.Flatten() + + +class MarkStaleCloudTaskRunModelsAsFailedAuditJob( + MarkStaleCloudTaskRunModelsAsFailedJob +): + """Audit job to check for CloudTaskRunModel entries that have been stuck in the + RUNNING or PENDING state for more than three days and log their IDs.""" + + DATASTORE_UPDATES_ALLOWED = False + + +class MarkStaleVoiceoverRegenerationJobModelsAsFailedJob(base_jobs.JobBase): + """One-off job to update the content voiceover regeneration status in + VoiceoverRegenerationJobModel entries, marking them as FAILED if + they have remained in the GENERATING state for more than three days. + """ + + DATASTORE_UPDATES_ALLOWED = True + + def mark_stale_model_as_failed( + self, + voiceover_regeneration_task_mapping_model: cloud_task_models.VoiceoverRegenerationJobModel, + ) -> cloud_task_models.VoiceoverRegenerationJobModel: + """Marks the given VoiceoverRegenerationJobModel's content + voiceover generation status as FAILED. + + Args: + voiceover_regeneration_task_mapping_model: VoiceoverRegenerationJobModel. + The model to be marked as FAILED. + + Returns: + VoiceoverRegenerationJobModel. The updated VoiceoverRegenerationJobModel with its + content voiceover generation status marked as FAILED. + """ + counter = 0 + with datastore_services.get_ndb_context(): + for ( + language_accent_code, + content_status_map, + ) in ( + voiceover_regeneration_task_mapping_model.language_accent_to_content_status_map.items() + ): + for content_id, status in content_status_map.items(): + if ( + status + == feconf.VoiceoverRegenerationState.GENERATING.value + ): + voiceover_regeneration_task_mapping_model.language_accent_to_content_status_map[ + language_accent_code + ][ + content_id + ] = feconf.VoiceoverRegenerationState.FAILED.value + counter += 1 + + voiceover_regeneration_task_mapping_model.last_updated = ( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + ) + + logging.info( + 'Marked the GENERATING status of the %s contents to FAILED in VoiceoverRegenerationJobModel with ID: %s.' + % (counter, voiceover_regeneration_task_mapping_model.id) + ) + + return voiceover_regeneration_task_mapping_model + + def run(self) -> beam.PCollection[job_run_result.JobRunResult]: + """Runs the MarkStaleVoiceoverRegenerationJobModelsAsFailedJob. + + This job marks VoiceoverRegenerationJobModel entries as FAILED if they + have remained in the GENERATING state for more than three days. + + Returns: + JobRunResult. Contains the total number of VoiceoverRegenerationJobModel entries + marked as FAILED, along with the IDs of those entries. + """ + # Stale VoiceoverRegenerationJobModels are those that have been in the + # GENERATING state for more than three days. + stale_voiceover_regeneration_task_mapping_models = ( + self.pipeline + | 'Get VoiceoverRegenerationJobModels from the datastore' + >> ndb_io.GetModels( + cloud_task_models.VoiceoverRegenerationJobModel.get_all() + ) + | 'Filter VoiceoverRegenerationJobModels which was last updated more than three days ago' + >> beam.Filter( + lambda model: ( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + >= model.last_updated + + datetime.timedelta(days=STALE_TASK_THRESHOLD_DAYS) + ) + ) + ) + + updated_voiceover_regeneration_task_mapping_models = ( + stale_voiceover_regeneration_task_mapping_models + | 'Mark stale VoiceoverRegenerationJobModel state as FAILED' + >> beam.Map(self.mark_stale_model_as_failed) + ) + + count_run_result = ( + updated_voiceover_regeneration_task_mapping_models + | 'Count updated VoiceoverRegenerationJobModels' + >> beam.combiners.Count.Globally() + | 'Format count to JobRunResult' + >> beam.Map( + lambda count: job_run_result.JobRunResult.as_stdout( + 'Number of VoiceoverRegenerationJobModels updated to FAILED: %d.' + % count + ) + ) + ) + + updated_model_ids_result = ( + updated_voiceover_regeneration_task_mapping_models + | 'Adds updated VoiceoverRegenerationJobModel IDs to job run result' + >> beam.Map( + lambda model: job_run_result.JobRunResult.as_stdout( + 'Updated state of VoiceoverRegenerationJobModel with ID: %s.' + % model.id + ) + ) + ) + + if self.DATASTORE_UPDATES_ALLOWED: + _ = ( + updated_voiceover_regeneration_task_mapping_models + | 'Write updated VoiceoverRegenerationJobModels to datastore' + >> ndb_io.PutModels() + ) + + return ( + count_run_result, + updated_model_ids_result, + ) | beam.Flatten() + + +class MarkStaleVoiceoverRegenerationJobModelsAsFailedAuditJob( + MarkStaleVoiceoverRegenerationJobModelsAsFailedJob +): + """Audit job to check for content status in the VoiceoverRegenerationJobModel + entries that have been stuck in the GENERATING state for more than three + days and log their IDs.""" + + DATASTORE_UPDATES_ALLOWED = False diff --git a/core/jobs/batch_jobs/cloud_task_run_migration_jobs_test.py b/core/jobs/batch_jobs/cloud_task_run_migration_jobs_test.py new file mode 100644 index 0000000000000..b14b71062a578 --- /dev/null +++ b/core/jobs/batch_jobs/cloud_task_run_migration_jobs_test.py @@ -0,0 +1,1006 @@ +# coding: utf-8 +# +# Copyright 2026 The Oppia Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS-IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for cloud_task_run_migration_jobs.""" + +from __future__ import annotations + +import datetime + +from core import feconf +from core.jobs import job_test_utils +from core.jobs.batch_jobs import cloud_task_run_migration_jobs +from core.jobs.types import job_run_result +from core.platform import models + +from typing import Type + +MYPY = False +if MYPY: # pragma: no cover + from mypy_imports import cloud_task_models + +(cloud_task_models,) = models.Registry.import_models([models.Names.CLOUD_TASK]) + + +class MarkStaleCloudTaskRunModelsAsFailedJobTests(job_test_utils.JobTestBase): + """Tests for MarkStaleCloudTaskRunModelsAsFailedJob.""" + + JOB_CLASS: Type[ + cloud_task_run_migration_jobs.MarkStaleCloudTaskRunModelsAsFailedJob + ] = cloud_task_run_migration_jobs.MarkStaleCloudTaskRunModelsAsFailedJob + + def test_empty_storage(self) -> None: + """Test that the job runs successfully with empty storage.""" + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'Number of CloudTaskRunModels updated to PERMANENTLY_FAILED: 0.' + ) + ] + ) + + def test_no_stale_models(self) -> None: + """Test that the job doesn't update models that are not stale.""" + # Create a recent model in RUNNING state (should not be updated). + recent_model = self.create_model( + cloud_task_models.CloudTaskRunModel, + id='recent_model_id', + cloud_task_name='projects/test/locations/us-central1/queues/default/tasks/task1', + task_id='task1', + queue_id='default', + latest_job_state=cloud_task_models.CloudTaskState.RUNNING.value, + function_id='regenerate_voiceovers_on_exploration_update', + exception_messages_for_failed_runs=[], + current_retry_attempt=0, + last_updated=datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ), + ) + + # Create a model in SUCCEEDED state (should not be updated). + succeeded_model = self.create_model( + cloud_task_models.CloudTaskRunModel, + id='succeeded_model_id', + cloud_task_name='projects/test/locations/us-central1/queues/default/tasks/task2', + task_id='task2', + queue_id='default', + latest_job_state=cloud_task_models.CloudTaskState.SUCCEEDED.value, + function_id='regenerate_voiceovers_on_exploration_update', + exception_messages_for_failed_runs=[], + current_retry_attempt=0, + last_updated=( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + - datetime.timedelta(days=4) + ), + ) + + self.put_multi([recent_model, succeeded_model]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'Number of CloudTaskRunModels updated to PERMANENTLY_FAILED: 0.' + ) + ] + ) + + # Verify models were not updated. + updated_recent_model = cloud_task_models.CloudTaskRunModel.get( + 'recent_model_id' + ) + updated_succeeded_model = cloud_task_models.CloudTaskRunModel.get( + 'succeeded_model_id' + ) + + # Ruling out the possibility of None for mypy type checking. + assert updated_recent_model is not None + assert updated_succeeded_model is not None + + self.assertEqual( + updated_recent_model.latest_job_state, + cloud_task_models.CloudTaskState.RUNNING.value, + ) + self.assertEqual( + updated_succeeded_model.latest_job_state, + cloud_task_models.CloudTaskState.SUCCEEDED.value, + ) + + def test_updates_stale_running_model(self) -> None: + """Test that the job updates a model that has been RUNNING for more than 3 days.""" + stale_running_model = self.create_model( + cloud_task_models.CloudTaskRunModel, + id='stale_running_model_id', + cloud_task_name='projects/test/locations/us-central1/queues/default/tasks/task3', + task_id='task3', + queue_id='default', + latest_job_state=cloud_task_models.CloudTaskState.RUNNING.value, + function_id='regenerate_voiceovers_on_exploration_update', + exception_messages_for_failed_runs=[], + current_retry_attempt=2, + last_updated=( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + - datetime.timedelta(days=4) + ), + ) + + self.put_multi([stale_running_model]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'Number of CloudTaskRunModels updated to PERMANENTLY_FAILED: 1.' + ), + job_run_result.JobRunResult.as_stdout( + 'Updated state of CloudTaskRunModel with ID: stale_running_model_id.' + ), + ] + ) + + # Verify the model was updated correctly. + updated_model = cloud_task_models.CloudTaskRunModel.get( + 'stale_running_model_id' + ) + + # Ruling out the possibility of None for mypy type checking. + assert updated_model is not None + + self.assertEqual( + updated_model.latest_job_state, + cloud_task_models.CloudTaskState.PERMANENTLY_FAILED.value, + ) + self.assertEqual( + len(updated_model.exception_messages_for_failed_runs), 1 + ) + self.assertIn( + 'This CloudTaskRunModel was marked as PERMANENTLY_FAILED ' + 'automatically since it has been in the RUNNING state for more than ' + 'three days.', + updated_model.exception_messages_for_failed_runs[0], + ) + + def test_updates_stale_pending_model(self) -> None: + """Test that the job updates a model that has been PENDING for more than 3 days.""" + stale_pending_model = self.create_model( + cloud_task_models.CloudTaskRunModel, + id='stale_pending_model_id', + cloud_task_name='projects/test/locations/us-central1/queues/default/tasks/task4', + task_id='task4', + queue_id='default', + latest_job_state=cloud_task_models.CloudTaskState.PENDING.value, + function_id='regenerate_voiceovers_on_exploration_update', + exception_messages_for_failed_runs=[], + current_retry_attempt=0, + last_updated=( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + - datetime.timedelta(days=5) + ), + ) + + self.put_multi([stale_pending_model]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'Number of CloudTaskRunModels updated to PERMANENTLY_FAILED: 1.' + ), + job_run_result.JobRunResult.as_stdout( + 'Updated state of CloudTaskRunModel with ID: stale_pending_model_id.' + ), + ] + ) + + # Verify the model was updated correctly. + updated_model = cloud_task_models.CloudTaskRunModel.get( + 'stale_pending_model_id' + ) + + # Ruling out the possibility of None for mypy type checking. + assert updated_model is not None + + self.assertEqual( + updated_model.latest_job_state, + cloud_task_models.CloudTaskState.PERMANENTLY_FAILED.value, + ) + self.assertEqual( + len(updated_model.exception_messages_for_failed_runs), 1 + ) + self.assertIn( + 'This CloudTaskRunModel was marked as PERMANENTLY_FAILED ' + 'automatically since it has been in the PENDING state for more than ' + 'three days.', + updated_model.exception_messages_for_failed_runs[0], + ) + + def test_updates_multiple_stale_models(self) -> None: + """Test that the job updates multiple stale models correctly.""" + # Create multiple stale models. + stale_running_model = self.create_model( + cloud_task_models.CloudTaskRunModel, + id='stale_running_model_id', + cloud_task_name='projects/test/locations/us-central1/queues/default/tasks/task5', + task_id='task5', + queue_id='default', + latest_job_state=cloud_task_models.CloudTaskState.RUNNING.value, + function_id='regenerate_voiceovers_on_exploration_update', + exception_messages_for_failed_runs=[], + current_retry_attempt=1, + last_updated=( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + - datetime.timedelta(days=4) + ), + ) + + stale_pending_model = self.create_model( + cloud_task_models.CloudTaskRunModel, + id='stale_pending_model_id', + cloud_task_name='projects/test/locations/us-central1/queues/default/tasks/task6', + task_id='task6', + queue_id='default', + latest_job_state=cloud_task_models.CloudTaskState.PENDING.value, + function_id='regenerate_voiceovers_on_exploration_update', + exception_messages_for_failed_runs=[], + current_retry_attempt=0, + last_updated=( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + - datetime.timedelta(days=6) + ), + ) + + # Create a non-stale model that should not be updated. + fresh_model = self.create_model( + cloud_task_models.CloudTaskRunModel, + id='fresh_model_id', + cloud_task_name='projects/test/locations/us-central1/queues/default/tasks/task7', + task_id='task7', + queue_id='default', + latest_job_state=cloud_task_models.CloudTaskState.RUNNING.value, + function_id='regenerate_voiceovers_on_exploration_update', + exception_messages_for_failed_runs=[], + current_retry_attempt=0, + last_updated=( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + - datetime.timedelta(hours=12) + ), + ) + + self.put_multi([stale_running_model, stale_pending_model, fresh_model]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'Number of CloudTaskRunModels updated to PERMANENTLY_FAILED: 2.' + ), + job_run_result.JobRunResult.as_stdout( + 'Updated state of CloudTaskRunModel with ID: stale_running_model_id.' + ), + job_run_result.JobRunResult.as_stdout( + 'Updated state of CloudTaskRunModel with ID: stale_pending_model_id.' + ), + ] + ) + + # Verify the stale models were updated. + updated_running_model = cloud_task_models.CloudTaskRunModel.get( + 'stale_running_model_id' + ) + updated_pending_model = cloud_task_models.CloudTaskRunModel.get( + 'stale_pending_model_id' + ) + updated_fresh_model = cloud_task_models.CloudTaskRunModel.get( + 'fresh_model_id' + ) + + # Ruling out the possibility of None for mypy type checking. + assert updated_running_model is not None + assert updated_pending_model is not None + assert updated_fresh_model is not None + + # Check that stale models were updated. + self.assertEqual( + updated_running_model.latest_job_state, + cloud_task_models.CloudTaskState.PERMANENTLY_FAILED.value, + ) + self.assertEqual( + updated_pending_model.latest_job_state, + cloud_task_models.CloudTaskState.PERMANENTLY_FAILED.value, + ) + + # Check that the fresh model was not updated. + self.assertEqual( + updated_fresh_model.latest_job_state, + cloud_task_models.CloudTaskState.RUNNING.value, + ) + + def test_preserves_existing_exception_messages(self) -> None: + """Test that the job preserves existing exception messages when updating a model.""" + existing_exception_message = 'Previous error occurred' + stale_model_with_exceptions = self.create_model( + cloud_task_models.CloudTaskRunModel, + id='stale_model_with_exceptions_id', + cloud_task_name='projects/test/locations/us-central1/queues/default/tasks/task8', + task_id='task8', + queue_id='default', + latest_job_state=cloud_task_models.CloudTaskState.RUNNING.value, + function_id='regenerate_voiceovers_on_exploration_update', + exception_messages_for_failed_runs=[existing_exception_message], + current_retry_attempt=3, + last_updated=( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + - datetime.timedelta(days=4) + ), + ) + + self.put_multi([stale_model_with_exceptions]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'Number of CloudTaskRunModels updated to PERMANENTLY_FAILED: 1.' + ), + job_run_result.JobRunResult.as_stdout( + 'Updated state of CloudTaskRunModel with ID: stale_model_with_exceptions_id.' + ), + ] + ) + + # Verify the model was updated and existing exception message was preserved. + updated_model = cloud_task_models.CloudTaskRunModel.get( + 'stale_model_with_exceptions_id' + ) + + # Ruling out the possibility of None for mypy type checking. + assert updated_model is not None + + self.assertEqual( + updated_model.latest_job_state, + cloud_task_models.CloudTaskState.PERMANENTLY_FAILED.value, + ) + self.assertEqual( + len(updated_model.exception_messages_for_failed_runs), 2 + ) + self.assertEqual( + updated_model.exception_messages_for_failed_runs[0], + existing_exception_message, + ) + self.assertIn( + 'This CloudTaskRunModel was marked as PERMANENTLY_FAILED ' + 'automatically since it has been in the RUNNING state for more than ' + 'three days.', + updated_model.exception_messages_for_failed_runs[1], + ) + + def test_should_update_model_that_is_exactly_three_days_old(self) -> None: + """Test that a model that is exactly 3 days old gets updated.""" + exactly_three_days_old_model = self.create_model( + cloud_task_models.CloudTaskRunModel, + id='exactly_three_days_old_id', + cloud_task_name='projects/test/locations/us-central1/queues/default/tasks/task10', + task_id='task10', + queue_id='default', + latest_job_state=cloud_task_models.CloudTaskState.PENDING.value, + function_id='regenerate_voiceovers_on_exploration_update', + exception_messages_for_failed_runs=[], + current_retry_attempt=0, + last_updated=( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + - datetime.timedelta(days=3) + ), + ) + + self.put_multi([exactly_three_days_old_model]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'Number of CloudTaskRunModels updated to PERMANENTLY_FAILED: 1.' + ), + job_run_result.JobRunResult.as_stdout( + 'Updated state of CloudTaskRunModel with ID: exactly_three_days_old_id.' + ), + ] + ) + + def test_should_not_update_model_that_is_just_under_three_days_old( + self, + ) -> None: + """Test that a model that is just under 3 days old does not get updated.""" + just_under_three_days_old_model = self.create_model( + cloud_task_models.CloudTaskRunModel, + id='just_under_three_days_old_id', + cloud_task_name='projects/test/locations/us-central1/queues/default/tasks/task11', + task_id='task11', + queue_id='default', + latest_job_state=cloud_task_models.CloudTaskState.RUNNING.value, + function_id='regenerate_voiceovers_on_exploration_update', + exception_messages_for_failed_runs=[], + current_retry_attempt=0, + last_updated=( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + - datetime.timedelta(days=3) + + datetime.timedelta(minutes=1) + ), + ) + + self.put_multi([just_under_three_days_old_model]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'Number of CloudTaskRunModels updated to PERMANENTLY_FAILED: 0.' + ) + ] + ) + + # Verify the model was not updated. + updated_model = cloud_task_models.CloudTaskRunModel.get( + 'just_under_three_days_old_id' + ) + + # Ruling out the possibility of None for mypy type checking. + assert updated_model is not None + + self.assertEqual( + updated_model.latest_job_state, + cloud_task_models.CloudTaskState.RUNNING.value, + ) + + def test_should_not_update_model_with_failed_and_awaiting_retry_state( + self, + ) -> None: + """Test that models in FAILED_AND_AWAITING_RETRY state are not updated.""" + failed_and_awaiting_retry_model = self.create_model( + cloud_task_models.CloudTaskRunModel, + id='failed_and_awaiting_retry_id', + cloud_task_name='projects/test/locations/us-central1/queues/default/tasks/task12', + task_id='task12', + queue_id='default', + latest_job_state=cloud_task_models.CloudTaskState.FAILED_AND_AWAITING_RETRY.value, + function_id='regenerate_voiceovers_on_exploration_update', + exception_messages_for_failed_runs=['Failed but will retry'], + current_retry_attempt=1, + last_updated=( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + - datetime.timedelta(days=5) + ), + ) + + self.put_multi([failed_and_awaiting_retry_model]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'Number of CloudTaskRunModels updated to PERMANENTLY_FAILED: 0.' + ) + ] + ) + + # Verify the model was not updated. + updated_model = cloud_task_models.CloudTaskRunModel.get( + 'failed_and_awaiting_retry_id' + ) + + # Ruling out the possibility of None for mypy type checking. + assert updated_model is not None + + self.assertEqual( + updated_model.latest_job_state, + cloud_task_models.CloudTaskState.FAILED_AND_AWAITING_RETRY.value, + ) + + def test_should_not_update_model_with_permanently_failed_state( + self, + ) -> None: + """Test that models already in PERMANENTLY_FAILED state are not updated.""" + permanently_failed_model = self.create_model( + cloud_task_models.CloudTaskRunModel, + id='permanently_failed_id', + cloud_task_name='projects/test/locations/us-central1/queues/default/tasks/task13', + task_id='task13', + queue_id='default', + latest_job_state=cloud_task_models.CloudTaskState.PERMANENTLY_FAILED.value, + function_id='regenerate_voiceovers_on_exploration_update', + exception_messages_for_failed_runs=['Already failed permanently'], + current_retry_attempt=3, + last_updated=( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + - datetime.timedelta(days=10) + ), + ) + + self.put_multi([permanently_failed_model]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'Number of CloudTaskRunModels updated to PERMANENTLY_FAILED: 0.' + ) + ] + ) + + # Verify the model was not updated. + updated_model = cloud_task_models.CloudTaskRunModel.get( + 'permanently_failed_id' + ) + + # Ruling out the possibility of None for mypy type checking. + assert updated_model is not None + + self.assertEqual( + updated_model.latest_job_state, + cloud_task_models.CloudTaskState.PERMANENTLY_FAILED.value, + ) + # Should still have only the original exception message. + self.assertEqual( + len(updated_model.exception_messages_for_failed_runs), 1 + ) + self.assertEqual( + updated_model.exception_messages_for_failed_runs[0], + 'Already failed permanently', + ) + + +class MarkStaleCloudTaskRunModelsAsFailedAuditJobTests( + job_test_utils.JobTestBase +): + """Tests for MarkStaleCloudTaskRunModelsAsFailedAuditJob.""" + + JOB_CLASS: Type[ + cloud_task_run_migration_jobs.MarkStaleCloudTaskRunModelsAsFailedAuditJob + ] = ( + cloud_task_run_migration_jobs.MarkStaleCloudTaskRunModelsAsFailedAuditJob + ) + + def test_audit_job_does_not_update_models(self) -> None: + """Test that the audit job does not update any models and only logs the IDs of stale models.""" + # Create a stale model in RUNNING state (should be logged but not updated). + stale_model = self.create_model( + cloud_task_models.CloudTaskRunModel, + id='stale_model_id', + cloud_task_name='projects/test/locations/us-central1/queues/default/tasks/task14', + task_id='task14', + queue_id='default', + latest_job_state=cloud_task_models.CloudTaskState.RUNNING.value, + function_id='regenerate_voiceovers_on_exploration_update', + exception_messages_for_failed_runs=[], + current_retry_attempt=0, + last_updated=( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + - datetime.timedelta(days=4) + ), + ) + + self.put_multi([stale_model]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'Number of CloudTaskRunModels updated to PERMANENTLY_FAILED: 1.' + ), + job_run_result.JobRunResult.as_stdout( + 'Updated state of CloudTaskRunModel with ID: stale_model_id.' + ), + ] + ) + + # Verify the model was not updated. + updated_model = cloud_task_models.CloudTaskRunModel.get( + 'stale_model_id' + ) + + # Ruling out the possibility of None for mypy type checking. + assert updated_model is not None + + self.assertEqual( + updated_model.latest_job_state, + cloud_task_models.CloudTaskState.RUNNING.value, + ) + + +class MarkStaleVoiceoverRegenerationJobModelsAsFailedJobTests( + job_test_utils.JobTestBase +): + """Tests for MarkStaleVoiceoverRegenerationJobModelsAsFailedJob.""" + + JOB_CLASS: Type[ + cloud_task_run_migration_jobs.MarkStaleVoiceoverRegenerationJobModelsAsFailedJob + ] = ( + cloud_task_run_migration_jobs.MarkStaleVoiceoverRegenerationJobModelsAsFailedJob + ) + + def test_empty_storage(self) -> None: + """Test that the job runs successfully with empty storage.""" + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'Number of VoiceoverRegenerationJobModels updated to FAILED: 0.' + ) + ] + ) + + def test_no_stale_models(self) -> None: + """Test that the job does not update non-stale models.""" + fresh_model = self.create_model( + cloud_task_models.VoiceoverRegenerationJobModel, + id='exp1:taskrun1', + exploration_id='exp1', + cloud_task_run_id='taskrun1', + language_accent_to_content_status_map={ + 'en-us': { + 'content_1': ( + feconf.VoiceoverRegenerationState.GENERATING.value + ) + } + }, + last_updated=( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + - datetime.timedelta(days=2) + ), + ) + + self.put_multi([fresh_model]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'Number of VoiceoverRegenerationJobModels updated to FAILED: 0.' + ) + ] + ) + + updated_model = cloud_task_models.VoiceoverRegenerationJobModel.get( + 'exp1:taskrun1' + ) + + # Ruling out the possibility of None for mypy type checking. + assert updated_model is not None + + self.assertEqual( + updated_model.language_accent_to_content_status_map, + { + 'en-us': { + 'content_1': ( + feconf.VoiceoverRegenerationState.GENERATING.value + ) + } + }, + ) + + def test_updates_stale_model_generating_statuses_to_failed(self) -> None: + """Test that stale GENERATING statuses are marked as FAILED.""" + stale_model = self.create_model( + cloud_task_models.VoiceoverRegenerationJobModel, + id='exp2:taskrun2', + exploration_id='exp2', + cloud_task_run_id='taskrun2', + language_accent_to_content_status_map={ + 'en-us': { + 'content_1': ( + feconf.VoiceoverRegenerationState.GENERATING.value + ), + 'content_2': ( + feconf.VoiceoverRegenerationState.SUCCEEDED.value + ), + }, + 'hi-in': { + 'content_3': ( + feconf.VoiceoverRegenerationState.GENERATING.value + ) + }, + }, + last_updated=( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + - datetime.timedelta(days=4) + ), + ) + + self.put_multi([stale_model]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'Number of VoiceoverRegenerationJobModels updated to FAILED: 1.' + ), + job_run_result.JobRunResult.as_stdout( + 'Updated state of VoiceoverRegenerationJobModel with ID: exp2:taskrun2.' + ), + ] + ) + + updated_model = cloud_task_models.VoiceoverRegenerationJobModel.get( + 'exp2:taskrun2' + ) + + # Ruling out the possibility of None for mypy type checking. + assert updated_model is not None + + self.assertEqual( + updated_model.language_accent_to_content_status_map, + { + 'en-us': { + 'content_1': feconf.VoiceoverRegenerationState.FAILED.value, + 'content_2': ( + feconf.VoiceoverRegenerationState.SUCCEEDED.value + ), + }, + 'hi-in': { + 'content_3': feconf.VoiceoverRegenerationState.FAILED.value + }, + }, + ) + + def test_updates_multiple_stale_models(self) -> None: + """Test that the job updates multiple stale models correctly.""" + stale_model_1 = self.create_model( + cloud_task_models.VoiceoverRegenerationJobModel, + id='exp3:taskrun3', + exploration_id='exp3', + cloud_task_run_id='taskrun3', + language_accent_to_content_status_map={ + 'en-us': { + 'content_1': ( + feconf.VoiceoverRegenerationState.GENERATING.value + ) + } + }, + last_updated=( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + - datetime.timedelta(days=5) + ), + ) + + stale_model_2 = self.create_model( + cloud_task_models.VoiceoverRegenerationJobModel, + id='exp4:taskrun4', + exploration_id='exp4', + cloud_task_run_id='taskrun4', + language_accent_to_content_status_map={ + 'hi-in': { + 'content_2': ( + feconf.VoiceoverRegenerationState.GENERATING.value + ) + } + }, + last_updated=( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + - datetime.timedelta(days=4) + ), + ) + + fresh_model = self.create_model( + cloud_task_models.VoiceoverRegenerationJobModel, + id='exp5:taskrun5', + exploration_id='exp5', + cloud_task_run_id='taskrun5', + language_accent_to_content_status_map={ + 'en-us': { + 'content_3': ( + feconf.VoiceoverRegenerationState.GENERATING.value + ) + } + }, + last_updated=( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + - datetime.timedelta(hours=23) + ), + ) + + self.put_multi([stale_model_1, stale_model_2, fresh_model]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'Number of VoiceoverRegenerationJobModels updated to FAILED: 2.' + ), + job_run_result.JobRunResult.as_stdout( + 'Updated state of VoiceoverRegenerationJobModel with ID: exp3:taskrun3.' + ), + job_run_result.JobRunResult.as_stdout( + 'Updated state of VoiceoverRegenerationJobModel with ID: exp4:taskrun4.' + ), + ] + ) + + updated_stale_model_1 = ( + cloud_task_models.VoiceoverRegenerationJobModel.get('exp3:taskrun3') + ) + updated_stale_model_2 = ( + cloud_task_models.VoiceoverRegenerationJobModel.get('exp4:taskrun4') + ) + updated_fresh_model = ( + cloud_task_models.VoiceoverRegenerationJobModel.get('exp5:taskrun5') + ) + + # Ruling out the possibility of None for mypy type checking. + assert updated_stale_model_1 is not None + assert updated_stale_model_2 is not None + assert updated_fresh_model is not None + + self.assertEqual( + updated_stale_model_1.language_accent_to_content_status_map, + { + 'en-us': { + 'content_1': feconf.VoiceoverRegenerationState.FAILED.value + } + }, + ) + self.assertEqual( + updated_stale_model_2.language_accent_to_content_status_map, + { + 'hi-in': { + 'content_2': feconf.VoiceoverRegenerationState.FAILED.value + } + }, + ) + self.assertEqual( + updated_fresh_model.language_accent_to_content_status_map, + { + 'en-us': { + 'content_3': ( + feconf.VoiceoverRegenerationState.GENERATING.value + ) + } + }, + ) + + def test_should_update_model_that_is_exactly_three_days_old(self) -> None: + """Test that a model exactly three days old gets updated.""" + exactly_three_days_old_model = self.create_model( + cloud_task_models.VoiceoverRegenerationJobModel, + id='exp6:taskrun6', + exploration_id='exp6', + cloud_task_run_id='taskrun6', + language_accent_to_content_status_map={ + 'en-us': { + 'content_1': ( + feconf.VoiceoverRegenerationState.GENERATING.value + ) + } + }, + last_updated=( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + - datetime.timedelta(days=3) + ), + ) + + self.put_multi([exactly_three_days_old_model]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'Number of VoiceoverRegenerationJobModels updated to FAILED: 1.' + ), + job_run_result.JobRunResult.as_stdout( + 'Updated state of VoiceoverRegenerationJobModel with ID: exp6:taskrun6.' + ), + ] + ) + + +class MarkStaleVoiceoverRegenerationJobModelsAsFailedAuditJobTests( + job_test_utils.JobTestBase +): + """Tests for MarkStaleVoiceoverRegenerationJobModelsAsFailedAuditJob.""" + + JOB_CLASS: Type[ + cloud_task_run_migration_jobs.MarkStaleVoiceoverRegenerationJobModelsAsFailedAuditJob + ] = ( + cloud_task_run_migration_jobs.MarkStaleVoiceoverRegenerationJobModelsAsFailedAuditJob + ) + + def test_empty_storage(self) -> None: + """Test that the audit job runs successfully with empty storage.""" + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'Number of VoiceoverRegenerationJobModels updated to FAILED: 0.' + ) + ] + ) + + def test_audit_job_does_not_update_models(self) -> None: + """Test that the audit job does not update stale models.""" + stale_model = self.create_model( + cloud_task_models.VoiceoverRegenerationJobModel, + id='exp7:taskrun7', + exploration_id='exp7', + cloud_task_run_id='taskrun7', + language_accent_to_content_status_map={ + 'en-us': { + 'content_1': ( + feconf.VoiceoverRegenerationState.GENERATING.value + ), + 'content_2': ( + feconf.VoiceoverRegenerationState.SUCCEEDED.value + ), + } + }, + last_updated=( + datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) + - datetime.timedelta(days=4) + ), + ) + + self.put_multi([stale_model]) + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'Number of VoiceoverRegenerationJobModels updated to FAILED: 1.' + ), + job_run_result.JobRunResult.as_stdout( + 'Updated state of VoiceoverRegenerationJobModel with ID: exp7:taskrun7.' + ), + ] + ) + + updated_model = cloud_task_models.VoiceoverRegenerationJobModel.get( + 'exp7:taskrun7' + ) + + # Ruling out the possibility of None for mypy type checking. + assert updated_model is not None + + self.assertEqual( + updated_model.language_accent_to_content_status_map, + { + 'en-us': { + 'content_1': ( + feconf.VoiceoverRegenerationState.GENERATING.value + ), + 'content_2': ( + feconf.VoiceoverRegenerationState.SUCCEEDED.value + ), + } + }, + ) diff --git a/core/jobs/batch_jobs/exp_migration_jobs_test.py b/core/jobs/batch_jobs/exp_migration_jobs_test.py index 4c9d125794fda..8d7ccdc11710a 100644 --- a/core/jobs/batch_jobs/exp_migration_jobs_test.py +++ b/core/jobs/batch_jobs/exp_migration_jobs_test.py @@ -483,6 +483,7 @@ def test_unmigrated_valid_published_exp_migrates(self) -> None: 'content_count': 4, 'translation_counts': {'hi': 0, 'bn': 0}, 'translation_in_review_counts': {}, + 'reviewer_only_content_count': 0, 'is_pinned': False, } @@ -952,6 +953,7 @@ def test_unmigrated_exp_is_migrated(self) -> None: 'content_count': 4, 'translation_counts': {'hi': 0}, 'translation_in_review_counts': {}, + 'reviewer_only_content_count': 0, 'is_pinned': False, } diff --git a/core/jobs/batch_jobs/opportunity_management_jobs.py b/core/jobs/batch_jobs/opportunity_management_jobs.py index 7e5d7a3dd170e..252cf0afa4265 100644 --- a/core/jobs/batch_jobs/opportunity_management_jobs.py +++ b/core/jobs/batch_jobs/opportunity_management_jobs.py @@ -377,6 +377,9 @@ def _generate_opportunities_related_to_topic( language_codes_with_assigned_voice_artists=( opportunity.language_codes_with_assigned_voice_artists ), + reviewer_only_content_count=( + opportunity.reviewer_only_content_count + ), ) model.update_timestamps() exploration_opportunity_summary_model_list.append(model) diff --git a/core/jobs/batch_jobs/suggestion_migration_jobs.py b/core/jobs/batch_jobs/suggestion_migration_jobs.py index 53a1cfb873963..90f9b72893147 100644 --- a/core/jobs/batch_jobs/suggestion_migration_jobs.py +++ b/core/jobs/batch_jobs/suggestion_migration_jobs.py @@ -170,9 +170,19 @@ def run(self) -> beam.PCollection[job_run_result.JobRunResult]: | 'Merge objects' >> beam.CoGroupByKey() | 'Get rid of ID' >> beam.Values() # pylint: disable=no-value-for-parameter + | 'Convert to lists' + >> beam.Map( + lambda objects: { + 'suggestion_models': list(objects['suggestion_models']), + 'exploration_model': list(objects['exploration_model']), + } + ) | 'Filter unwanted exploration' >> beam.Filter( - lambda objects: len(objects['suggestion_models']) != 0 + lambda objects: ( + len(objects['suggestion_models']) > 0 + and len(objects['exploration_model']) > 0 + ) ) | 'Transform and migrate model' >> beam.Map( diff --git a/core/jobs/batch_jobs/synthesize_voiceover_by_language_accent_jobs.py b/core/jobs/batch_jobs/synthesize_voiceover_by_language_accent_jobs.py new file mode 100644 index 0000000000000..f49cdfa69625f --- /dev/null +++ b/core/jobs/batch_jobs/synthesize_voiceover_by_language_accent_jobs.py @@ -0,0 +1,603 @@ +# coding: utf-8 +# +# Copyright 2026 The Oppia Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS-IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Jobs used for regenerating voiceovers for all the curated explorations by +language accent.""" + +from __future__ import annotations + +import collections +import logging +import traceback + +from core import feconf +from core.domain import ( + exp_fetchers, + opportunity_services, + translation_fetchers, + voiceover_domain, + voiceover_regeneration_services, + voiceover_services, +) +from core.jobs import base_jobs, job_options +from core.jobs.io import ndb_io +from core.jobs.types import job_run_result +from core.platform import models + +import apache_beam as beam +from typing import Dict, Iterator, Optional, Sequence, Tuple, Union, cast + +MYPY = False +if MYPY: # pragma: no cover + from mypy_imports import ( + datastore_services, + exp_models, + translation_models, + voiceover_models, + ) + +(exp_models, translation_models, voiceover_models) = ( + models.Registry.import_models( + [ + models.Names.EXPLORATION, + models.Names.TRANSLATION, + models.Names.VOICEOVER, + ] + ) +) +datastore_services = models.Registry.import_datastore_services() + + +# TODO(#15613): Here we use MyPy ignore because the incomplete typing of +# apache_beam library and absences of stubs in Typeshed, forces MyPy to +# assume that PTransform class is of type Any. Thus to avoid MyPy's error +# (Class cannot subclass 'PTransform' (has type 'Any')), we added an +# ignore here. +class GenerateVoiceoversFn(beam.DoFn): # type: ignore[misc] + """A DoFn that generates voiceovers for a given exploration.""" + + def __init__( + self, + oppia_project_id: Optional[str] = None, + language_accent_code: Optional[str] = None, + ) -> None: + super().__init__() + logging.info( + 'Voiceover synthesis log: Initializing GenerateVoiceoversFn.' + ) + + self.oppia_project_id = oppia_project_id + logging.info( + 'Voiceover synthesis log: Setting oppia project ID from args: %s' + % self.oppia_project_id, + ) + + self.language_accent_code = language_accent_code + logging.info( + 'Voiceover synthesis log: Setting language accent code from args: %s' + % self.language_accent_code, + ) + + def process( + self, + combined_models: Tuple[ + str, + Dict[ + str, + Sequence[exp_models.ExplorationModel] + | Sequence[voiceover_models.EntityVoiceoversModel] + | Sequence[translation_models.EntityTranslationsModel], + ], + ], + autogeneration_policy_model: ( + voiceover_models.VoiceoverAutogenerationPolicyModel + ), + ) -> Iterator[ + Union[ + voiceover_models.EntityVoiceoversModel, + beam.pvalue.TaggedOutput[str], + ] + ]: + """Method to process each element in the PCollection. + + Args: + combined_models: tuple(str, dict). A tuple where the first element + is the exploration ID and the second element is a dictionary + with keys 'exploration', 'translations' and 'voiceovers' mapping + to a list of corresponding models. + autogeneration_policy_model: VoiceoverAutogenerationPolicyModel. + The voiceover autogeneration policy model. + + Yields: + EntityVoiceoversModel. The generated entity voiceover models. + str. The status string for the voiceover generation process. + """ + entity_id = combined_models[0] + logging.info( + 'Voiceover synthesis log: Processing exploration with ID: %s' + % entity_id + ) + # Here we use cast because we are narrowing down the type of + # exploration field in combined_models to Exploration model. + exploration_model = cast( + exp_models.ExplorationModel, combined_models[1]['exploration'][0] + ) + # Here we use cast because we are narrowing down the type of + # translations field in combined_models to Sequence of + # EntityTranslationsModel. + entity_translation_models = cast( + Sequence[translation_models.EntityTranslationsModel], + combined_models[1]['translations'], + ) + # Here we use cast because we are narrowing down the type of + # voiceovers field in combined_models to Sequence of EntityVoiceoversModel. + entity_voiceover_models = cast( + Sequence[voiceover_models.EntityVoiceoversModel], + combined_models[1]['voiceovers'], + ) + + entity_voiceovers, status_string = ( + VoiceoverSynthesisByAccentJob.generate_voiceovers_for_exploration( + exploration_model=exploration_model, + entity_translation_models=entity_translation_models, + entity_voiceover_models=entity_voiceover_models, + voiceover_policy_model=autogeneration_policy_model, + language_accent_code_to_generate=self.language_accent_code, + oppia_project_id=self.oppia_project_id, + ) + ) + + logging.info( + 'Voiceover synthesis log: Completed generating voiceovers for exploration ID: %s' + % entity_id + ) + + # Yield entity voiceovers to main output. + yield entity_voiceovers + + # Yield status string to tagged side output. + yield beam.pvalue.TaggedOutput('status', status_string) + + +class VoiceoverSynthesisByAccentJob(base_jobs.JobBase): + """A one-off job to generate voiceovers for all curated explorations in + English and other supported translated languages. + """ + + DATASTORE_UPDATES_ALLOWED = True + + @staticmethod + def is_exploration_curated(exploration_id: str) -> Optional[bool]: + """Checks whether the provided exploration ID belongs to a curated + exploration or not. + + Args: + exploration_id: str. The given exploration ID. + + Returns: + bool. A boolean value indicating if the exploration is curated + or not. + """ + with datastore_services.get_ndb_context(): + return ( + opportunity_services.is_exploration_available_for_contribution( + exploration_id + ) + ) + + @classmethod + def generate_voiceovers_for_exploration( + cls, + exploration_model: exp_models.ExplorationModel, + entity_translation_models: Sequence[ + translation_models.EntityTranslationsModel + ], + entity_voiceover_models: Sequence[ + voiceover_models.EntityVoiceoversModel + ], + voiceover_policy_model: voiceover_models.VoiceoverAutogenerationPolicyModel, + language_accent_code_to_generate: Optional[str] = None, + oppia_project_id: Optional[str] = None, + ) -> Tuple[Optional[voiceover_models.EntityVoiceoversModel], str]: + """Generates voiceovers in English and all translated languages, + covering every supported accent for the given exploration. + + Args: + exploration_model: ExplorationModel. The exploration model for which + to generate voiceovers. + entity_translation_models: list(EntityTranslationsModel). The + existing entity translation models related to the exploration. + entity_voiceover_models: list(EntityVoiceoversModel). The existing + entity voiceover models related to the exploration. + voiceover_policy_model: VoiceoverAutogenerationPolicyModel. The + voiceover autogeneration policy model. + language_accent_code_to_generate: str. The language accent code for which to + generate voiceovers. + oppia_project_id: Optional[str]. The Google Cloud Project ID. + Explicitly required when running on Beam Dataflow, as workers + cannot retrieve the ID from environment variables. + + Returns: + Tuple[Optional[EntityVoiceoversModel], str]. A tuple containing the + EntityVoiceoversModel that was updated or created (or None if no + voiceovers were generated) and a string with logs during voiceover + generation. + """ + if language_accent_code_to_generate is None: + message = ( + 'Not generating voiceovers for exploration ID: %s since language accent code is None.' + % exploration_model.id + ) + return (None, message) + + assert isinstance(language_accent_code_to_generate, str) + + logs_during_voiceover_generation = '' + + logs_during_voiceover_generation += ( + 'Exploration ID: %s.\n' % exploration_model.id + ) + + entity_translations_list = [] + required_entity_voiceovers_for_update = None + + with datastore_services.get_ndb_context(): + # Converting exploration model to domain object. + exploration = exp_fetchers.get_exploration_from_model( + exploration_model, False + ) + logging.info( + 'Voiceover synthesis log: Converted exploration model to exploration domain object.' + ) + + # Converting EntityTranslationsModels to domain objects. + for entity_translation_model in list(entity_translation_models): + entity_translations_list.append( + translation_fetchers.get_entity_translation_from_model( + entity_translation_model + ) + ) + logging.info( + 'Voiceover synthesis log: Converted entity translation models to ' + 'entity translation domain objects.' + ) + + # Converting EntityVoiceoversModels to domain objects. + for entity_voiceover_model in list(entity_voiceover_models): + # Filtering out EntityVoiceoversModels that match the current + # exploration version and the language accent code for which + # we want to generate voiceovers. + if ( + entity_voiceover_model.entity_version == exploration.version + and entity_voiceover_model.language_accent_code + == language_accent_code_to_generate + ): + required_entity_voiceovers_for_update = ( + voiceover_services.get_entity_voiceovers_from_model( + entity_voiceover_model + ) + ) + break + + logging.info( + 'Voiceover synthesis log: Converted entity voiceover model to ' + 'entity voiceover domain object.' + ) + + # Extracting language codes mapping from the autogeneration policy + # model. + language_codes_mapping = ( + voiceover_policy_model.language_codes_mapping + ) + + entity_type = feconf.ENTITY_TYPE_EXPLORATION + entity_id = exploration.id + entity_version = exploration.version + + language_code_to_generate = '' + + for language_code, accent_mapping in language_codes_mapping.items(): + for accent_code, is_autogeneratable in accent_mapping.items(): + + # Getting the language code for which to generate voiceovers. + if ( + is_autogeneratable + and accent_code == language_accent_code_to_generate + ): + language_code_to_generate = language_code + break + + # A dictionary where each key is a language code, and each value is a + # content mapping dictionary. The content mapping dictionary contains + # content IDs as keys and their corresponding HTML content as values. + language_code_to_contents_mapping = {} + + language_code_to_contents_mapping.update( + voiceover_services.extract_english_voiceover_texts_from_exploration( + exploration + ) + ) + language_code_to_contents_mapping.update( + voiceover_services.extract_translated_voiceover_texts_from_entity_translations( + entity_translations_list + ) + ) + + content_ids_to_content_values = language_code_to_contents_mapping.get( + language_code_to_generate, {} + ) + logging.info( + 'Voiceover synthesis log: language code %s' + % language_code_to_generate + ) + logging.info( + 'Voiceover synthesis log: language_code_to_contents_mapping: %s' + % language_code_to_contents_mapping, + ) + + # Skip voiceover generation for this exploration if its curated contents + # do not include any content in the target language code. + if not content_ids_to_content_values: + logs_during_voiceover_generation += ( + 'No content found for language code: %s.' + % language_code_to_generate + ) + return (None, logs_during_voiceover_generation) + + entity_voiceovers_id = '%s-%s-%s-%s' % ( + entity_type, + entity_id, + str(entity_version), + language_accent_code_to_generate, + ) + + # If no existing EntityVoiceovers domain object is found for the current + # exploration version and specified language accent, an empty object can + # be created and later populated by the job. + if required_entity_voiceovers_for_update is None: + required_entity_voiceovers_for_update = ( + voiceover_domain.EntityVoiceovers.create_empty( + entity_id, + entity_type, + entity_version, + language_accent_code_to_generate, + ) + ) + + error_message_to_content_ids_dict = collections.defaultdict(list) + + logging.info( + 'Voiceover synthesis log: Generating voiceovers for Entityvoiceover with ID: %s.', + entity_voiceovers_id, + ) + logs_during_voiceover_generation += ( + 'EntityVoiceovers ID: %s.\n' % entity_voiceovers_id + ) + + number_of_content_ids = len(content_ids_to_content_values) + number_of_characters = 0 + + logging.info( + 'Voiceover synthesis log: number_of_content_ids: %s.', + number_of_content_ids, + ) + + for content_id, content_html in content_ids_to_content_values.items(): + try: + voiceover_filename = voiceover_regeneration_services.generate_new_voiceover_filename( + content_id, language_accent_code_to_generate + ) + logging.info( + 'Voiceover synthesis log: Generated new voiceover filename: %s for content_id: %s, content_html: %s.' + % (voiceover_filename, content_id, content_html) + ) + + with datastore_services.get_ndb_context(): + sentence_tokens_with_durations = voiceover_regeneration_services.synthesize_voiceover_for_html_string( + entity_id, + content_html, + language_accent_code_to_generate, + voiceover_filename, + oppia_project_id, + ) + + if not sentence_tokens_with_durations: + continue + + voiceover = voiceover_regeneration_services.fetch_voiceover_by_filename( + entity_id, voiceover_filename, oppia_project_id + ) + + number_of_characters += len(content_html) + + required_entity_voiceovers_for_update.add_voiceover( + content_id, feconf.VoiceoverType.AUTO, voiceover + ) + required_entity_voiceovers_for_update.add_automated_voiceovers_audio_offsets( + content_id, sentence_tokens_with_durations + ) + + logging.info( + 'Voiceover synthesis log: Generated voiceover for content_id: %s.', + content_id, + ) + except Exception as error: + error_message_to_content_ids_dict[str(error)].append(content_id) + stack_trace = traceback.format_exc() + logging.error( + 'Voiceover synthesis log: Stack trace: %s', + stack_trace, + ) + logging.error( + 'Voiceover synthesis log: Error generating voiceover for exploration ID: %s, language_accent_code: %s, content_id: %s. Error: %s' + % ( + entity_id, + language_accent_code_to_generate, + content_id, + str(error), + ) + ) + + for ( + error_message, + content_ids, + ) in error_message_to_content_ids_dict.items(): + comma_separated_content_ids = ', '.join(content_ids) + logs_during_voiceover_generation += ( + 'Content IDs failed: [%s]. Error message: %s\n' + % (comma_separated_content_ids, error_message) + ) + + final_report_logs = ( + 'Total content IDs processed: %d. ' + 'Total characters processed: %d.\n' + % ( + number_of_content_ids, + number_of_characters, + ) + ) + logging.info('Voiceover synthesis log: %s.' % final_report_logs) + logs_during_voiceover_generation += final_report_logs + logging.info( + 'Voiceover synthesis log: Completed voiceover generation for entity ID: %s.' + % entity_voiceovers_id + ) + + # EntityVoiceoversModel instance to be stored in the datastore. + with datastore_services.get_ndb_context(): + required_entity_voiceovers_for_update.validate() + updated_entity_voiceovers_model = ( + voiceover_services.create_entity_voiceovers_model( + required_entity_voiceovers_for_update + ) + ) + + return ( + updated_entity_voiceovers_model, + logs_during_voiceover_generation, + ) + + def run(self) -> beam.PCollection[job_run_result.JobRunResult]: + """Returns a PCollection of job run results for EntityVoiceoversModels + that were updated after voiceover synthesis. + + Returns: + beam.PCollection[job_run_result.JobRunResult]. A PCollection + containing job run results with the IDs of the + EntityVoiceoversModels that were updated or created. + """ + exploration_models = ( + self.pipeline + | 'Get exploration models' + >> ndb_io.GetModels(exp_models.ExplorationModel.get_all()) + | 'Filter out curated explorations' + >> beam.Filter( + lambda model: self.is_exploration_curated( + exploration_id=model.id + ) + ) + ) + + entity_translation_models = ( + self.pipeline + | 'Get all entity translation models' + >> ndb_io.GetModels( + translation_models.EntityTranslationsModel.get_all() + ) + | 'Filter out entity translations for curated explorations' + >> beam.Filter( + lambda model: self.is_exploration_curated( + exploration_id=model.entity_id + ) + ) + ) + + entity_voiceovers_models = ( + self.pipeline + | 'Get all entity voiceover models' + >> ndb_io.GetModels( + voiceover_models.EntityVoiceoversModel.get_all() + ) + | 'Filter out entity voiceovers for curated explorations' + >> beam.Filter( + lambda model: self.is_exploration_curated( + exploration_id=model.entity_id + ) + ) + ) + + exploration_id_to_exploration = ( + exploration_models + | 'Map exploration ID to exploration model' + >> beam.Map(lambda model: (model.id, model)) + ) + + entity_id_to_translation_models = ( + entity_translation_models + | 'Map entity ID to translation model' + >> beam.Map(lambda model: (model.entity_id, model)) + ) + + entity_id_to_voiceover_models = ( + entity_voiceovers_models + | 'Map entity ID to voiceover model' + >> beam.Map(lambda model: (model.entity_id, model)) + ) + + combined_models = { + 'exploration': exploration_id_to_exploration, + 'translations': entity_id_to_translation_models, + 'voiceovers': entity_id_to_voiceover_models, + } | 'Join all by entity ID' >> beam.CoGroupByKey() + + voiceover_policy_model = ( + self.pipeline + | 'Get all voiceover autogeneration policy models' + >> ndb_io.GetModels( + voiceover_models.VoiceoverAutogenerationPolicyModel.get_all() + ) + ) + + custom_options = self.pipeline.options.view_as(job_options.JobOptions) + oppia_project_id = custom_options.oppia_project_id + language_accent_code = custom_options.language_accent_code + + voiceovers_and_status = ( + combined_models + | 'Generate voiceovers for each exploration' + >> beam.ParDo( + GenerateVoiceoversFn( + oppia_project_id=oppia_project_id, + language_accent_code=language_accent_code, + ), + beam.pvalue.AsSingleton(voiceover_policy_model), + ).with_outputs('status', main='voiceovers') + ) + + entity_voiceovers_models = voiceovers_and_status.voiceovers + status_strings = voiceovers_and_status.status + + if self.DATASTORE_UPDATES_ALLOWED: + unused_put_results = ( + entity_voiceovers_models + | 'Filter out None results' + >> beam.Filter(lambda model: model is not None) + | 'Put models into datastore' >> ndb_io.PutModels() + ) + + return status_strings | 'Format results' >> beam.Map( + job_run_result.JobRunResult.as_stdout + ) diff --git a/core/jobs/batch_jobs/synthesize_voiceover_by_language_accent_jobs_test.py b/core/jobs/batch_jobs/synthesize_voiceover_by_language_accent_jobs_test.py new file mode 100644 index 0000000000000..46e6ef601c3db --- /dev/null +++ b/core/jobs/batch_jobs/synthesize_voiceover_by_language_accent_jobs_test.py @@ -0,0 +1,593 @@ +# coding: utf-8 +# +# Copyright 2026 The Oppia Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS-IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for jobs.batch_jobs.synthesize_voiceover_by_language_accent_jobs.""" + +from __future__ import annotations + +from core import constants, feconf +from core.domain import ( + exp_domain, + exp_services, + state_domain, + story_domain, + story_services, + topic_domain, + topic_services, + voiceover_regeneration_services, +) +from core.jobs import job_options, job_test_utils +from core.jobs.batch_jobs import synthesize_voiceover_by_language_accent_jobs +from core.jobs.types import job_run_result +from core.platform import models +from core.tests import test_utils + +from typing import Dict, List, Optional, Type, Union + +MYPY = False +if MYPY: + from mypy_imports import translation_models, voiceover_models + +(translation_models, voiceover_models) = models.Registry.import_models( + [models.Names.TRANSLATION, models.Names.VOICEOVER] +) + + +class VoiceoverSynthesisByAccentBaseClass( + job_test_utils.JobTestBase, test_utils.GenericTestBase +): + """Base class for voiceover synthesis by accent job tests.""" + + EDITOR_EMAIL_1 = 'editor1@example.com' + EDITOR_EMAIL_2 = 'editor2@example.com' + EDITOR_USERNAME_1 = 'editor1' + EDITOR_USERNAME_2 = 'editor2' + + CURATED_EXPLORATION_ID_1 = 'exploration_id_1' + CURATED_EXPLORATION_ID_2 = 'exploration_id_2' + NON_CURATED_EXPLORATION_ID = 'exploration_id_3' + + TOPIC_ID_1 = 'topic_id_1' + TOPIC_ID_2 = 'topic_id_2' + STORY_ID_1 = 'story_id_1' + STORY_ID_2 = 'story_id_2' + + def setUp(self) -> None: + super().setUp() + self.signup(self.EDITOR_EMAIL_1, self.EDITOR_USERNAME_1) + self.signup(self.EDITOR_EMAIL_2, self.EDITOR_USERNAME_2) + self.signup(self.CURRICULUM_ADMIN_EMAIL, self.CURRICULUM_ADMIN_USERNAME) + self.signup(self.OWNER_EMAIL, self.OWNER_USERNAME) + + self.set_curriculum_admins( + [ + self.EDITOR_USERNAME_1, + self.EDITOR_USERNAME_2, + self.CURRICULUM_ADMIN_USERNAME, + ] + ) + + self.admin_id = self.get_user_id_from_email(self.CURRICULUM_ADMIN_EMAIL) + self.owner_id = self.get_user_id_from_email(self.OWNER_EMAIL) + + self.voiceover_dict_1: state_domain.VoiceoverDict = { + 'filename': 'filename1.mp3', + 'file_size_bytes': 3000, + 'needs_update': False, + 'duration_secs': 42.43, + } + self.voiceover_dict_2: state_domain.VoiceoverDict = { + 'filename': 'filename2.mp3', + 'file_size_bytes': 3000, + 'needs_update': False, + 'duration_secs': 40, + } + + def _create_data_for_testing(self) -> None: + """This method creates three explorations — two curated and one + non-curated. It adds Hindi translations to the first curated + exploration and Portuguese translations to the second. Additionally, + it adds voiceovers in Hindi, Portuguese, and English to selected + content within the curated explorations. + """ + + # Creating and publishing two topics. + topic_1 = topic_domain.Topic.create_default_topic( + self.TOPIC_ID_1, 'topic1', 'abbrev', 'description', 'fragm' + ) + topic_1.thumbnail_filename = 'thumbnail.svg' + topic_1.thumbnail_bg_color = '#C6DCDA' + topic_1.subtopics = [ + topic_domain.Subtopic( + 1, + 'Title', + ['skill_id_1'], + 'image.svg', + constants.constants.ALLOWED_THUMBNAIL_BG_COLORS['subtopic'][0], + 21131, + 'dummy-subtopic-url', + ) + ] + topic_1.next_subtopic_id = 2 + topic_1.skill_ids_for_diagnostic_test = ['skill_id_1'] + + topic_services.save_new_topic(self.owner_id, topic_1) + topic_services.publish_topic(self.TOPIC_ID_1, self.admin_id) + + story_1 = story_domain.Story.create_default_story( + self.STORY_ID_1, + 'A story', + 'Description', + self.TOPIC_ID_1, + 'story-two', + ) + story_services.save_new_story(self.owner_id, story_1) + topic_services.add_canonical_story( + self.owner_id, self.TOPIC_ID_1, self.STORY_ID_1 + ) + + topic_services.publish_story( + self.TOPIC_ID_1, self.STORY_ID_1, self.admin_id + ) + + topic_2 = topic_domain.Topic.create_default_topic( + self.TOPIC_ID_2, 'topic2', 'abbrev-top', 'description', 'fragmem' + ) + topic_2.thumbnail_filename = 'thumbnail.svg' + topic_2.thumbnail_bg_color = '#C6DCDA' + topic_2.subtopics = [ + topic_domain.Subtopic( + 1, + 'Title subtopic', + ['skill_id_1'], + 'image.svg', + constants.constants.ALLOWED_THUMBNAIL_BG_COLORS['subtopic'][0], + 21131, + 'dummy-subtopic-url-sub', + ) + ] + topic_2.next_subtopic_id = 2 + topic_2.skill_ids_for_diagnostic_test = ['skill_id_1'] + + topic_services.save_new_topic(self.owner_id, topic_2) + topic_services.publish_topic(self.TOPIC_ID_2, self.admin_id) + + story_2 = story_domain.Story.create_default_story( + self.STORY_ID_2, + 'The second story', + 'Description second', + self.TOPIC_ID_2, + 'story-three', + ) + story_services.save_new_story(self.owner_id, story_2) + topic_services.add_canonical_story( + self.owner_id, self.TOPIC_ID_2, self.STORY_ID_2 + ) + + topic_services.publish_story( + self.TOPIC_ID_2, self.STORY_ID_2, self.admin_id + ) + + # Creating 2 curated explorations. + exploration_1 = self.save_new_valid_exploration( + self.CURATED_EXPLORATION_ID_1, + self.owner_id, + title='title1', + category=constants.constants.ALL_CATEGORIES[0], + end_state_name='End State', + ) + + self.publish_exploration(self.owner_id, exploration_1.id) + + exp_services.update_exploration( + self.owner_id, + self.CURATED_EXPLORATION_ID_1, + [ + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'property_name': exp_domain.STATE_PROPERTY_CONTENT, + 'state_name': 'Introduction', + 'new_value': { + 'content_id': 'content_0', + 'html': '

This is the first card of first exploration.

', + }, + } + ), + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'property_name': exp_domain.STATE_PROPERTY_CONTENT, + 'state_name': 'End State', + 'new_value': { + 'content_id': 'content_3', + 'html': '

This is the last card of first exploration.

', + }, + } + ), + ], + 'Changes content.', + ) + + story_services.update_story( + self.owner_id, + self.STORY_ID_1, + [ + story_domain.StoryChange( + { + 'cmd': 'add_story_node', + 'node_id': 'node_1', + 'title': 'Node1', + } + ), + story_domain.StoryChange( + { + 'cmd': 'update_story_node_property', + 'property_name': 'exploration_id', + 'node_id': 'node_1', + 'old_value': None, + 'new_value': self.CURATED_EXPLORATION_ID_1, + } + ), + ], + 'Changes.', + ) + + exploration_2 = self.save_new_valid_exploration( + self.CURATED_EXPLORATION_ID_2, + self.owner_id, + title='title2', + category=constants.constants.ALL_CATEGORIES[0], + end_state_name='End State', + ) + self.publish_exploration(self.owner_id, exploration_2.id) + + exp_services.update_exploration( + self.owner_id, + self.CURATED_EXPLORATION_ID_2, + [ + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'property_name': exp_domain.STATE_PROPERTY_CONTENT, + 'state_name': 'Introduction', + 'new_value': { + 'content_id': 'content_0', + 'html': '

This is the first card of second exploration.

', + }, + } + ), + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'property_name': exp_domain.STATE_PROPERTY_CONTENT, + 'state_name': 'End State', + 'new_value': { + 'content_id': 'content_3', + 'html': '

This is the last card of second exploration.

', + }, + } + ), + ], + 'Changes content.', + ) + + story_services.update_story( + self.owner_id, + self.STORY_ID_2, + [ + story_domain.StoryChange( + { + 'cmd': 'add_story_node', + 'node_id': 'node_1', + 'title': 'Node1', + } + ), + story_domain.StoryChange( + { + 'cmd': 'update_story_node_property', + 'property_name': 'exploration_id', + 'node_id': 'node_1', + 'old_value': None, + 'new_value': self.CURATED_EXPLORATION_ID_2, + } + ), + ], + 'Changes.', + ) + + # Create a non-curated exploration. + exploration_3 = self.save_new_valid_exploration( + self.NON_CURATED_EXPLORATION_ID, + self.owner_id, + title='title3', + category=constants.constants.ALL_CATEGORIES[0], + end_state_name='End State', + ) + self.publish_exploration(self.owner_id, exploration_3.id) + + # Adding Hindi and Portuguese translations to the first and second. + translation_models.EntityTranslationsModel.create_new( + 'exploration', + self.CURATED_EXPLORATION_ID_1, + 2, + 'hi', + { + 'content_0': { + 'content_value': '

यह प्रथम अन्वेषण का पहला कार्ड है.

', + 'content_format': 'html', + 'needs_update': False, + }, + 'content_3': { + 'content_value': '

यह प्रथम अन्वेषण का अंतिम कार्ड है.

', + 'content_format': 'html', + 'needs_update': True, + }, + }, + ).put() + + translation_models.EntityTranslationsModel.create_new( + 'exploration', + self.CURATED_EXPLORATION_ID_2, + 2, + 'pt', + { + 'content_0': { + 'content_value': '

Esta é a primeira carta da segunda exploração.

', + 'content_format': 'html', + 'needs_update': False, + }, + 'content_3': { + 'content_value': '

Esta é a segunda carta da segunda exploração.

', + 'content_format': 'html', + 'needs_update': True, + }, + }, + ).put() + + voiceover_models.EntityVoiceoversModel.create_new( + feconf.ENTITY_TYPE_EXPLORATION, + self.CURATED_EXPLORATION_ID_1, + 2, + 'hi-IN', + { + 'content_0': { + 'manual': None, + 'auto': self.voiceover_dict_2, + } + }, + {}, + ).put() + + voiceover_models.EntityVoiceoversModel.create_new( + feconf.ENTITY_TYPE_EXPLORATION, + self.CURATED_EXPLORATION_ID_2, + 2, + 'en-US', + { + 'content_0': { + 'manual': self.voiceover_dict_1, + 'auto': self.voiceover_dict_2, + } + }, + {}, + ).put() + + voiceover_models.EntityVoiceoversModel.create_new( + feconf.ENTITY_TYPE_EXPLORATION, + self.CURATED_EXPLORATION_ID_2, + 2, + 'pt-BR', + { + 'content_0': { + 'manual': None, + 'auto': self.voiceover_dict_2, + } + }, + {}, + ).put() + + voiceover_autogeneration_policy_model = ( + voiceover_models.VoiceoverAutogenerationPolicyModel( + id=voiceover_models.VOICEOVER_AUTOGENERATION_POLICY_ID + ) + ) + voiceover_autogeneration_policy_model.language_codes_mapping = { + 'en': {'en-US': True, 'en-NG': False}, + 'hi': {'hi-IN': True}, + 'pt': {'pt-BR': True}, + 'ar': {'ar-AE': True}, + } + ( + voiceover_autogeneration_policy_model.autogenerated_voiceovers_are_enabled + ) = True + voiceover_autogeneration_policy_model.update_timestamps() + voiceover_autogeneration_policy_model.put() + + def _set_language_accent_code( + self, language_accent_code: Optional[str] + ) -> None: + """Sets the language accent code for the job. + + Args: + language_accent_code: str. The language accent code to set for the job. + """ + custom_options = self.pipeline.options.view_as(job_options.JobOptions) + custom_options.language_accent_code = language_accent_code + + +class VoiceoverSynthesisByAccentJobRunTests( + VoiceoverSynthesisByAccentBaseClass +): + """Tests for VoiceoverSynthesisByAccentJob.""" + + JOB_CLASS: Type[ + synthesize_voiceover_by_language_accent_jobs.VoiceoverSynthesisByAccentJob + ] = ( + synthesize_voiceover_by_language_accent_jobs.VoiceoverSynthesisByAccentJob + ) + + def test_empty_storage(self) -> None: + self._set_language_accent_code('en-US') + self.assert_job_output_is_empty() + + def test_should_regenerate_voiceover_successfully(self) -> None: + self._create_data_for_testing() + self._set_language_accent_code('en-US') + + expected_output_1 = ( + 'Exploration ID: exploration_id_1.\n' + 'EntityVoiceovers ID: exploration-exploration_id_1-2-en-US.\n' + 'Total content IDs processed: 4. Total characters processed: 101.\n' + ) + + expected_output_2 = ( + 'Exploration ID: exploration_id_2.\n' + 'EntityVoiceovers ID: exploration-exploration_id_2-2-en-US.\n' + 'Total content IDs processed: 4. Total characters processed: 103.\n' + ) + + expected_output = [ + job_run_result.JobRunResult(stdout=expected_output_1, stderr=''), + job_run_result.JobRunResult(stdout=expected_output_2, stderr=''), + ] + + self.assert_job_output_is(expected_output) + + def test_should_not_generate_voiceover_for_which_contents_are_not_available( + self, + ) -> None: + self._create_data_for_testing() + self._set_language_accent_code('ar-AE') + + expected_output_1 = ( + 'Exploration ID: exploration_id_1.\n' + 'No content found for language code: ar.' + ) + + expected_output_2 = ( + 'Exploration ID: exploration_id_2.\n' + 'No content found for language code: ar.' + ) + + expected_output = [ + job_run_result.JobRunResult(stdout=expected_output_1, stderr=''), + job_run_result.JobRunResult(stdout=expected_output_2, stderr=''), + ] + + self.assert_job_output_is(expected_output) + + def test_should_not_generate_voiceover_when_language_accent_code_is_none( + self, + ) -> None: + self._create_data_for_testing() + self._set_language_accent_code(None) + + expected_output_1 = 'Not generating voiceovers for exploration ID: exploration_id_1 since language accent code is None.' + + expected_output_2 = 'Not generating voiceovers for exploration ID: exploration_id_2 since language accent code is None.' + + expected_output = [ + job_run_result.JobRunResult(stdout=expected_output_1, stderr=''), + job_run_result.JobRunResult(stdout=expected_output_2, stderr=''), + ] + + self.assert_job_output_is(expected_output) + + def test_should_handle_failures_during_voiceover_regeneration(self) -> None: + def mock_synthesize_voiceover_for_html_string( + _exploration_id: str, + _content_html: str, + _language_accent_code: str, + _voiceover_filename: str, + _oppia_project_id: Optional[str], + ) -> List[Dict[str, Union[str, float]]]: + raise Exception('Failed to generate voiceovers.') + + self._create_data_for_testing() + self._set_language_accent_code('en-US') + + expected_output_1 = ( + 'Exploration ID: exploration_id_1.\n' + 'EntityVoiceovers ID: exploration-exploration_id_1-2-en-US.\n' + 'Content IDs failed: [content_0, default_outcome_1, ca_placeholder_2, content_3]. Error message: Failed to generate voiceovers.\n' + 'Total content IDs processed: 4. Total characters processed: 0.\n' + ) + expected_output_2 = ( + 'Exploration ID: exploration_id_2.\n' + 'EntityVoiceovers ID: exploration-exploration_id_2-2-en-US.\n' + 'Content IDs failed: [content_0, default_outcome_1, ca_placeholder_2, content_3]. Error message: Failed to generate voiceovers.\n' + 'Total content IDs processed: 4. Total characters processed: 0.\n' + ) + + with self.swap( + voiceover_regeneration_services, + 'synthesize_voiceover_for_html_string', + mock_synthesize_voiceover_for_html_string, + ): + expected_output = [ + job_run_result.JobRunResult( + stdout=expected_output_1, stderr='' + ), + job_run_result.JobRunResult( + stdout=expected_output_2, stderr='' + ), + ] + + self.assert_job_output_is(expected_output) + + def test_should_handle_empty_strings_during_voiceover_regeneration( + self, + ) -> None: + def mock_synthesize_voiceover_for_html_string( + _exploration_id: str, + _content_html: str, + _language_accent_code: str, + _voiceover_filename: str, + _oppia_project_id: Optional[str], + ) -> List[Dict[str, Union[str, float]]]: + return [] + + self._create_data_for_testing() + self._set_language_accent_code('en-US') + + expected_output_1 = ( + 'Exploration ID: exploration_id_1.\n' + 'EntityVoiceovers ID: exploration-exploration_id_1-2-en-US.\n' + 'Total content IDs processed: 4. Total characters processed: 0.\n' + ) + + expected_output_2 = ( + 'Exploration ID: exploration_id_2.\n' + 'EntityVoiceovers ID: exploration-exploration_id_2-2-en-US.\n' + 'Total content IDs processed: 4. Total characters processed: 0.\n' + ) + + with self.swap( + voiceover_regeneration_services, + 'synthesize_voiceover_for_html_string', + mock_synthesize_voiceover_for_html_string, + ): + expected_output = [ + job_run_result.JobRunResult( + stdout=expected_output_1, stderr='' + ), + job_run_result.JobRunResult( + stdout=expected_output_2, stderr='' + ), + ] + + self.assert_job_output_is(expected_output) diff --git a/core/jobs/batch_jobs/translation_audit_jobs.py b/core/jobs/batch_jobs/translation_audit_jobs.py new file mode 100644 index 0000000000000..34cf7b64fa516 --- /dev/null +++ b/core/jobs/batch_jobs/translation_audit_jobs.py @@ -0,0 +1,174 @@ +# coding: utf-8 +# +# Copyright 2026 The Oppia Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS-IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Beam jobs for auditing translation counts.""" + +from __future__ import annotations + +from core.jobs import base_jobs +from core.jobs.io import ndb_io +from core.jobs.types import job_run_result +from core.platform import models + +import apache_beam as beam +from typing import Dict, Iterable, Tuple + +MYPY = False +if MYPY: # pragma: no cover + from mypy_imports import opportunity_models, translation_models + +(opportunity_models, translation_models) = models.Registry.import_models( + [ + models.Names.OPPORTUNITY, + models.Names.TRANSLATION, + ] +) + + +class ValidateExplorationOpportunityCountsJob(base_jobs.JobBase): + """Job that validates translation_counts in + ExplorationOpportunitySummaryModel. + + This job computes the true translation counts by looking at + EntityTranslationsModel and compares it to the translation_counts + recorded in ExplorationOpportunitySummaryModel. It returns SUCCESS + if all counts match, and logs the mismatches otherwise. + """ + + def _get_translation_counts( + self, translation_model: translation_models.EntityTranslationsModel + ) -> Tuple[str, Tuple[str, int]]: + """Extracts translation counts from an EntityTranslationsModel. + + Args: + translation_model: EntityTranslationsModel. The model + to extract counts from. + + Returns: + tuple(str, tuple(str, int)). A tuple of + (exploration_id, (language_code, translation_count)). + """ + translation_count = len(translation_model.translations) + return ( + translation_model.entity_id, + (translation_model.language_code, translation_count), + ) + + def _validate_counts( + self, + exploration_id: str, + opportunity_summary_models_list: Iterable[ + opportunity_models.ExplorationOpportunitySummaryModel + ], + translation_counts_list: Iterable[Tuple[str, int]], + ) -> Iterable[job_run_result.JobRunResult]: + """Validates the translation counts for a given exploration. + + Args: + exploration_id: str. The exploration ID. + opportunity_summary_models_list: + list(ExplorationOpportunitySummaryModel). The list + of opportunity summary models for the exploration. + translation_counts_list: list(tuple(str, int)). True + counts from EntityTranslationsModel. + + Yields: + JobRunResult. Results detailing whether counts match or + describing the mismatches. + """ + summary_models = list(opportunity_summary_models_list) + actual_translations = list(translation_counts_list) + + if not summary_models: + return + + summary_model = summary_models[0] + stored_translation_counts = summary_model.translation_counts + + actual_translation_counts_dict: Dict[str, int] = {} + for language_code, count in actual_translations: + actual_translation_counts_dict[language_code] = count + + mismatch_found = False + + for lang_code, stored_count in stored_translation_counts.items(): + actual_count = actual_translation_counts_dict.get(lang_code, 0) + if stored_count != actual_count: + mismatch_found = True + yield job_run_result.JobRunResult.as_stderr( + 'Mismatch for exploration %s in %s: ' + 'stored=%s, actual=%s' + % (exploration_id, lang_code, stored_count, actual_count) + ) + + for lang_code, actual_count in actual_translation_counts_dict.items(): + if lang_code not in stored_translation_counts and actual_count > 0: + mismatch_found = True + yield job_run_result.JobRunResult.as_stderr( + 'Mismatch for exploration %s in %s: ' + 'stored=0 (missing), actual=%s' + % (exploration_id, lang_code, actual_count) + ) + + if not mismatch_found: + yield job_run_result.JobRunResult.as_stdout( + 'SUCCESS - Exploration %s counts are valid.' % exploration_id + ) + + def run(self) -> beam.PCollection[job_run_result.JobRunResult]: + """Returns a PCollection of results from the translation + count validation. + + Returns: + PCollection. A PCollection of JobRunResult objects. + """ + opportunity_summaries = ( + self.pipeline + | 'Get all ExplorationOpportunitySummaryModels' + >> ndb_io.GetModels( + opportunity_models.ExplorationOpportunitySummaryModel.get_all() + ) + | 'Key Opportunity by exploration_id' + >> beam.WithKeys( # pylint: disable=no-value-for-parameter + lambda model: model.id + ) + ) + + translation_counts = ( + self.pipeline + | 'Get all Exploration EntityTranslationsModels' + >> ndb_io.GetModels( + translation_models.EntityTranslationsModel.query( + translation_models.EntityTranslationsModel.entity_type + == 'exploration' + ) + ) + | 'Extract translation counts' + >> beam.Map(self._get_translation_counts) + ) + + grouped_data = { + 'opportunity_summary': opportunity_summaries, + 'translation_counts': translation_counts, + } | 'Group by exploration_id' >> beam.CoGroupByKey() + + return grouped_data | 'Process and Validate Counts' >> beam.FlatMap( + lambda kv: self._validate_counts( + exploration_id=kv[0], + opportunity_summary_models_list=(kv[1]['opportunity_summary']), + translation_counts_list=kv[1]['translation_counts'], + ) + ) diff --git a/core/jobs/batch_jobs/translation_audit_jobs_test.py b/core/jobs/batch_jobs/translation_audit_jobs_test.py new file mode 100644 index 0000000000000..7d591ca4e517d --- /dev/null +++ b/core/jobs/batch_jobs/translation_audit_jobs_test.py @@ -0,0 +1,200 @@ +# coding: utf-8 +# +# Copyright 2026 The Oppia Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS-IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for jobs.batch_jobs.translation_audit_jobs.""" + +from __future__ import annotations + +from core.jobs import job_test_utils +from core.jobs.batch_jobs import translation_audit_jobs +from core.jobs.types import job_run_result +from core.platform import models + +(opportunity_models, translation_models) = models.Registry.import_models( + [ + models.Names.OPPORTUNITY, + models.Names.TRANSLATION, + ] +) + + +class ValidateExplorationOpportunityCountsJobTests(job_test_utils.JobTestBase): + + JOB_CLASS = translation_audit_jobs.ValidateExplorationOpportunityCountsJob + + def test_empty_storage(self) -> None: + self.assert_job_output_is_empty() + + def test_matches_exactly(self) -> None: + exp_id = 'exp_1' + summary_model = opportunity_models.ExplorationOpportunitySummaryModel( + id=exp_id, + topic_id='topic1', + topic_name='Topic 1', + story_id='story_1', + story_title='Story 1', + chapter_title='Chapter 1', + content_count=10, + incomplete_translation_language_codes=['hi'], + translation_counts={'hi': 5, 'es': 10}, + language_codes_needing_voice_artists=[], + language_codes_with_assigned_voice_artists=[], + ) + summary_model.update_timestamps() + summary_model.put() + + translation_1 = translation_models.EntityTranslationsModel( + id='exploration-exp_1-1-hi', + entity_id=exp_id, + entity_type='exploration', + entity_version=1, + language_code='hi', + translations={'content_%d' % i: {} for i in range(5)}, + ) + translation_1.update_timestamps() + translation_1.put() + + translation_2 = translation_models.EntityTranslationsModel( + id='exploration-exp_1-1-es', + entity_id=exp_id, + entity_type='exploration', + entity_version=1, + language_code='es', + translations={'content_%d' % i: {} for i in range(10)}, + ) + translation_2.update_timestamps() + translation_2.put() + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stdout( + 'SUCCESS - Exploration %s counts are valid.' % exp_id + ) + ] + ) + + def test_mismatch(self) -> None: + exp_id = 'exp_2' + summary_model_2 = opportunity_models.ExplorationOpportunitySummaryModel( + id=exp_id, + topic_id='topic1', + topic_name='Topic 1', + story_id='story_1', + story_title='Story 1', + chapter_title='Chapter 1', + content_count=10, + incomplete_translation_language_codes=['hi'], + translation_counts={'hi': 6}, + language_codes_needing_voice_artists=[], + language_codes_with_assigned_voice_artists=[], + ) + summary_model_2.update_timestamps() + summary_model_2.put() + + translation_1_mock = translation_models.EntityTranslationsModel( + id='exploration-exp_2-1-hi', + entity_id=exp_id, + entity_type='exploration', + entity_version=1, + language_code='hi', + translations={'content_%d' % i: {} for i in range(4)}, + ) + translation_1_mock.update_timestamps() + translation_1_mock.put() + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stderr( + 'Mismatch for exploration %s in hi: ' + 'stored=6, actual=4' % exp_id + ) + ] + ) + + def test_no_opportunity_summary_for_translation(self) -> None: + """Test that translations without a corresponding opportunity summary + produce no output. + """ + exp_id = 'exp_no_summary' + translation_model = translation_models.EntityTranslationsModel( + id='exploration-%s-1-hi' % exp_id, + entity_id=exp_id, + entity_type='exploration', + entity_version=1, + language_code='hi', + translations={'content_0': {}, 'content_1': {}}, + ) + translation_model.update_timestamps() + translation_model.put() + + self.assert_job_output_is_empty() + + def test_mismatch_language_in_translations_but_not_in_summary( + self, + ) -> None: + """Test that a language present in EntityTranslationsModel but missing + from the opportunity summary's translation_counts is reported as a + mismatch. + """ + exp_id = 'exp_extra_lang' + summary_model = opportunity_models.ExplorationOpportunitySummaryModel( + id=exp_id, + topic_id='topic1', + topic_name='Topic 1', + story_id='story_1', + story_title='Story 1', + chapter_title='Chapter 1', + content_count=10, + incomplete_translation_language_codes=['hi'], + translation_counts={'hi': 3}, + language_codes_needing_voice_artists=[], + language_codes_with_assigned_voice_artists=[], + ) + summary_model.update_timestamps() + summary_model.put() + + translation_hi = translation_models.EntityTranslationsModel( + id='exploration-%s-1-hi' % exp_id, + entity_id=exp_id, + entity_type='exploration', + entity_version=1, + language_code='hi', + translations={'content_%d' % i: {} for i in range(3)}, + ) + translation_hi.update_timestamps() + translation_hi.put() + + # 'es' translations exist but are not in the summary's + # translation_counts. + translation_es = translation_models.EntityTranslationsModel( + id='exploration-%s-1-es' % exp_id, + entity_id=exp_id, + entity_type='exploration', + entity_version=1, + language_code='es', + translations={'content_0': {}, 'content_1': {}}, + ) + translation_es.update_timestamps() + translation_es.put() + + self.assert_job_output_is( + [ + job_run_result.JobRunResult.as_stderr( + 'Mismatch for exploration %s in es: ' + 'stored=0 (missing), actual=2' % exp_id + ) + ] + ) diff --git a/core/jobs/batch_jobs/voiceover_synthesis_jobs.py b/core/jobs/batch_jobs/voiceover_synthesis_jobs.py new file mode 100644 index 0000000000000..0ddf4126a7041 --- /dev/null +++ b/core/jobs/batch_jobs/voiceover_synthesis_jobs.py @@ -0,0 +1,597 @@ +# coding: utf-8 +# +# Copyright 2025 The Oppia Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS-IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Jobs used for regenerating voiceovers for all the curated explorations.""" + +from __future__ import annotations + +import collections +import logging +import traceback + +from core import feconf +from core.domain import ( + exp_fetchers, + opportunity_services, + translation_fetchers, + voiceover_domain, + voiceover_regeneration_services, + voiceover_services, +) +from core.jobs import base_jobs, job_options +from core.jobs.io import ndb_io +from core.jobs.types import job_run_result +from core.platform import models + +import apache_beam as beam +from typing import Dict, Iterator, List, Optional, Sequence, Tuple, Union, cast + +MYPY = False +if MYPY: # pragma: no cover + from mypy_imports import ( + datastore_services, + exp_models, + translation_models, + voiceover_models, + ) + +(exp_models, translation_models, voiceover_models) = ( + models.Registry.import_models( + [ + models.Names.EXPLORATION, + models.Names.TRANSLATION, + models.Names.VOICEOVER, + ] + ) +) +datastore_services = models.Registry.import_datastore_services() + + +# TODO(#15613): Here we use MyPy ignore because the incomplete typing of +# apache_beam library and absences of stubs in Typeshed, forces MyPy to +# assume that PTransform class is of type Any. Thus to avoid MyPy's error +# (Class cannot subclass 'PTransform' (has type 'Any')), we added an +# ignore here. +class GenerateVoiceoversFn(beam.DoFn): # type: ignore[misc] + """A DoFn that generates voiceovers for a given exploration.""" + + def __init__(self, oppia_project_id: Optional[str] = None) -> None: + super().__init__() + logging.info( + 'Voiceover synthesis log: Initializing GenerateVoiceoversFn.' + ) + + self.oppia_project_id = oppia_project_id + logging.info( + 'Voiceover synthesis log: Setting oppia project ID from args: %s', + self.oppia_project_id, + ) + + def process( + self, + combined_models: Tuple[ + str, + Dict[ + str, + Sequence[exp_models.ExplorationModel] + | Sequence[voiceover_models.EntityVoiceoversModel] + | Sequence[translation_models.EntityTranslationsModel], + ], + ], + autogeneration_policy_model: ( + voiceover_models.VoiceoverAutogenerationPolicyModel + ), + ) -> Iterator[ + Union[ + voiceover_models.EntityVoiceoversModel, + beam.pvalue.TaggedOutput[str], + ] + ]: + """Method to process each element in the PCollection. + + Args: + combined_models: tuple(str, dict). A tuple where the first element + is the exploration ID and the second element is a dictionary + with keys 'exploration', 'translations' and 'voiceovers' mapping + to a list of corresponding models. + autogeneration_policy_model: VoiceoverAutogenerationPolicyModel. + The voiceover autogeneration policy model. + + Yields: + EntityVoiceoversModel. The generated entity voiceover models. + str. The status string for the voiceover generation process. + """ + entity_id = combined_models[0] + + logging.info( + 'Voiceover synthesis log: Generating voiceovers for exploration ID: %s', + entity_id, + ) + # Here we use cast because we are narrowing down the type of + # exploration field in combined_models to Exploration model. + exploration_model = cast( + exp_models.ExplorationModel, combined_models[1]['exploration'][0] + ) + # Here we use cast because we are narrowing down the type of + # translations field in combined_models to Sequence of + # EntityTranslationsModel. + entity_translation_models = cast( + Sequence[translation_models.EntityTranslationsModel], + combined_models[1]['translations'], + ) + # Here we use cast because we are narrowing down the type of + # voiceovers field in combined_models to Sequence of EntityVoiceoversModel. + entity_voiceover_models = cast( + Sequence[voiceover_models.EntityVoiceoversModel], + combined_models[1]['voiceovers'], + ) + + entity_voiceovers_list, status_string = ( + VoiceoverSynthesisJob.generate_voiceovers_for_exploration( + exploration_model=exploration_model, + entity_translation_models=entity_translation_models, + entity_voiceover_models=entity_voiceover_models, + voiceover_policy_model=autogeneration_policy_model, + oppia_project_id=self.oppia_project_id, + ) + ) + + logging.info( + 'Voiceover synthesis log: Completed generating voiceovers for exploration ID: %s', + entity_id, + ) + + # Yield entity voiceovers to main output. + for entity_voiceovers in entity_voiceovers_list: + yield entity_voiceovers + + # Yield status string to tagged side output. + yield beam.pvalue.TaggedOutput('status', status_string) + + +class VoiceoverSynthesisJob(base_jobs.JobBase): + """A one-off job to generate voiceovers for all curated explorations in + English and other supported translated languages. + """ + + DATASTORE_UPDATES_ALLOWED = True + + @staticmethod + def is_exploration_curated(exploration_id: str) -> Optional[bool]: + """Checks whether the provided exploration ID belongs to a curated + exploration or not. + + Args: + exploration_id: str. The given exploration ID. + + Returns: + bool. A boolean value indicating if the exploration is curated + or not. + """ + try: + with datastore_services.get_ndb_context(): + return opportunity_services.is_exploration_available_for_contribution( + exploration_id + ) + except Exception: + logging.exception( + 'Not able to check whether exploration is curated or not' + ' for exploration ID %s.' % exploration_id + ) + return False + + @classmethod + def generate_voiceovers_for_exploration( + cls, + exploration_model: exp_models.ExplorationModel, + entity_translation_models: Sequence[ + translation_models.EntityTranslationsModel + ], + entity_voiceover_models: Sequence[ + voiceover_models.EntityVoiceoversModel + ], + voiceover_policy_model: voiceover_models.VoiceoverAutogenerationPolicyModel, + oppia_project_id: Optional[str] = None, + ) -> Tuple[Sequence[voiceover_models.EntityVoiceoversModel], str]: + """Generates voiceovers in English and all translated languages, + covering every supported accent for the given exploration. + + Args: + exploration_model: ExplorationModel. The exploration model for which + to generate voiceovers. + entity_translation_models: list(EntityTranslationsModel). The + existing entity translation models related to the exploration. + entity_voiceover_models: list(EntityVoiceoversModel). The existing + entity voiceover models related to the exploration. + voiceover_policy_model: VoiceoverAutogenerationPolicyModel. The + voiceover autogeneration policy model. + oppia_project_id: Optional[str]. The Google Cloud Project ID. + Explicitly required when running on Beam Dataflow, as workers + cannot retrieve the ID from environment variables. + + Returns: + Iterable[EntityVoiceoversModel]. An iterable of + EntityVoiceoversModels that were updated or created. + """ + logs_during_voiceover_generation = '' + + entity_translations_list = [] + entity_voiceovers_list = [] + + with datastore_services.get_ndb_context(): + # Converting exploration model to domain object. + exploration = exp_fetchers.get_exploration_from_model( + exploration_model, False + ) + logging.info( + 'Voiceover synthesis log: Converted exploration model to exploration domain object.' + ) + + # Converting EntityTranslationsModels to domain objects. + for entity_translation_model in list(entity_translation_models): + entity_translations_list.append( + translation_fetchers.get_entity_translation_from_model( + entity_translation_model + ) + ) + logging.info( + 'Voiceover synthesis log: Converted entity translation models to ' + 'entity translation domain objects.' + ) + + # Converting EntityVoiceoversModels to domain objects. + for entity_voiceover_model in list(entity_voiceover_models): + if entity_voiceover_model.entity_version != exploration.version: + continue + entity_voiceovers_list.append( + voiceover_services.get_entity_voiceovers_from_model( + entity_voiceover_model + ) + ) + logging.info( + 'Voiceover synthesis log: Converted entity voiceover models to ' + 'entity voiceover domain objects.' + ) + + # Extracting language codes mapping from the autogeneration policy + # model. + language_codes_mapping = ( + voiceover_policy_model.language_codes_mapping + ) + + entity_type = feconf.ENTITY_TYPE_EXPLORATION + entity_id = exploration.id + entity_version = exploration.version + + # A dictionary that maps each entity voiceover ID to its corresponding + # EntityVoiceovers domain object. + entity_voiceovers_id_to_domain_object = {} + + for entity_voiceovers in entity_voiceovers_list: + entity_voiceovers_id = '%s-%s-%s-%s' % ( + entity_voiceovers.entity_type, + entity_voiceovers.entity_id, + entity_voiceovers.entity_version, + entity_voiceovers.language_accent_code, + ) + + entity_voiceovers_id_to_domain_object[entity_voiceovers_id] = ( + entity_voiceovers + ) + + # A dictionary mapping each language code to a list of accent codes + # that support autogenerated voiceovers. + autogeneratable_language_codes_mapping: Dict[str, List[str]] = {} + + for language_code, accent_mapping in language_codes_mapping.items(): + autogeneratable_language_codes_mapping[language_code] = [] + for accent_code, is_autogeneratable in accent_mapping.items(): + if is_autogeneratable: + autogeneratable_language_codes_mapping[ + language_code + ].append(accent_code) + + # A dictionary where each key is a language code, and each value is a + # content mapping dictionary. The content mapping dictionary contains + # content IDs as keys and their corresponding HTML content as values. + language_code_to_contents_mapping = {} + + language_code_to_contents_mapping.update( + voiceover_services.extract_english_voiceover_texts_from_exploration( + exploration + ) + ) + language_code_to_contents_mapping.update( + voiceover_services.extract_translated_voiceover_texts_from_entity_translations( + entity_translations_list + ) + ) + + # Get all language codes that need voiceover regeneration in this + # request. + language_codes = list(language_code_to_contents_mapping.keys()) + + for language_code in language_codes: + language_accent_codes = autogeneratable_language_codes_mapping.get( + language_code, [] + ) + + content_ids_to_content_values = ( + language_code_to_contents_mapping.get(language_code, {}) + ) + + for language_accent_code in language_accent_codes: + entity_voiceovers_id = '%s-%s-%s-%s' % ( + entity_type, + entity_id, + str(entity_version), + language_accent_code, + ) + + default_entity_voiceovers = ( + voiceover_domain.EntityVoiceovers.create_empty( + entity_id, + entity_type, + entity_version, + language_accent_code, + ) + ) + error_message_to_content_ids_dict = collections.defaultdict( + list + ) + + entity_voiceovers = entity_voiceovers_id_to_domain_object.get( + entity_voiceovers_id, default_entity_voiceovers + ) + + logging.info( + 'Voiceover synthesis log: Generating voiceovers for Entityvoiceover with ID: %s.', + entity_voiceovers_id, + ) + logs_during_voiceover_generation += ( + 'EntityVoiceovers ID: %s.\n' % entity_voiceovers_id + ) + + number_of_content_ids = len(content_ids_to_content_values) + number_of_characters = 0 + + logging.info( + 'Voiceover synthesis log: content_ids_to_content_values: %s.', + content_ids_to_content_values, + ) + logging.info( + 'Voiceover synthesis log: number_of_content_ids: %s.', + number_of_content_ids, + ) + + for ( + content_id, + content_html, + ) in content_ids_to_content_values.items(): + + try: + voiceover_filename = voiceover_regeneration_services.generate_new_voiceover_filename( + content_id, language_accent_code + ) + logging.info( + 'Voiceover synthesis log: Generated new voiceover filename: %s for content_id: %s, content_html: %s.' + % (voiceover_filename, content_id, content_html) + ) + + with datastore_services.get_ndb_context(): + sentence_tokens_with_durations = voiceover_regeneration_services.synthesize_voiceover_for_html_string( + entity_id, + content_html, + language_accent_code, + voiceover_filename, + oppia_project_id, + ) + + if not sentence_tokens_with_durations: + continue + + voiceover = voiceover_regeneration_services.fetch_voiceover_by_filename( + entity_id, voiceover_filename, oppia_project_id + ) + + number_of_characters += len(content_html) + + entity_voiceovers.add_voiceover( + content_id, feconf.VoiceoverType.AUTO, voiceover + ) + entity_voiceovers.add_automated_voiceovers_audio_offsets( + content_id, sentence_tokens_with_durations + ) + + logging.info( + 'Voiceover synthesis log: Generated voiceover for content_id: %s.', + content_id, + ) + except Exception as error: + error_message_to_content_ids_dict[str(error)].append( + content_id + ) + stack_trace = traceback.format_exc() + logging.error( + 'Voiceover synthesis log: Stack trace: %s', + stack_trace, + ) + logging.error( + 'Voiceover synthesis log: Error generating voiceover for exploration ID: %s, language_accent_code: %s, content_id: %s. Error: %s' + % ( + entity_id, + language_accent_code, + content_id, + str(error), + ) + ) + + for ( + error_message, + content_ids, + ) in error_message_to_content_ids_dict.items(): + comma_separated_content_ids = ', '.join(content_ids) + logs_during_voiceover_generation += ( + 'Content IDs failed: [%s]. Error message: %s\n' + % (comma_separated_content_ids, error_message) + ) + + entity_voiceovers.validate() + entity_voiceovers_id_to_domain_object[entity_voiceovers_id] = ( + entity_voiceovers + ) + + final_report_logs = ( + 'Total content IDs processed: %d. ' + 'Total characters processed: %d.\n' + % ( + number_of_content_ids, + number_of_characters, + ) + ) + logging.info('Voiceover synthesis log: %s.' % final_report_logs) + logs_during_voiceover_generation += final_report_logs + logging.info( + 'Voiceover synthesis log: Completed voiceover generation for entity ID: %s.' + % entity_voiceovers_id + ) + + # List of EntityVoiceoversModel instances to be stored in the datastore. + entity_voiceover_models_to_put = [] + for entity_voiceovers in entity_voiceovers_id_to_domain_object.values(): + with datastore_services.get_ndb_context(): + entity_voiceover_models_to_put.append( + voiceover_services.create_entity_voiceovers_model( + entity_voiceovers + ) + ) + + return ( + entity_voiceover_models_to_put, + logs_during_voiceover_generation, + ) + + def run(self) -> beam.PCollection[job_run_result.JobRunResult]: + """Returns a PCollection of job run results for EntityVoiceoversModels + that were updated after voiceover synthesis. + + Returns: + beam.PCollection[job_run_result.JobRunResult]. A PCollection + containing job run results with the IDs of the + EntityVoiceoversModels that were updated or created. + """ + exploration_models = ( + self.pipeline + | 'Get exploration models' + >> ndb_io.GetModels(exp_models.ExplorationModel.get_all()) + | 'Filter out curated explorations' + >> beam.Filter( + lambda model: self.is_exploration_curated( + exploration_id=model.id + ) + ) + ) + + entity_translation_models = ( + self.pipeline + | 'Get all entity translation models' + >> ndb_io.GetModels( + translation_models.EntityTranslationsModel.get_all() + ) + | 'Filter out entity translations for curated explorations' + >> beam.Filter( + lambda model: self.is_exploration_curated( + exploration_id=model.entity_id + ) + ) + ) + + entity_voiceovers_models = ( + self.pipeline + | 'Get all entity voiceover models' + >> ndb_io.GetModels( + voiceover_models.EntityVoiceoversModel.get_all() + ) + | 'Filter out entity voiceovers for curated explorations' + >> beam.Filter( + lambda model: self.is_exploration_curated( + exploration_id=model.entity_id + ) + ) + ) + + exploration_id_to_exploration = ( + exploration_models + | 'Map exploration ID to exploration model' + >> beam.Map(lambda model: (model.id, model)) + ) + + entity_id_to_translation_models = ( + entity_translation_models + | 'Map entity ID to translation model' + >> beam.Map(lambda model: (model.entity_id, model)) + ) + + entity_id_to_voiceover_models = ( + entity_voiceovers_models + | 'Map entity ID to voiceover model' + >> beam.Map(lambda model: (model.entity_id, model)) + ) + + combined_models = { + 'exploration': exploration_id_to_exploration, + 'translations': entity_id_to_translation_models, + 'voiceovers': entity_id_to_voiceover_models, + } | 'Join all by entity ID' >> beam.CoGroupByKey() + + voiceover_policy_model = ( + self.pipeline + | 'Get all voiceover autogeneration policy models' + >> ndb_io.GetModels( + voiceover_models.VoiceoverAutogenerationPolicyModel.get_all() + ) + ) + + custom_options = self.pipeline.options.view_as(job_options.JobOptions) + oppia_project_id = custom_options.oppia_project_id + + voiceovers_and_status = ( + combined_models + | 'Generate voiceovers for each exploration' + >> beam.ParDo( + GenerateVoiceoversFn(oppia_project_id=oppia_project_id), + beam.pvalue.AsSingleton(voiceover_policy_model), + ).with_outputs('status', main='voiceovers') + ) + + entity_voiceovers_models = voiceovers_and_status.voiceovers + status_strings = voiceovers_and_status.status + + if self.DATASTORE_UPDATES_ALLOWED: + unused_put_results = ( + entity_voiceovers_models + | 'Put models into datastore' >> ndb_io.PutModels() + ) + + return status_strings | 'Format results' >> beam.Map( + job_run_result.JobRunResult.as_stdout + ) + + +class VoiceoverSynthesisAuditJob(VoiceoverSynthesisJob): + """Audit job for VoiceoverSynthesisJob.""" + + DATASTORE_UPDATES_ALLOWED = False diff --git a/core/jobs/batch_jobs/voiceover_synthesis_jobs_test.py b/core/jobs/batch_jobs/voiceover_synthesis_jobs_test.py new file mode 100644 index 0000000000000..a69fb04fa7387 --- /dev/null +++ b/core/jobs/batch_jobs/voiceover_synthesis_jobs_test.py @@ -0,0 +1,604 @@ +# coding: utf-8 +# +# Copyright 2025 The Oppia Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS-IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for jobs.batch_jobs.voiceover_synthesis_jobs.""" + +from __future__ import annotations + +from core import constants, feconf +from core.domain import ( + exp_domain, + exp_services, + state_domain, + story_domain, + story_services, + topic_domain, + topic_services, + voiceover_regeneration_services, +) +from core.jobs import job_test_utils +from core.jobs.batch_jobs import voiceover_synthesis_jobs +from core.jobs.types import job_run_result +from core.platform import models +from core.tests import test_utils + +from typing import Dict, List, Type, Union + +MYPY = False +if MYPY: + from mypy_imports import translation_models, voiceover_models + +(translation_models, voiceover_models) = models.Registry.import_models( + [models.Names.TRANSLATION, models.Names.VOICEOVER] +) + + +class VoiceoverSynthesisBaseClass( + job_test_utils.JobTestBase, test_utils.GenericTestBase +): + """Base class for voiceover synthesis job tests.""" + + EDITOR_EMAIL_1 = 'editor1@example.com' + EDITOR_EMAIL_2 = 'editor2@example.com' + EDITOR_USERNAME_1 = 'editor1' + EDITOR_USERNAME_2 = 'editor2' + + CURATED_EXPLORATION_ID_1 = 'exploration_id_1' + CURATED_EXPLORATION_ID_2 = 'exploration_id_2' + NON_CURATED_EXPLORATION_ID = 'exploration_id_3' + + TOPIC_ID_1 = 'topic_id_1' + TOPIC_ID_2 = 'topic_id_2' + STORY_ID_1 = 'story_id_1' + STORY_ID_2 = 'story_id_2' + + def setUp(self) -> None: + super().setUp() + self.signup(self.EDITOR_EMAIL_1, self.EDITOR_USERNAME_1) + self.signup(self.EDITOR_EMAIL_2, self.EDITOR_USERNAME_2) + self.signup(self.CURRICULUM_ADMIN_EMAIL, self.CURRICULUM_ADMIN_USERNAME) + self.signup(self.OWNER_EMAIL, self.OWNER_USERNAME) + + self.set_curriculum_admins( + [ + self.EDITOR_USERNAME_1, + self.EDITOR_USERNAME_2, + self.CURRICULUM_ADMIN_USERNAME, + ] + ) + + self.admin_id = self.get_user_id_from_email(self.CURRICULUM_ADMIN_EMAIL) + self.owner_id = self.get_user_id_from_email(self.OWNER_EMAIL) + + self.voiceover_dict_1: state_domain.VoiceoverDict = { + 'filename': 'filename1.mp3', + 'file_size_bytes': 3000, + 'needs_update': False, + 'duration_secs': 42.43, + } + self.voiceover_dict_2: state_domain.VoiceoverDict = { + 'filename': 'filename2.mp3', + 'file_size_bytes': 3000, + 'needs_update': False, + 'duration_secs': 40, + } + + def _create_data_for_testing(self) -> None: + """This method creates three explorations — two curated and one + non-curated. It adds Hindi translations to the first curated + exploration and Portuguese translations to the second. Additionally, + it adds voiceovers in Hindi, Portuguese, and English to selected + content within the curated explorations. + """ + + # Creating and publishing two topics. + topic_1 = topic_domain.Topic.create_default_topic( + self.TOPIC_ID_1, 'topic1', 'abbrev', 'description', 'fragm' + ) + topic_1.thumbnail_filename = 'thumbnail.svg' + topic_1.thumbnail_bg_color = '#C6DCDA' + topic_1.subtopics = [ + topic_domain.Subtopic( + 1, + 'Title', + ['skill_id_1'], + 'image.svg', + constants.constants.ALLOWED_THUMBNAIL_BG_COLORS['subtopic'][0], + 21131, + 'dummy-subtopic-url', + ) + ] + topic_1.next_subtopic_id = 2 + topic_1.skill_ids_for_diagnostic_test = ['skill_id_1'] + + topic_services.save_new_topic(self.owner_id, topic_1) + topic_services.publish_topic(self.TOPIC_ID_1, self.admin_id) + + story_1 = story_domain.Story.create_default_story( + self.STORY_ID_1, + 'A story', + 'Description', + self.TOPIC_ID_1, + 'story-two', + ) + story_services.save_new_story(self.owner_id, story_1) + topic_services.add_canonical_story( + self.owner_id, self.TOPIC_ID_1, self.STORY_ID_1 + ) + + topic_services.publish_story( + self.TOPIC_ID_1, self.STORY_ID_1, self.admin_id + ) + + topic_2 = topic_domain.Topic.create_default_topic( + self.TOPIC_ID_2, 'topic2', 'abbrev-top', 'description', 'fragmem' + ) + topic_2.thumbnail_filename = 'thumbnail.svg' + topic_2.thumbnail_bg_color = '#C6DCDA' + topic_2.subtopics = [ + topic_domain.Subtopic( + 1, + 'Title subtopic', + ['skill_id_1'], + 'image.svg', + constants.constants.ALLOWED_THUMBNAIL_BG_COLORS['subtopic'][0], + 21131, + 'dummy-subtopic-url-sub', + ) + ] + topic_2.next_subtopic_id = 2 + topic_2.skill_ids_for_diagnostic_test = ['skill_id_1'] + + topic_services.save_new_topic(self.owner_id, topic_2) + topic_services.publish_topic(self.TOPIC_ID_2, self.admin_id) + + story_2 = story_domain.Story.create_default_story( + self.STORY_ID_2, + 'The second story', + 'Description second', + self.TOPIC_ID_2, + 'story-three', + ) + story_services.save_new_story(self.owner_id, story_2) + topic_services.add_canonical_story( + self.owner_id, self.TOPIC_ID_2, self.STORY_ID_2 + ) + + topic_services.publish_story( + self.TOPIC_ID_2, self.STORY_ID_2, self.admin_id + ) + + # Creating 2 curated explorations. + exploration_1 = self.save_new_valid_exploration( + self.CURATED_EXPLORATION_ID_1, + self.owner_id, + title='title1', + category=constants.constants.ALL_CATEGORIES[0], + end_state_name='End State', + ) + + self.publish_exploration(self.owner_id, exploration_1.id) + + exp_services.update_exploration( + self.owner_id, + self.CURATED_EXPLORATION_ID_1, + [ + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'property_name': exp_domain.STATE_PROPERTY_CONTENT, + 'state_name': 'Introduction', + 'new_value': { + 'content_id': 'content_0', + 'html': '

This is the first card of first exploration.

', + }, + } + ), + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'property_name': exp_domain.STATE_PROPERTY_CONTENT, + 'state_name': 'End State', + 'new_value': { + 'content_id': 'content_3', + 'html': '

This is the last card of first exploration.

', + }, + } + ), + ], + 'Changes content.', + ) + + story_services.update_story( + self.owner_id, + self.STORY_ID_1, + [ + story_domain.StoryChange( + { + 'cmd': 'add_story_node', + 'node_id': 'node_1', + 'title': 'Node1', + } + ), + story_domain.StoryChange( + { + 'cmd': 'update_story_node_property', + 'property_name': 'exploration_id', + 'node_id': 'node_1', + 'old_value': None, + 'new_value': self.CURATED_EXPLORATION_ID_1, + } + ), + ], + 'Changes.', + ) + + exploration_2 = self.save_new_valid_exploration( + self.CURATED_EXPLORATION_ID_2, + self.owner_id, + title='title2', + category=constants.constants.ALL_CATEGORIES[0], + end_state_name='End State', + ) + self.publish_exploration(self.owner_id, exploration_2.id) + + exp_services.update_exploration( + self.owner_id, + self.CURATED_EXPLORATION_ID_2, + [ + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'property_name': exp_domain.STATE_PROPERTY_CONTENT, + 'state_name': 'Introduction', + 'new_value': { + 'content_id': 'content_0', + 'html': '

This is the first card of second exploration.

', + }, + } + ), + exp_domain.ExplorationChange( + { + 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY, + 'property_name': exp_domain.STATE_PROPERTY_CONTENT, + 'state_name': 'End State', + 'new_value': { + 'content_id': 'content_3', + 'html': '

This is the last card of second exploration.

', + }, + } + ), + ], + 'Changes content.', + ) + + story_services.update_story( + self.owner_id, + self.STORY_ID_2, + [ + story_domain.StoryChange( + { + 'cmd': 'add_story_node', + 'node_id': 'node_1', + 'title': 'Node1', + } + ), + story_domain.StoryChange( + { + 'cmd': 'update_story_node_property', + 'property_name': 'exploration_id', + 'node_id': 'node_1', + 'old_value': None, + 'new_value': self.CURATED_EXPLORATION_ID_2, + } + ), + ], + 'Changes.', + ) + + # Create a non-curated exploration. + exploration_3 = self.save_new_valid_exploration( + self.NON_CURATED_EXPLORATION_ID, + self.owner_id, + title='title3', + category=constants.constants.ALL_CATEGORIES[0], + end_state_name='End State', + ) + self.publish_exploration(self.owner_id, exploration_3.id) + + # Adding Hindi and Portuguese translations to the first and second. + translation_models.EntityTranslationsModel.create_new( + 'exploration', + self.CURATED_EXPLORATION_ID_1, + 2, + 'hi', + { + 'content_0': { + 'content_value': '

यह प्रथम अन्वेषण का पहला कार्ड है.

', + 'content_format': 'html', + 'needs_update': False, + }, + 'content_3': { + 'content_value': '

यह प्रथम अन्वेषण का अंतिम कार्ड है.

', + 'content_format': 'html', + 'needs_update': True, + }, + }, + ).put() + + translation_models.EntityTranslationsModel.create_new( + 'exploration', + self.CURATED_EXPLORATION_ID_2, + 2, + 'pt', + { + 'content_0': { + 'content_value': '

Esta é a primeira carta da segunda exploração.

', + 'content_format': 'html', + 'needs_update': False, + }, + 'content_3': { + 'content_value': '

Esta é a segunda carta da segunda exploração.

', + 'content_format': 'html', + 'needs_update': True, + }, + }, + ).put() + + voiceover_models.EntityVoiceoversModel.create_new( + feconf.ENTITY_TYPE_EXPLORATION, + self.CURATED_EXPLORATION_ID_1, + 1, + 'en-US', + { + 'content_0': { + 'manual': None, + 'auto': None, + } + }, + {}, + ).put() + + voiceover_models.EntityVoiceoversModel.create_new( + feconf.ENTITY_TYPE_EXPLORATION, + self.CURATED_EXPLORATION_ID_1, + 2, + 'en-US', + { + 'content_0': { + 'manual': self.voiceover_dict_1, + 'auto': self.voiceover_dict_2, + } + }, + {}, + ).put() + + voiceover_models.EntityVoiceoversModel.create_new( + feconf.ENTITY_TYPE_EXPLORATION, + self.CURATED_EXPLORATION_ID_1, + 2, + 'hi-IN', + { + 'content_0': { + 'manual': None, + 'auto': self.voiceover_dict_2, + } + }, + {}, + ).put() + + voiceover_models.EntityVoiceoversModel.create_new( + feconf.ENTITY_TYPE_EXPLORATION, + self.CURATED_EXPLORATION_ID_2, + 2, + 'en-US', + { + 'content_0': { + 'manual': self.voiceover_dict_1, + 'auto': self.voiceover_dict_2, + } + }, + {}, + ).put() + + voiceover_models.EntityVoiceoversModel.create_new( + feconf.ENTITY_TYPE_EXPLORATION, + self.CURATED_EXPLORATION_ID_2, + 2, + 'pt-BR', + { + 'content_0': { + 'manual': None, + 'auto': self.voiceover_dict_2, + } + }, + {}, + ).put() + + voiceover_autogeneration_policy_model = ( + voiceover_models.VoiceoverAutogenerationPolicyModel( + id=voiceover_models.VOICEOVER_AUTOGENERATION_POLICY_ID + ) + ) + voiceover_autogeneration_policy_model.language_codes_mapping = { + 'en': {'en-US': True, 'en-NG': False}, + 'hi': {'hi-IN': True}, + 'pt': {'pt-BR': True}, + } + ( + voiceover_autogeneration_policy_model.autogenerated_voiceovers_are_enabled + ) = True + voiceover_autogeneration_policy_model.update_timestamps() + voiceover_autogeneration_policy_model.put() + + +class VoiceoverSynthesisJobRunTests(VoiceoverSynthesisBaseClass): + + JOB_CLASS: Type[voiceover_synthesis_jobs.VoiceoverSynthesisJob] = ( + voiceover_synthesis_jobs.VoiceoverSynthesisJob + ) + + def test_empty_storage(self) -> None: + self.assert_job_output_is_empty() + + def test_should_regenerate_voiceover_successfully(self) -> None: + self._create_data_for_testing() + + expected_output_1 = ( + 'EntityVoiceovers ID: exploration-exploration_id_1-2-en-US.\n' + 'Total content IDs processed: 4. Total characters processed: 101.\n' + 'EntityVoiceovers ID: exploration-exploration_id_1-2-hi-IN.\n' + 'Total content IDs processed: 1. Total characters processed: 41.\n' + ) + + expected_output_2 = ( + 'EntityVoiceovers ID: exploration-exploration_id_2-2-en-US.\n' + 'Total content IDs processed: 4. Total characters processed: 103.\n' + 'EntityVoiceovers ID: exploration-exploration_id_2-2-pt-BR.\n' + 'Total content IDs processed: 1. Total characters processed: 53.\n' + ) + + expected_output = [ + job_run_result.JobRunResult(stdout=expected_output_1, stderr=''), + job_run_result.JobRunResult(stdout=expected_output_2, stderr=''), + ] + + self.assert_job_output_is(expected_output) + + def test_check_is_exploration_curated_for_invalid_id(self) -> None: + is_exploration_curated = voiceover_synthesis_jobs.VoiceoverSynthesisJob.is_exploration_curated( + exploration_id='' + ) + self.assertFalse(is_exploration_curated) + + def test_should_handle_failures_during_voiceover_regneration(self) -> None: + def mock_synthesize_voiceover_for_html_string( + _exploration_id: str, + _content_html: str, + _language_accent_code: str, + _voiceover_filename: str, + _oppia_project_id: str, + ) -> List[Dict[str, Union[str, float]]]: + raise Exception('Failed to generate voiceovers.') + + self._create_data_for_testing() + + expected_output_1 = ( + 'EntityVoiceovers ID: exploration-exploration_id_1-2-en-US.\n' + 'Content IDs failed: [content_0, default_outcome_1, ca_placeholder_2, content_3]. Error message: Failed to generate voiceovers.\n' + 'Total content IDs processed: 4. Total characters processed: 0.\n' + 'EntityVoiceovers ID: exploration-exploration_id_1-2-hi-IN.\n' + 'Content IDs failed: [content_0]. Error message: Failed to generate voiceovers.\n' + 'Total content IDs processed: 1. Total characters processed: 0.\n' + ) + expected_output_2 = ( + 'EntityVoiceovers ID: exploration-exploration_id_2-2-en-US.\n' + 'Content IDs failed: [content_0, default_outcome_1, ca_placeholder_2, content_3]. Error message: Failed to generate voiceovers.\n' + 'Total content IDs processed: 4. Total characters processed: 0.\n' + 'EntityVoiceovers ID: exploration-exploration_id_2-2-pt-BR.\n' + 'Content IDs failed: [content_0]. Error message: Failed to generate voiceovers.\n' + 'Total content IDs processed: 1. Total characters processed: 0.\n' + ) + + with self.swap( + voiceover_regeneration_services, + 'synthesize_voiceover_for_html_string', + mock_synthesize_voiceover_for_html_string, + ): + expected_output = [ + job_run_result.JobRunResult( + stdout=expected_output_1, stderr='' + ), + job_run_result.JobRunResult( + stdout=expected_output_2, stderr='' + ), + ] + + self.assert_job_output_is(expected_output) + + def test_should_handle_empty_strings_during_voiceover_regneration( + self, + ) -> None: + + def mock_synthesize_voiceover_for_html_string( + _exploration_id: str, + _content_html: str, + _language_accent_code: str, + _voiceover_filename: str, + _oppia_project_id: str, + ) -> List[Dict[str, Union[str, float]]]: + return [] + + self._create_data_for_testing() + + expected_output_1 = ( + 'EntityVoiceovers ID: exploration-exploration_id_1-2-en-US.\n' + 'Total content IDs processed: 4. Total characters processed: 0.\n' + 'EntityVoiceovers ID: exploration-exploration_id_1-2-hi-IN.\n' + 'Total content IDs processed: 1. Total characters processed: 0.\n' + ) + expected_output_2 = ( + 'EntityVoiceovers ID: exploration-exploration_id_2-2-en-US.\n' + 'Total content IDs processed: 4. Total characters processed: 0.\n' + 'EntityVoiceovers ID: exploration-exploration_id_2-2-pt-BR.\n' + 'Total content IDs processed: 1. Total characters processed: 0.\n' + ) + + with self.swap( + voiceover_regeneration_services, + 'synthesize_voiceover_for_html_string', + mock_synthesize_voiceover_for_html_string, + ): + expected_output = [ + job_run_result.JobRunResult( + stdout=expected_output_1, stderr='' + ), + job_run_result.JobRunResult( + stdout=expected_output_2, stderr='' + ), + ] + + self.assert_job_output_is(expected_output) + + +class VoiceoverSynthesisAuditJobRunTests(VoiceoverSynthesisBaseClass): + + JOB_CLASS: Type[voiceover_synthesis_jobs.VoiceoverSynthesisAuditJob] = ( + voiceover_synthesis_jobs.VoiceoverSynthesisAuditJob + ) + + def test_should_regenerate_voiceover_successfully(self) -> None: + self._create_data_for_testing() + + expected_output_1 = ( + 'EntityVoiceovers ID: exploration-exploration_id_1-2-en-US.\n' + 'Total content IDs processed: 4. Total characters processed: 101.\n' + 'EntityVoiceovers ID: exploration-exploration_id_1-2-hi-IN.\n' + 'Total content IDs processed: 1. Total characters processed: 41.\n' + ) + + expected_output_2 = ( + 'EntityVoiceovers ID: exploration-exploration_id_2-2-en-US.\n' + 'Total content IDs processed: 4. Total characters processed: 103.\n' + 'EntityVoiceovers ID: exploration-exploration_id_2-2-pt-BR.\n' + 'Total content IDs processed: 1. Total characters processed: 53.\n' + ) + + expected_output = [ + job_run_result.JobRunResult(stdout=expected_output_1, stderr=''), + job_run_result.JobRunResult(stdout=expected_output_2, stderr=''), + ] + self.assert_job_output_is(expected_output) diff --git a/core/jobs/job_options.py b/core/jobs/job_options.py index 6d9860de00ec2..113f13cdb72b3 100644 --- a/core/jobs/job_options.py +++ b/core/jobs/job_options.py @@ -46,10 +46,37 @@ class JobOptions(pipeline_options.PipelineOptions): # type: ignore[misc] str, 'Namespace for isolating the NDB operations during tests.', ), + 'oppia_project_id': ( + str, + 'The ID of the Google Cloud Project for Oppia.', + ), + } + + # A subset of Dataflow pipeline options related to resource utilization. + # For the complete list of available options, refer to: + # https://docs.cloud.google.com/dataflow/docs/reference/pipeline-options#resource_utilization + DATAFLOW_RESOURCE_OPTIONS = { + 'max_num_workers', + 'num_workers', + 'autoscaling_algorithm', + } + + # Historically, Beam jobs get data from the datastore and process it. + # In some cases, we may want to run parameterized Beam jobs that get data + # from other sources. For example, in the case of bulk regeneration of + # voiceovers by language accent, we want to pass language accent code as a + # parameter to the Beam job. + SPECIAL_JOB_OPTIONS = { + 'language_accent_code': ( + str, + 'Language-accent code to scope voiceover synthesis jobs.', + ), } def __init__( - self, flags: Optional[List[str]] = None, **job_options: Optional[str] + self, + flags: Optional[List[str]] = None, + **job_options: Optional[str | int], ) -> None: """Initializes a new JobOptions instance. @@ -64,13 +91,17 @@ def __init__( Raises: ValueError. Unsupported job option(s). """ - unsupported_options = set(job_options).difference(self.JOB_OPTIONS) + allowed_options = set(self.JOB_OPTIONS.keys()).union( + self.DATAFLOW_RESOURCE_OPTIONS, self.SPECIAL_JOB_OPTIONS + ) + unsupported_options = set(job_options).difference(allowed_options) if unsupported_options: joined_unsupported_options = ', '.join(sorted(unsupported_options)) raise ValueError( 'Unsupported option(s): %s' % joined_unsupported_options ) oppia_project_id = app_identity_services.get_application_id() + assert isinstance(oppia_project_id, str) super().__init__( # Needed by PipelineOptions. @@ -106,3 +137,11 @@ def _add_argparse_args(cls, parser: argparse.ArgumentParser) -> None: parser.add_argument( '--%s' % option_name, help=option_doc, type=option_type ) + + for option_name, ( + option_type, + option_doc, + ) in cls.SPECIAL_JOB_OPTIONS.items(): + parser.add_argument( + '--%s' % option_name, help=option_doc, type=option_type + ) diff --git a/core/jobs/job_options_test.py b/core/jobs/job_options_test.py index 3a4a92ced4e27..c650c04e09dd2 100644 --- a/core/jobs/job_options_test.py +++ b/core/jobs/job_options_test.py @@ -34,6 +34,11 @@ def test_overwritten_values(self) -> None: self.assertEqual(options.namespace, 'abc') + def test_special_overwritten_values(self) -> None: + options = job_options.JobOptions(language_accent_code='en-IN') + + self.assertEqual(options.language_accent_code, 'en-IN') + def test_unsupported_values(self) -> None: with self.assertRaisesRegex(ValueError, r'Unsupported option\(s\)'): job_options.JobOptions(a='a', b='b') diff --git a/core/jobs/job_test_utils.py b/core/jobs/job_test_utils.py index 9264aaef6b136..e91af3b973135 100644 --- a/core/jobs/job_test_utils.py +++ b/core/jobs/job_test_utils.py @@ -63,7 +63,10 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.pipeline = test_pipeline.TestPipeline( runner=runners.DirectRunner(), - options=job_options.JobOptions(namespace=self.namespace), + options=job_options.JobOptions( + namespace=self.namespace, + oppia_project_id='dev-project-id', + ), ) self._pipeline_context_stack: Optional[contextlib.ExitStack] = None diff --git a/core/jobs/jobs_manager.py b/core/jobs/jobs_manager.py index c719e741d7b25..d8f8ec89c2250 100644 --- a/core/jobs/jobs_manager.py +++ b/core/jobs/jobs_manager.py @@ -33,7 +33,7 @@ import apache_beam as beam from apache_beam import runners from google.cloud import dataflow -from typing import Iterator, Optional, Type +from typing import Dict, Iterator, Optional, Type MYPY = False if MYPY: # pragma: no cover @@ -103,6 +103,7 @@ def run_job( sync: bool, namespace: Optional[str] = None, pipeline: Optional[beam.Pipeline] = None, + parameterized_args: Optional[Dict[str, str]] = None, ) -> beam_job_models.BeamJobRunModel: """Runs the specified job synchronously. @@ -115,6 +116,8 @@ def run_job( namespace: str. The namespace in which models should be created. pipeline: Pipeline. The pipeline to run the job upon. If omitted, then a new pipeline will be used instead. + parameterized_args: dict(str, str). The dictionary of parameterized + arguments to be passed to the job. Returns: BeamJobRun. Contains metadata related to the execution status of the @@ -123,14 +126,34 @@ def run_job( Raises: RuntimeError. Failed to deploy given job to the Dataflow service. """ + job_name = job_class.__name__ + + additional_options: Dict[str, int | str] = {} + if does_job_requires_limiting_workers(job_name): + # We want to limit the number of workers for Beam jobs related to voiceover + # synthesis, as these jobs depend on Azure for voiceover regeneration, and + # increasing parallelism may lead to rate-limiting issues. + logging.info('Limiting the number of workers for job: %s' % job_name) + additional_options = { + 'max_num_workers': 15, + 'autoscaling_algorithm': 'THROUGHPUT_BASED', + } + + if parameterized_args: + additional_options.update(parameterized_args) + if pipeline is None: pipeline = beam.Pipeline( runner=runners.DirectRunner() if sync else runners.DataflowRunner(), - options=job_options.JobOptions(namespace=namespace), + options=job_options.JobOptions( + flags=None, + namespace=namespace, + oppia_project_id=app_identity_services.get_application_id(), + **additional_options, + ), ) job = job_class(pipeline) - job_name = job_class.__name__ # Clear cache before running the job to be sure that the cache # does not affect the job. @@ -294,3 +317,22 @@ def _put_job_stderr(job_id: str, stderr: str) -> None: job_id, '', stderr ) result_model.put() + + +def does_job_requires_limiting_workers(job_name: str) -> bool: + """Returns whether the given job requires limiting the number of workers. + + Args: + job_name: str. The name of the job. + + Returns: + bool. Whether the given job requires limiting the number of workers. + """ + jobs_requiring_limiting_workers = [ + 'VoiceoverSynthesisJob', + 'VoiceoverSynthesisAuditJob', + 'VoiceoverSynthesisByAccentJob', + # The below job is used in unit tests. + 'VoiceoverSynthesisForTestingJob', + ] + return job_name in jobs_requiring_limiting_workers diff --git a/core/jobs/jobs_manager_test.py b/core/jobs/jobs_manager_test.py index 580e843201ff9..6882fd946116a 100644 --- a/core/jobs/jobs_manager_test.py +++ b/core/jobs/jobs_manager_test.py @@ -43,6 +43,15 @@ def run(self) -> beam.PCollection[job_run_result.JobRunResult]: ) +class VoiceoverSynthesisForTestingJob(base_jobs.JobBase): + """Simple job for voiceover synthesis to test resource limiting.""" + + def run(self) -> beam.PCollection[job_run_result.JobRunResult]: + return self.pipeline | beam.Create( + [job_run_result.JobRunResult(stdout='o', stderr='e')] + ) + + class FailingJob(base_jobs.JobBase): """Simple job that always raises an exception.""" @@ -113,6 +122,24 @@ def test_async_job_that_does_not_start(self) -> None: result = beam_job_services.get_beam_job_run_result(run.id) self.assertIn('Failed to deploy WorkingJob', result.stderr) + def test_job_run_with_parameterized_arg(self) -> None: + run = jobs_manager.run_job( + WorkingJob, + True, + namespace=self.namespace, + parameterized_args={'language_accent_code': 'en-IN'}, + ) + + self.assertEqual(run.latest_job_state, 'DONE') + + run_model = beam_job_models.BeamJobRunModel.get(run.id) + self.assertEqual(run, run_model) + + self.assertEqual( + beam_job_services.get_beam_job_run_result(run.id).to_dict(), + {'stdout': 'o', 'stderr': 'e'}, + ) + class RefreshStateOfBeamJobRunModelTests(test_utils.GenericTestBase): @@ -253,3 +280,33 @@ def test_failed_api_call_logs_the_exception(self) -> None: self.assertGreater(len(logs), 0) self.assertIn('uh-oh', logs[0]) + + +class LimitJobResourcesTests(test_utils.GenericTestBase): + + def test_does_job_requires_limiting_workers_true(self) -> None: + job_name = 'VoiceoverSynthesisJob' + self.assertTrue( + jobs_manager.does_job_requires_limiting_workers(job_name) + ) + + def test_does_job_requires_limiting_workers_false(self) -> None: + job_name = 'SomeOtherJob' + self.assertFalse( + jobs_manager.does_job_requires_limiting_workers(job_name) + ) + + def test_working_voiceover_sync_job(self) -> None: + run = jobs_manager.run_job( + VoiceoverSynthesisForTestingJob, True, namespace=self.namespace + ) + + self.assertEqual(run.latest_job_state, 'DONE') + + run_model = beam_job_models.BeamJobRunModel.get(run.id) + self.assertEqual(run, run_model) + + self.assertEqual( + beam_job_services.get_beam_job_run_result(run.id).to_dict(), + {'stdout': 'o', 'stderr': 'e'}, + ) diff --git a/core/jobs/registry.py b/core/jobs/registry.py index 3fa1798fb5548..42038ad60ce21 100644 --- a/core/jobs/registry.py +++ b/core/jobs/registry.py @@ -45,8 +45,11 @@ audit_non_existent_threads_messages_jobs, audit_stories_with_disconnected_node_ids_job, audit_threads_with_missing_suggestions_jobs, + blog_author_details_migration_jobs, blog_post_search_indexing_jobs, blog_validation_jobs, + cleanup_duplicate_translation_suggestions_jobs, + cloud_task_run_migration_jobs, collection_info_jobs, contributor_admin_stats_jobs, delete_duplicate_content_ids_jobs, @@ -66,11 +69,14 @@ subtopic_migration_jobs, suggestion_migration_jobs, suggestion_stats_computation_jobs, + synthesize_voiceover_by_language_accent_jobs, topic_migration_jobs, + translation_audit_jobs, translation_migration_jobs, user_bios_change_jobs, user_stats_computation_jobs, user_validation_jobs, + voiceover_synthesis_jobs, ) from typing import List, Type diff --git a/core/platform/app_identity/gae_app_identity_services.py b/core/platform/app_identity/gae_app_identity_services.py index 14f21671cf523..4b199035789df 100644 --- a/core/platform/app_identity/gae_app_identity_services.py +++ b/core/platform/app_identity/gae_app_identity_services.py @@ -20,6 +20,8 @@ import os +from typing import Optional + _GCS_RESOURCE_BUCKET_NAME_SUFFIX = '-resources' @@ -43,7 +45,7 @@ def get_application_id() -> str: return oppia_project_id -def get_gcs_resource_bucket_name() -> str: +def get_gcs_resource_bucket_name(oppia_project_id: Optional[str] = None) -> str: """Returns the application's bucket name for GCS resources, which depends on the application ID in production mode, or default bucket name in development mode. @@ -55,7 +57,13 @@ def get_gcs_resource_bucket_name() -> str: if we try to use it in production mode but the default bucket hasn't been enabled through the project console. + Args: + oppia_project_id: Optional[str]. The Google Cloud Project ID. Explicitly + required when running on Beam Dataflow, as workers cannot + retrieve the ID from environment variables. + Returns: str. The bucket name for the application's GCS resources. """ - return '%s%s' % (get_application_id(), _GCS_RESOURCE_BUCKET_NAME_SUFFIX) + project_id = oppia_project_id or get_application_id() + return '%s%s' % (project_id, _GCS_RESOURCE_BUCKET_NAME_SUFFIX) diff --git a/core/platform/search/elastic_search_services.py b/core/platform/search/elastic_search_services.py index 8907a667848d6..d99427b6bd64e 100644 --- a/core/platform/search/elastic_search_services.py +++ b/core/platform/search/elastic_search_services.py @@ -413,6 +413,8 @@ def blog_post_summaries_search( 'multi_match': { 'query': query_string, 'fields': ['title', 'summary'], + 'type': 'bool_prefix', + 'operator': 'and', } } ] diff --git a/core/platform/search/elastic_search_services_test.py b/core/platform/search/elastic_search_services_test.py index c0af1f204b71c..fd0dce3f708d4 100644 --- a/core/platform/search/elastic_search_services_test.py +++ b/core/platform/search/elastic_search_services_test.py @@ -390,6 +390,8 @@ def mock_search( 'multi_match': { 'query': 'query', 'fields': ['title', 'summary'], + 'type': 'bool_prefix', + 'operator': 'and', } } ], diff --git a/core/platform/secrets/cloud_secrets_services.py b/core/platform/secrets/cloud_secrets_services.py index b8c15fa650e78..28a6a1bedb510 100644 --- a/core/platform/secrets/cloud_secrets_services.py +++ b/core/platform/secrets/cloud_secrets_services.py @@ -45,16 +45,20 @@ @functools.lru_cache(maxsize=64) -def get_secret(name: str) -> Optional[str]: +def get_secret(name: str, project_id: Optional[str] = None) -> Optional[str]: """Gets the value of a secret. Args: name: str. The name of the secret to retrieve. + project_id: Optional[str]. The Google Cloud Project ID. Explicitly + required when running on Beam Dataflow, as workers cannot + retrieve the ID from environment variables. Returns: str. The value of the secret. """ - oppia_project_id = app_identity_services.get_application_id() + oppia_project_id = project_id or app_identity_services.get_application_id() + secret_name = f'projects/{oppia_project_id}/secrets/{name}/versions/latest' try: response = CLIENT.access_secret_version(request={'name': secret_name}) diff --git a/core/platform/secrets/cloud_secrets_services_test.py b/core/platform/secrets/cloud_secrets_services_test.py index 1f3f571afd91a..7d43991982fc0 100644 --- a/core/platform/secrets/cloud_secrets_services_test.py +++ b/core/platform/secrets/cloud_secrets_services_test.py @@ -35,6 +35,19 @@ def test_get_secret_returns_existing_secret(self) -> None: ): self.assertEqual(cloud_secrets_services.get_secret('name'), 'secre') + def test_get_secret_with_oppia_project_id_as_parameter(self) -> None: + with self.swap_to_always_return( + cloud_secrets_services.CLIENT, + 'access_secret_version', + types.SimpleNamespace(payload=types.SimpleNamespace(data=b'secre')), + ): + self.assertEqual( + cloud_secrets_services.get_secret( + 'name', project_id='project-id' + ), + 'secre', + ) + def test_get_secret_returns_none_when_secret_does_not_exist(self) -> None: with self.swap_to_always_raise( cloud_secrets_services.CLIENT, diff --git a/core/platform/secrets/dev_mode_secrets_services.py b/core/platform/secrets/dev_mode_secrets_services.py index 09482ae87d47c..92225758e4353 100644 --- a/core/platform/secrets/dev_mode_secrets_services.py +++ b/core/platform/secrets/dev_mode_secrets_services.py @@ -26,11 +26,14 @@ @functools.lru_cache(maxsize=64) -def get_secret(name: str) -> Optional[str]: +def get_secret(name: str, _: Optional[str] = None) -> Optional[str]: """Gets the value of a secret. This is only dev mode version of the secrets. Args: name: str. The name of the secret to retrieve. + _: Optional[str]. The Google Cloud Project ID. Explicitly + required when running on Beam Dataflow, as workers cannot + retrieve the ID from environment variables. Returns: str. The value of the secret. diff --git a/core/platform/speech_synthesis/azure_speech_synthesis_services.py b/core/platform/speech_synthesis/azure_speech_synthesis_services.py index f1dc7c9bd8668..3774922e5b503 100644 --- a/core/platform/speech_synthesis/azure_speech_synthesis_services.py +++ b/core/platform/speech_synthesis/azure_speech_synthesis_services.py @@ -21,12 +21,12 @@ from __future__ import annotations -import json -import os +import logging import re +import time +from xml.sax import saxutils -from core import feconf -from core.constants import constants +from core import constants, feconf from core.domain import voiceover_services from core.platform import models @@ -61,6 +61,8 @@

""" +MAX_RETRIES_FOR_VOICEOVER_SYNTHESIS_WITH_EXPONENTIAL_BACKOFF = 10 + class WordBoundaryCollection: """This class handles word boundary events to collect the time offsets @@ -103,11 +105,9 @@ def get_azure_voicecode_from_language_accent_code( str. The Azure voice code associated with the given language accent code. """ - file_path = os.path.join( - feconf.VOICEOVERS_DATA_DIR, 'autogeneratable_language_accent_list.json' + autogeneratable_language_accent_list: Dict[str, Dict[str, str]] = ( + constants.autogeneratable_language_accent_constants ) - with open(file_path, 'r', encoding='utf-8') as f: - autogeneratable_language_accent_list = json.loads(f.read()) voice_code: str = autogeneratable_language_accent_list[ language_accent_code @@ -130,7 +130,7 @@ def process_factorial_in_text( str. The processed text with factorial expressions replaced by their corresponding words or phrases. """ - pronounciation = math_symbol_pronunciations['!'] + ' ' + pronounciation = math_symbol_pronunciations.get('!', '') + ' ' return re.sub(r'(\d+)!', pronounciation + r'\1', text) @@ -254,18 +254,29 @@ def convert_plaintext_to_ssml_content( ) math_symbol_pronunciations = ( - constants.LANGUAGE_CODE_TO_MATH_SYMBOL_PRONUNCIATIONS.get( + constants.constants.LANGUAGE_CODE_TO_MATH_SYMBOL_PRONUNCIATIONS.get( language_code, {} ) ) main_ssml_content = '' for content in content_list: + # Escaping special characters in the content to ensure they are + # pronounced correctly by the Azure Text-to-Speech service. + # This includes characters like <, >, &, etc. + content = saxutils.escape(content) # Updates the content to pronounce `-` correctly in the given language. if ' - ' in content: - content = content.replace( - '-', MATH_TEMPLATE_SSML_BLOCK % math_symbol_pronunciations['-'] - ) + pattern = re.compile(r'(\d+)\s*-\s*(\d+)') + + def replacer(match: re.Match[str]) -> str: + num1, num2 = match.groups() + pronunciation = ( + MATH_TEMPLATE_SSML_BLOCK % math_symbol_pronunciations['-'] + ) + return '%s %s %s' % (num1, pronunciation, num2) + + content = pattern.sub(replacer, content) # Update the content to pronounce `*` correctly in the given language. if ' * ' in content: @@ -286,7 +297,8 @@ def convert_plaintext_to_ssml_content( # Update the content to pronounce `/` correctly in the given language. if ' / ' in content: content = content.replace( - '/', MATH_TEMPLATE_SSML_BLOCK % math_symbol_pronunciations['÷'] + ' / ', + MATH_TEMPLATE_SSML_BLOCK % math_symbol_pronunciations['÷'], ) # Update the content to pronounce `÷` correctly in the given language. @@ -332,8 +344,10 @@ def convert_plaintext_to_ssml_content( def regenerate_speech_from_text( - plaintext: str, language_accent_code: str -) -> Tuple[bytes, List[Dict[str, Union[str, float]]], Optional[str]]: + plaintext: str, + language_accent_code: str, + oppia_project_id: Optional[str] = None, +) -> Tuple[Optional[bytes], List[Dict[str, Union[str, float]]], Optional[str]]: """Regenerates speech (Oppia's voiceovers) from the provided text. This method uses Azure Text-to-Speech to synthesize speech from the input @@ -344,6 +358,9 @@ def regenerate_speech_from_text( plaintext: str. The plaintext that needs to be synthesized into speech. language_accent_code: str. The language accent code in which the speech is to be synthesized. + oppia_project_id: Optional[str]. The Google Cloud Project ID. Explicitly + required when running on Beam Dataflow, as workers cannot + retrieve the ID from environment variables. Returns: tuple. A tuple containing three elements: @@ -360,7 +377,9 @@ def regenerate_speech_from_text( """ # Azure text-to-speech API key. - azure_tts_api_key = secrets_services.get_secret('AZURE_TTS_API_KEY') + azure_tts_api_key = secrets_services.get_secret( + 'AZURE_TTS_API_KEY', oppia_project_id + ) if azure_tts_api_key is None: raise Exception('Azure TTS API key is not available.') @@ -393,18 +412,85 @@ def regenerate_speech_from_text( plaintext, language_accent_code ) - speech_synthesis_result = speech_synthesizer.speak_ssml_async( - ssml_text_for_speech_synthesis - ).get() + delay_in_sec_before_retrying = 1 + binary_audio_data = None + error_details = None + + for _ in range( + MAX_RETRIES_FOR_VOICEOVER_SYNTHESIS_WITH_EXPONENTIAL_BACKOFF + ): + logging.info( + 'Voiceover synthesis log: Retrying speech synthesis after %s seconds delay.', + delay_in_sec_before_retrying, + ) + time.sleep(delay_in_sec_before_retrying) + + speech_synthesis_result = speech_synthesizer.speak_ssml_async( + ssml_text_for_speech_synthesis + ).get() + + if ( + speech_synthesis_result.reason + == speechsdk.ResultReason.SynthesizingAudioCompleted + ): + binary_audio_data = speech_synthesis_result.audio_data + error_details = None + break - binary_audio_data = speech_synthesis_result.audio_data + if speech_synthesis_result.reason == speechsdk.ResultReason.Canceled: + cancellation_details = speech_synthesis_result.cancellation_details + + if ( + cancellation_details.reason + == speechsdk.CancellationReason.Error + ): + error_details = cancellation_details.error_details + error_code = cancellation_details.error_code + + logging.error( + 'Voiceover synthesis log: Speech synthesis failed for content %s with error code %s and details: %s' + % (plaintext, error_code, error_details) + ) + + # Exponential backoff for retrying speech synthesis in case of too + # many requests, connection failure, or service timeout errors. + if error_code in [ + speechsdk.CancellationErrorCode.TooManyRequests, + speechsdk.CancellationErrorCode.ConnectionFailure, + speechsdk.CancellationErrorCode.ServiceTimeout, + ]: + logging.info( + 'Voiceover synthesis log: Known error encountered, retrying with exponential backoff.' + ) + delay_in_sec_before_retrying *= 2 + continue + + logging.info( + 'Voiceover synthesis log: Non-retryable error encountered, aborting further attempts.' + ) + break + + error_details = ( + 'Speech synthesis was canceled for reason: %s' + % cancellation_details.reason + ) + logging.error( + 'Voiceover synthesis log: Voiceover synthesis error: %s for content: %s' + % (error_details, plaintext) + ) + break - error_details = None - if speech_synthesis_result.reason == speechsdk.ResultReason.Canceled: - cancellation_details = speech_synthesis_result.cancellation_details + error_details = ( + 'Speech synthesis failed for reason: %s' + % speech_synthesis_result.reason + ) + logging.error( + 'Voiceover synthesis log: Voiceover synthesis error: %s for content: %s' + % (error_details, plaintext) + ) + break - if cancellation_details.reason == speechsdk.CancellationReason.Error: - error_details = cancellation_details.error_details + logging.info('Voiceover synthesis log: Speech synthesis attempt completed.') return ( binary_audio_data, diff --git a/core/platform/speech_synthesis/azure_speech_synthesis_services_test.py b/core/platform/speech_synthesis/azure_speech_synthesis_services_test.py index 2f24a025b02ad..b84e8b65dc84d 100644 --- a/core/platform/speech_synthesis/azure_speech_synthesis_services_test.py +++ b/core/platform/speech_synthesis/azure_speech_synthesis_services_test.py @@ -42,9 +42,9 @@ def setUp(self) -> None: self.swap_api_key_secrets_return_secret = self.swap_with_checks( secrets_services, 'get_secret', - lambda _: 'azure_key', + lambda _, oppia_project_id: 'azure_key', expected_args=[ - ('AZURE_TTS_API_KEY',), + ('AZURE_TTS_API_KEY', None), ], ) @@ -200,7 +200,7 @@ def test_regenerate_speech_from_text_failed_for_invalid_credentials( plaintext = 'This is a test text' language_accent_code = 'en-US' - mock_audio_data = b'' + mock_audio_data = None mock_word_boundaries: List[Dict[str, Union[str, float]]] = [] mock_error_details = ( 'WebSocket upgrade failed: Authentication error (401). ' @@ -238,7 +238,7 @@ def test_regenerate_speech_from_text_failed( plaintext, language_accent_code ) ) - mock_audio_data = b'' + mock_audio_data = None mock_speech_config_instance = mock_speech_config.return_value mock_speech_config_instance.set_speech_synthesis_output_format = ( @@ -289,6 +289,168 @@ def test_regenerate_speech_from_text_failed( self.assertEqual(result_audio_offsets, mock_word_boundaries) self.assertEqual(result_error, error_details) + @mock.patch('azure.cognitiveservices.speech.SpeechSynthesizer') + @mock.patch('azure.cognitiveservices.speech.SpeechConfig') + @mock.patch( + 'core.platform.speech_synthesis.' + 'azure_speech_synthesis_services.WordBoundaryCollection' + ) + def test_regenerate_speech_from_text_failed_due_to_multiple_requests( + self, + mock_word_boundary_collection: mock.Mock, + mock_speech_config: mock.Mock, + mock_speech_synthesizer: mock.Mock, + ) -> None: + plaintext = 'This is a test text' + language_accent_code = 'en-US' + mock_audio_data = None + + mock_speech_config_instance = mock_speech_config.return_value + mock_speech_config_instance.set_speech_synthesis_output_format = ( + mock.MagicMock() + ) + mock_speech_synthesizer_instance = mock_speech_synthesizer.return_value + mock_speech_synthesis_result = mock.MagicMock() + mock_speech_synthesis_result.audio_data = mock_audio_data + mock_cancellation_details = mock.MagicMock() + + error_details = ( + 'WebSocket upgrade failed: Too many requests (429). Please check ' + 'subscription information and region name. USP state: Sending. ' + 'Received audio size: 0 bytes' + ) + mock_cancellation_details.reason = speechsdk.CancellationReason.Error + mock_cancellation_details.error_details = error_details + mock_cancellation_details.error_code = ( + speechsdk.CancellationErrorCode.TooManyRequests + ) + + mock_speech_synthesis_result.reason = speechsdk.ResultReason.Canceled + mock_speech_synthesis_result.cancellation_details = ( + mock_cancellation_details + ) + ( + mock_speech_synthesizer_instance.speak_ssml_async.return_value.get.return_value + ) = mock_speech_synthesis_result + mock_word_boundary_instance = mock.MagicMock() + mock_word_boundaries: List[Dict[str, Union[str, float]]] = [] + mock_word_boundary_instance.audio_offset_list = mock_word_boundaries + mock_word_boundary_collection.return_value = mock_word_boundary_instance + + with self.swap_api_key_secrets_return_secret: + result_binary_data, result_audio_offsets, result_error = ( + azure_speech_synthesis_services.regenerate_speech_from_text( + plaintext, language_accent_code + ) + ) + + self.assertEqual(result_binary_data, mock_audio_data) + self.assertEqual(result_audio_offsets, mock_word_boundaries) + self.assertEqual(result_error, error_details) + + @mock.patch('azure.cognitiveservices.speech.SpeechSynthesizer') + @mock.patch('azure.cognitiveservices.speech.SpeechConfig') + @mock.patch( + 'core.platform.speech_synthesis.' + 'azure_speech_synthesis_services.WordBoundaryCollection' + ) + def test_regenerate_speech_from_text_failed_due_to_end_of_stream( + self, + mock_word_boundary_collection: mock.Mock, + mock_speech_config: mock.Mock, + mock_speech_synthesizer: mock.Mock, + ) -> None: + plaintext = 'This is a test text' + language_accent_code = 'en-US' + mock_audio_data = None + + mock_speech_config_instance = mock_speech_config.return_value + mock_speech_config_instance.set_speech_synthesis_output_format = ( + mock.MagicMock() + ) + mock_speech_synthesizer_instance = mock_speech_synthesizer.return_value + mock_speech_synthesis_result = mock.MagicMock() + mock_speech_synthesis_result.audio_data = mock_audio_data + mock_cancellation_details = mock.MagicMock() + + error_details = 'Speech synthesis was canceled for reason: CancellationReason.EndOfStream' + mock_cancellation_details.reason = ( + speechsdk.CancellationReason.EndOfStream + ) + + mock_speech_synthesis_result.reason = speechsdk.ResultReason.Canceled + mock_speech_synthesis_result.cancellation_details = ( + mock_cancellation_details + ) + ( + mock_speech_synthesizer_instance.speak_ssml_async.return_value.get.return_value + ) = mock_speech_synthesis_result + mock_word_boundary_instance = mock.MagicMock() + mock_word_boundaries: List[Dict[str, Union[str, float]]] = [] + mock_word_boundary_instance.audio_offset_list = mock_word_boundaries + mock_word_boundary_collection.return_value = mock_word_boundary_instance + + with self.swap_api_key_secrets_return_secret: + result_binary_data, result_audio_offsets, result_error = ( + azure_speech_synthesis_services.regenerate_speech_from_text( + plaintext, language_accent_code + ) + ) + + self.assertEqual(result_binary_data, mock_audio_data) + self.assertEqual(result_audio_offsets, mock_word_boundaries) + self.assertEqual(result_error, error_details) + + @mock.patch('azure.cognitiveservices.speech.SpeechSynthesizer') + @mock.patch('azure.cognitiveservices.speech.SpeechConfig') + @mock.patch( + 'core.platform.speech_synthesis.' + 'azure_speech_synthesis_services.WordBoundaryCollection' + ) + def test_regenerate_speech_from_text_failed_due_to_unknown_reason( + self, + mock_word_boundary_collection: mock.Mock, + mock_speech_config: mock.Mock, + mock_speech_synthesizer: mock.Mock, + ) -> None: + plaintext = 'This is a test text' + language_accent_code = 'en-US' + mock_audio_data = None + + mock_speech_config_instance = mock_speech_config.return_value + mock_speech_config_instance.set_speech_synthesis_output_format = ( + mock.MagicMock() + ) + mock_speech_synthesizer_instance = mock_speech_synthesizer.return_value + mock_speech_synthesis_result = mock.MagicMock() + mock_speech_synthesis_result.audio_data = mock_audio_data + mock_cancellation_details = mock.MagicMock() + + error_details = ( + 'Speech synthesis failed for reason: UnknownCancellationReason' + ) + mock_cancellation_details.reason = 'UnknownCancellationReason' + + mock_speech_synthesis_result.reason = 'UnknownCancellationReason' + ( + mock_speech_synthesizer_instance.speak_ssml_async.return_value.get.return_value + ) = mock_speech_synthesis_result + mock_word_boundary_instance = mock.MagicMock() + mock_word_boundaries: List[Dict[str, Union[str, float]]] = [] + mock_word_boundary_instance.audio_offset_list = mock_word_boundaries + mock_word_boundary_collection.return_value = mock_word_boundary_instance + + with self.swap_api_key_secrets_return_secret: + result_binary_data, result_audio_offsets, result_error = ( + azure_speech_synthesis_services.regenerate_speech_from_text( + plaintext, language_accent_code + ) + ) + + self.assertEqual(result_binary_data, mock_audio_data) + self.assertEqual(result_audio_offsets, mock_word_boundaries) + self.assertEqual(result_error, error_details) + def test_should_return_word_boundary_collection_correctly(self) -> None: word_boundary_collection = ( azure_speech_synthesis_services.WordBoundaryCollection() @@ -400,6 +562,19 @@ def test_should_convert_plaintext_to_ssml_content_correctly(self) -> None: self._get_ssml_content(expected_main_content, language_accent_code), ) + plaintext = 'Hello - welcome to Oppia!' + expected_main_content = 'Hello - welcome to Oppia!' + + ssml_content = ( + azure_speech_synthesis_services.convert_plaintext_to_ssml_content( + plaintext, language_accent_code + ) + ) + self.assertEqual( + ssml_content, + self._get_ssml_content(expected_main_content, language_accent_code), + ) + plaintext = 'Find the value of 5 + 3.' expected_main_content = ( 'Find the value of 5 plus 3.' @@ -417,8 +592,8 @@ def test_should_convert_plaintext_to_ssml_content_correctly(self) -> None: plaintext = 'Find the value of 15 / 5.' expected_main_content = ( - 'Find the value of 15 divided by' - ' 5.' + 'Find the value of 15divided by' + '5.' ) ssml_content = ( diff --git a/core/platform/speech_synthesis/dev_mode_speech_synthesis_services.py b/core/platform/speech_synthesis/dev_mode_speech_synthesis_services.py index 54678dc9f2da1..7b139e4bcd963 100644 --- a/core/platform/speech_synthesis/dev_mode_speech_synthesis_services.py +++ b/core/platform/speech_synthesis/dev_mode_speech_synthesis_services.py @@ -37,7 +37,7 @@ def regenerate_speech_from_text( - _: str, language_accent_code: str + _: str, language_accent_code: str, _oppia_project_id: Optional[str] = None ) -> Tuple[bytes, List[Dict[str, Union[str, float]]], Optional[str]]: """The method provides mock data to simulate the Azure text-to-speech synthesis service in the development environment. @@ -46,6 +46,9 @@ def regenerate_speech_from_text( _: str. The plaintext that needs to be synthesized into speech. language_accent_code: str. The language accent code in which the speech is to be synthesized. + _oppia_project_id: Optional[str]. The Google Cloud Project ID. + Explicitly required when running on Beam Dataflow, as workers + cannot retrieve the ID from environment variables. Returns: tuple. A tuple containing three elements: diff --git a/core/schema_utils.py b/core/schema_utils.py index 959f2efc49a4c..47ec8f2ed5af7 100644 --- a/core/schema_utils.py +++ b/core/schema_utils.py @@ -114,6 +114,7 @@ def normalize_against_schema( Raises: Exception. The object fails to validate against the schema. AssertionError. The validation for schema validators fails. + InvalidInputException. The schema validators fail. """ # Here we use type Any because 'normalized_obj' can be of type int, str, # Dict, List and other types too. @@ -319,7 +320,7 @@ def normalize_against_schema( not validator_func(normalized_obj, **kwargs) and not expect_invalid_default_value ): - raise AssertionError( + raise utils.InvalidInputException( 'Validation failed: %s (%s) for object %s' % (validator['id'], kwargs, normalized_obj) ) diff --git a/core/schema_utils_test.py b/core/schema_utils_test.py index 7f0ff38723c34..d5282a57dff1e 100644 --- a/core/schema_utils_test.py +++ b/core/schema_utils_test.py @@ -21,7 +21,7 @@ import inspect import re -from core import schema_utils +from core import schema_utils, utils from core.tests import test_utils from typing import Any, Dict, List, Tuple @@ -724,7 +724,10 @@ def test_schemas_are_correctly_validated(self) -> None: # TODO(#13059): Here we use MyPy ignore because after we fully type # the codebase we plan to get rid of the tests that intentionally # test wrong inputs that we can normally catch by typing. - with self.assertRaisesRegex((AssertionError, KeyError), error_msg): + with self.assertRaisesRegex( + (AssertionError, KeyError, utils.InvalidInputException), + error_msg, + ): validate_schema(schemas) # type: ignore[arg-type] def test_normalize_against_schema_raises_exception(self) -> None: @@ -787,6 +790,22 @@ def test_is_valid_algebraic_expression_validator(self) -> None: self.assertTrue(is_valid_algebraic_expression('3+4/2')) self.assertFalse(is_valid_algebraic_expression('3+4/a*')) + def test_normalize_against_schema_raises_invalid_input_exception( + self, + ) -> None: + """Tests normalize_against_schema raises InvalidInputException when + custom validator fails. + """ + schema = { + 'type': 'unicode', + 'validators': [{'id': 'does_not_contain_email'}], + } + with self.assertRaisesRegex( + utils.InvalidInputException, + r'^Validation failed: does_not_contain_email .* email@email.com$', + ): + schema_utils.normalize_against_schema('email@email.com', schema) + def test_is_valid_numeric_expression_validator(self) -> None: """Tests for the is_valid_numeric_expression static method with numeric type. diff --git a/core/storage/cloud_task/gae_models.py b/core/storage/cloud_task/gae_models.py index 8b452a8588d90..c204ee257a15d 100644 --- a/core/storage/cloud_task/gae_models.py +++ b/core/storage/cloud_task/gae_models.py @@ -293,7 +293,7 @@ def get_by_queue_id(cls, queue_id: str) -> list[CloudTaskRunModel]: ) -class VoiceoverRegenerationTaskMappingModel(base_models.BaseModel): +class VoiceoverRegenerationJobModel(base_models.BaseModel): """The model maps an exploration's voiceover regeneration request to its Cloud Task run. @@ -345,9 +345,9 @@ def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]: ) @classmethod - def get_voiceover_regeneration_tasks_by_exploration_id( + def get_all_by_exp_id( cls, exploration_id: str - ) -> List[VoiceoverRegenerationTaskMappingModel]: + ) -> List[VoiceoverRegenerationJobModel]: """The method fetches all voiceover regeneration task requests for the given exploration ID. @@ -355,12 +355,12 @@ def get_voiceover_regeneration_tasks_by_exploration_id( exploration_id: str. The ID of the exploration. Returns: - list(VoiceoverRegenerationTaskMappingModel). A list of - VoiceoverRegenerationTaskMappingModel instances matching the given + list(VoiceoverRegenerationJobModel). A list of + VoiceoverRegenerationJobModel instances matching the given exploration ID. """ return list( - VoiceoverRegenerationTaskMappingModel.query( + VoiceoverRegenerationJobModel.query( datastore_services.all_of( cls.exploration_id == exploration_id, cls.deleted # pylint: disable=singleton-comparison @@ -368,3 +368,159 @@ def get_voiceover_regeneration_tasks_by_exploration_id( ) ).fetch() ) + + +class VoiceoverRegenerationBatchExecutionModel(base_models.BaseModel): + """Voiceover regeneration for a large number of contents within a single + Cloud Task run (deferred request) significantly increases the workload and + may lead to timeout failures due to Gunicorn limitations. + + To mitigate this issue, a single deferred regeneration task is split into + multiple smaller batches, organized in a parent-child relationship between + Cloud Task runs. + + This model stores metadata for each regeneration batch, including: + - The mapping between parent and child Cloud Task runs + - Exploration details + - Content details associated with the regeneration process + + The model key is composed of the parent Cloud Task run ID and the child + Cloud Task run ID, ensuring a unique entry for every parent-child mapping. + """ + + # ID of the parent CloudTaskRunModel. + parent_cloud_task_run_id = datastore_services.StringProperty( + required=True, indexed=True + ) + + # ID of the child CloudTaskRunModel corresponding to a specific batch of + # the regeneration task. + child_cloud_task_run_id = datastore_services.StringProperty( + required=True, indexed=True + ) + + exploration_id = datastore_services.StringProperty( + required=True, indexed=True + ) + + exploration_version = datastore_services.IntegerProperty( + required=True, indexed=False + ) + + language_accent_code = datastore_services.StringProperty( + required=True, indexed=True + ) + + # A dictionary mapping content IDs to their corresponding content string of + # the exploration. + content_ids_to_contents_map = datastore_services.JsonProperty( + required=True, indexed=False + ) + + @staticmethod + def get_deletion_policy() -> base_models.DELETION_POLICY: + """Model doesn't contain any data directly corresponding to a user.""" + return base_models.DELETION_POLICY.NOT_APPLICABLE + + @staticmethod + def get_model_association_to_user() -> ( + base_models.MODEL_ASSOCIATION_TO_USER + ): + """Model does not contain user data.""" + return base_models.MODEL_ASSOCIATION_TO_USER.NOT_CORRESPONDING_TO_USER + + @classmethod + def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]: + """Model doesn't contain any data directly corresponding to a user.""" + return dict( + super(cls, cls).get_export_policy(), + **{ + 'parent_cloud_task_run_id': ( + base_models.EXPORT_POLICY.NOT_APPLICABLE + ), + 'child_cloud_task_run_id': ( + base_models.EXPORT_POLICY.NOT_APPLICABLE + ), + 'exploration_id': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'exploration_version': ( + base_models.EXPORT_POLICY.NOT_APPLICABLE + ), + 'language_accent_code': ( + base_models.EXPORT_POLICY.NOT_APPLICABLE + ), + 'content_ids_to_contents_map': ( + base_models.EXPORT_POLICY.NOT_APPLICABLE + ), + }, + ) + + @classmethod + def get_models_by_parent_id( + cls, parent_cloud_task_run_id: str + ) -> List[VoiceoverRegenerationBatchExecutionModel]: + """The method fetches all model instances corresponding to the given + parent Cloud Task run ID. + + Args: + parent_cloud_task_run_id: str. The ID of the parent Cloud Task run. + + Returns: + list(VoiceoverRegenerationBatchExecutionModel). A list of + VoiceoverRegenerationBatchExecutionModel instances matching the given + parent Cloud Task run ID. + """ + return list( + VoiceoverRegenerationBatchExecutionModel.query( + datastore_services.all_of( + cls.parent_cloud_task_run_id == parent_cloud_task_run_id, + cls.deleted # pylint: disable=singleton-comparison + == False, + ) + ).fetch() + ) + + @classmethod + def create_and_save_model( + cls, + parent_cloud_task_run_id: str, + child_cloud_task_run_id: str, + exploration_id: str, + exploration_version: int, + language_accent_code: str, + content_ids_to_contents_map: Dict[str, str], + ) -> VoiceoverRegenerationBatchExecutionModel: + """Creates a new instance of VoiceoverRegenerationBatchExecutionModel. + + Args: + parent_cloud_task_run_id: str. The ID of the parent Cloud Task run. + child_cloud_task_run_id: str. The ID of the child Cloud Task run + corresponding to a specific batch of the regeneration task. + exploration_id: str. The ID of the exploration for which the + regeneration is being done. + exploration_version: int. The version of the exploration for which + the regeneration is being done. + language_accent_code: str. The language accent code for which the + regeneration is being done. + content_ids_to_contents_map: dict(str, str). A dictionary mapping + content IDs to their corresponding content text associated + with the regeneration process. + + Returns: + VoiceoverRegenerationBatchExecutionModel. The newly created instance of + VoiceoverRegenerationBatchExecutionModel. + """ + model_id = '%s:%s' % (parent_cloud_task_run_id, child_cloud_task_run_id) + model_instance = cls( + id=model_id, + parent_cloud_task_run_id=parent_cloud_task_run_id, + child_cloud_task_run_id=child_cloud_task_run_id, + exploration_id=exploration_id, + exploration_version=exploration_version, + language_accent_code=language_accent_code, + content_ids_to_contents_map=content_ids_to_contents_map, + ) + + model_instance.update_timestamps() + model_instance.put() + + return model_instance diff --git a/core/storage/cloud_task/gae_models_test.py b/core/storage/cloud_task/gae_models_test.py index 5a3aa8564d652..ca33351550704 100644 --- a/core/storage/cloud_task/gae_models_test.py +++ b/core/storage/cloud_task/gae_models_test.py @@ -216,12 +216,12 @@ def test_get_new_id_raises_error_after_too_many_failed_attempts( cloud_task_models.CloudTaskRunModel.get_new_id() -class VoiceoverRegenerationTaskMappingModelUnitTest(test_utils.GenericTestBase): - """Test the VoiceoverRegenerationTaskMappingModel class.""" +class VoiceoverRegenerationJobModelUnitTest(test_utils.GenericTestBase): + """Test the VoiceoverRegenerationJobModel class.""" def test_get_export_policy_not_applicable(self) -> None: self.assertEqual( - cloud_task_models.VoiceoverRegenerationTaskMappingModel.get_export_policy(), + cloud_task_models.VoiceoverRegenerationJobModel.get_export_policy(), { 'exploration_id': base_models.EXPORT_POLICY.NOT_APPLICABLE, 'cloud_task_run_id': base_models.EXPORT_POLICY.NOT_APPLICABLE, @@ -236,18 +236,18 @@ def test_get_model_association_to_user_not_corresponding_to_user( self, ) -> None: self.assertEqual( - cloud_task_models.VoiceoverRegenerationTaskMappingModel.get_model_association_to_user(), + cloud_task_models.VoiceoverRegenerationJobModel.get_model_association_to_user(), base_models.MODEL_ASSOCIATION_TO_USER.NOT_CORRESPONDING_TO_USER, ) def test_get_deletion_policy_not_applicable(self) -> None: self.assertEqual( - cloud_task_models.VoiceoverRegenerationTaskMappingModel.get_deletion_policy(), + cloud_task_models.VoiceoverRegenerationJobModel.get_deletion_policy(), base_models.DELETION_POLICY.NOT_APPLICABLE, ) def test_should_get_models_by_exp_id(self) -> None: - model_1 = cloud_task_models.VoiceoverRegenerationTaskMappingModel( + model_1 = cloud_task_models.VoiceoverRegenerationJobModel( id='exp1:taskrun1', exploration_id='exp1', cloud_task_run_id='taskrun1', @@ -257,7 +257,7 @@ def test_should_get_models_by_exp_id(self) -> None: ) model_1.put() - model_2 = cloud_task_models.VoiceoverRegenerationTaskMappingModel( + model_2 = cloud_task_models.VoiceoverRegenerationJobModel( id='exp1:taskrun2', exploration_id='exp1', cloud_task_run_id='taskrun2', @@ -267,7 +267,7 @@ def test_should_get_models_by_exp_id(self) -> None: ) model_2.put() - model_3 = cloud_task_models.VoiceoverRegenerationTaskMappingModel( + model_3 = cloud_task_models.VoiceoverRegenerationJobModel( id='exp2:taskrun3', exploration_id='exp2', cloud_task_run_id='taskrun3', @@ -277,11 +277,87 @@ def test_should_get_models_by_exp_id(self) -> None: ) model_3.put() - fetched_models = cloud_task_models.VoiceoverRegenerationTaskMappingModel.get_voiceover_regeneration_tasks_by_exploration_id( - 'exp1' + fetched_models = ( + cloud_task_models.VoiceoverRegenerationJobModel.get_all_by_exp_id( + 'exp1' + ) ) self.assertEqual(len(fetched_models), 2) fetched_model_ids = [model.id for model in fetched_models] expected_model_ids = ['exp1:taskrun1', 'exp1:taskrun2'] self.assertItemsEqual(fetched_model_ids, expected_model_ids) + + +class VoiceoverRegenerationBatchExecutionModelTests(test_utils.GenericTestBase): + """Test the VoiceoverRegenerationBatchExecutionModel class.""" + + def test_get_export_policy_not_applicable(self) -> None: + self.assertEqual( + cloud_task_models.VoiceoverRegenerationBatchExecutionModel.get_export_policy(), + { + 'parent_cloud_task_run_id': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'child_cloud_task_run_id': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'exploration_id': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'exploration_version': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'language_accent_code': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'content_ids_to_contents_map': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'created_on': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'deleted': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'last_updated': base_models.EXPORT_POLICY.NOT_APPLICABLE, + }, + ) + + def test_get_model_association_to_user_not_corresponding_to_user( + self, + ) -> None: + self.assertEqual( + cloud_task_models.VoiceoverRegenerationBatchExecutionModel.get_model_association_to_user(), + base_models.MODEL_ASSOCIATION_TO_USER.NOT_CORRESPONDING_TO_USER, + ) + + def test_get_deletion_policy_not_applicable(self) -> None: + self.assertEqual( + cloud_task_models.VoiceoverRegenerationBatchExecutionModel.get_deletion_policy(), + base_models.DELETION_POLICY.NOT_APPLICABLE, + ) + + def test_should_get_models_by_parent_cloud_task_run_id(self) -> None: + cloud_task_models.VoiceoverRegenerationBatchExecutionModel.create_and_save_model( + parent_cloud_task_run_id='taskrun1', + child_cloud_task_run_id='childtaskrun1', + exploration_id='exp1', + exploration_version=1, + language_accent_code='en-US', + content_ids_to_contents_map={'content_0': 'Hello world!'}, + ) + + cloud_task_models.VoiceoverRegenerationBatchExecutionModel.create_and_save_model( + parent_cloud_task_run_id='taskrun1', + child_cloud_task_run_id='childtaskrun2', + exploration_id='exp1', + exploration_version=1, + language_accent_code='en-US', + content_ids_to_contents_map={'content_1': 'Congratulations!'}, + ) + + cloud_task_models.VoiceoverRegenerationBatchExecutionModel.create_and_save_model( + parent_cloud_task_run_id='taskrun2', + child_cloud_task_run_id='childtaskrun3', + exploration_id='exp1', + exploration_version=1, + language_accent_code='en-US', + content_ids_to_contents_map={'content_2': 'Hello learners!'}, + ) + + fetched_models = cloud_task_models.VoiceoverRegenerationBatchExecutionModel.get_models_by_parent_id( + 'taskrun1' + ) + + self.assertEqual(len(fetched_models), 2) + fetched_model_ids = [model.id for model in fetched_models] + expected_model_ids = [ + 'taskrun1:childtaskrun1', + 'taskrun1:childtaskrun2', + ] + self.assertItemsEqual(fetched_model_ids, expected_model_ids) diff --git a/core/storage/opportunity/gae_models.py b/core/storage/opportunity/gae_models.py index 57f6e9e5318ed..d0f3faf32903d 100644 --- a/core/storage/opportunity/gae_models.py +++ b/core/storage/opportunity/gae_models.py @@ -59,6 +59,11 @@ class ExplorationOpportunitySummaryModel(base_models.BaseModel): language_codes_needing_voice_artists = datastore_services.StringProperty( repeated=True, indexed=True ) + # The number of content items that are only translatable by reviewers + # (e.g. content with 'set_of_strings' data format). + reviewer_only_content_count = datastore_services.IntegerProperty( + required=True, default=0, indexed=False + ) @staticmethod def get_deletion_policy() -> base_models.DELETION_POLICY: @@ -88,6 +93,7 @@ def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]: 'translation_counts': base_models.EXPORT_POLICY.NOT_APPLICABLE, 'language_codes_with_assigned_voice_artists': base_models.EXPORT_POLICY.NOT_APPLICABLE, 'language_codes_needing_voice_artists': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'reviewer_only_content_count': base_models.EXPORT_POLICY.NOT_APPLICABLE, }, ) @@ -407,3 +413,90 @@ def create_new( ), translation_counts=translation_counts, ) + + +class ExplorationOpportunitySummaryAuditModel(base_models.BaseModel): + """Audit model for tracking changes to the translation counts in + ExplorationOpportunitySummaryModel. + """ + + exploration_id = datastore_services.StringProperty( + required=True, indexed=True + ) + language_code = datastore_services.StringProperty( + required=True, indexed=True + ) + action = datastore_services.StringProperty(required=True, indexed=True) + old_translation_count = datastore_services.IntegerProperty( + required=True, indexed=False + ) + new_translation_count = datastore_services.IntegerProperty( + required=True, indexed=False + ) + content_count = datastore_services.IntegerProperty( + required=True, indexed=False + ) + + @staticmethod + def get_deletion_policy() -> base_models.DELETION_POLICY: + """Model doesn't contain any data directly corresponding to a user.""" + return base_models.DELETION_POLICY.NOT_APPLICABLE + + @staticmethod + def get_model_association_to_user() -> ( + base_models.MODEL_ASSOCIATION_TO_USER + ): + """Model does not contain user data.""" + return base_models.MODEL_ASSOCIATION_TO_USER.NOT_CORRESPONDING_TO_USER + + @classmethod + def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]: + """Model doesn't contain any data directly corresponding to a user.""" + return dict( + super(cls, cls).get_export_policy(), + **{ + 'exploration_id': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'language_code': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'action': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'old_translation_count': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'new_translation_count': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'content_count': base_models.EXPORT_POLICY.NOT_APPLICABLE, + }, + ) + + @classmethod + def create_new( + cls, + exploration_id: str, + language_code: str, + action: str, + old_translation_count: int, + new_translation_count: int, + content_count: int, + ) -> 'ExplorationOpportunitySummaryAuditModel': + """Creates and returns a new ExplorationOpportunitySummaryAuditModel + instance. + + Args: + exploration_id: str. The ID of the exploration. + language_code: str. The language code. + action: str. The action that caused the count change. + old_translation_count: int. Previous translation count. + new_translation_count: int. New translation count. + content_count: int. Total content count. + + Returns: + ExplorationOpportunitySummaryAuditModel. A new model instance. + """ + model_id = cls.get_new_id('') + model = cls( + id=model_id, + exploration_id=exploration_id, + language_code=language_code, + action=action, + old_translation_count=old_translation_count, + new_translation_count=new_translation_count, + content_count=content_count, + ) + model.update_timestamps() + return model diff --git a/core/storage/opportunity/gae_models_test.py b/core/storage/opportunity/gae_models_test.py index 08e68ebb8652c..8b1ff49f121e9 100644 --- a/core/storage/opportunity/gae_models_test.py +++ b/core/storage/opportunity/gae_models_test.py @@ -142,6 +142,7 @@ def test_get_export_policy(self) -> None: 'translation_counts': base_models.EXPORT_POLICY.NOT_APPLICABLE, 'language_codes_with_assigned_voice_artists': base_models.EXPORT_POLICY.NOT_APPLICABLE, 'language_codes_needing_voice_artists': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'reviewer_only_content_count': base_models.EXPORT_POLICY.NOT_APPLICABLE, } self.assertEqual( opportunity_models.ExplorationOpportunitySummaryModel.get_export_policy(), diff --git a/core/storage/voiceover/gae_models.py b/core/storage/voiceover/gae_models.py index 0ffaa5aea1024..62429092c8576 100644 --- a/core/storage/voiceover/gae_models.py +++ b/core/storage/voiceover/gae_models.py @@ -43,7 +43,6 @@ VOICEOVER_AUTOGENERATION_POLICY_ID: Final = 'voiceover_policy' - assert feconf.VoiceoverType.MANUAL.value == 'manual' assert feconf.VoiceoverType.AUTO.value == 'auto' @@ -310,7 +309,7 @@ class CachedAutomaticVoiceoversModel(base_models.BaseModel): # voiceovers. hash_code = datastore_services.StringProperty(required=True, indexed=True) # The plaintext linked to the stored voiceover. - plaintext = datastore_services.StringProperty(required=True) + plaintext = datastore_services.TextProperty(required=True) # The filename of the stored voiceover, saved either in Google Cloud for # production or in Datastore for development. voiceover_filename = datastore_services.StringProperty(required=True) diff --git a/core/storage/voiceover/gae_models_test.py b/core/storage/voiceover/gae_models_test.py index d8f5b875738b9..f21f31a2b9f19 100644 --- a/core/storage/voiceover/gae_models_test.py +++ b/core/storage/voiceover/gae_models_test.py @@ -22,6 +22,8 @@ from core.platform import models from core.tests import test_utils +from typing import Dict, List, Union + MYPY = False if MYPY: # pragma: no cover # Here, 'state_domain' is imported only for type checking. @@ -283,7 +285,7 @@ def test_create_and_get_cache_model_successfully(self) -> None: plaintext ) voiceover_filename = 'en-IN-content_1-qwerty.mp3' - audio_offset_list = [ + audio_offset_list: List[Dict[str, Union[str, float]]] = [ {'token': 'This', 'audio_offset_msecs': 0.0}, {'token': 'is', 'audio_offset_msecs': 100.0}, {'token': 'a', 'audio_offset_msecs': 200.0}, @@ -292,20 +294,14 @@ def test_create_and_get_cache_model_successfully(self) -> None: {'token': '!', 'audio_offset_msecs': 450.0}, ] - cached_model_id = ( - voiceover_models.CachedAutomaticVoiceoversModel.generate_id( - language_accent_code, hash_code, provider + cached_model = ( + voiceover_models.CachedAutomaticVoiceoversModel.create_cache_model( + language_accent_code=language_accent_code, + plaintext=plaintext, + voiceover_filename=voiceover_filename, + audio_offset_list=audio_offset_list, ) ) - cached_model = voiceover_models.CachedAutomaticVoiceoversModel( - id=cached_model_id, - language_accent_code=language_accent_code, - provider=provider, - hash_code=hash_code, - plaintext=plaintext, - voiceover_filename=voiceover_filename, - audio_offset_list=audio_offset_list, - ) cached_model.update_timestamps() cached_model.put() diff --git a/core/templates/app.constants.ts b/core/templates/app.constants.ts index 015504a2775a8..d8ac8a347a7cb 100644 --- a/core/templates/app.constants.ts +++ b/core/templates/app.constants.ts @@ -423,6 +423,20 @@ export const AppConstants = { }, ONE_WEEK_IN_MILLIS: 7 * 24 * 60 * 60 * 1000, ONE_MONTH_IN_MILLIS: 30 * 24 * 60 * 60 * 1000, + + FINANCIAL_LITERACY_CAMPAIGN_CONFIG_TEST: { + bannerImageRelativePath: '/donate/financial-literacy-campaign.webp', + startDate: new Date('2026-03-01'), + endDate: new Date('2026-04-30'), + bannerReRenderIntervalMs: 30 * 1000, + }, + + FINANCIAL_LITERACY_CAMPAIGN_CONFIG_PROD: { + bannerImageRelativePath: '/donate/financial-literacy-campaign.webp', + startDate: new Date('2026-04-07'), + endDate: new Date('2026-04-30'), + bannerReRenderIntervalMs: 7 * 24 * 60 * 60 * 1000, + }, } as const; export enum NavbarAndFooterGATrackingPages { diff --git a/core/templates/pages/lightweight-oppia-root/lightweight-oppia-root.component.ts b/core/templates/components/campaign-banner/campaign-banner-module.ts similarity index 57% rename from core/templates/pages/lightweight-oppia-root/lightweight-oppia-root.component.ts rename to core/templates/components/campaign-banner/campaign-banner-module.ts index 006102296fcf4..3998dfd1ea1a5 100644 --- a/core/templates/pages/lightweight-oppia-root/lightweight-oppia-root.component.ts +++ b/core/templates/components/campaign-banner/campaign-banner-module.ts @@ -1,4 +1,4 @@ -// Copyright 2021 The Oppia Authors. All Rights Reserved. +// Copyright 2026 The Oppia Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -13,13 +13,16 @@ // limitations under the License. /** - * @fileoverview Oppia root component. + * @fileoverview Module for campaign banner. */ -import {Component} from '@angular/core'; +import {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {CampaignBannerComponent} from './campaign-banner.component'; -@Component({ - selector: 'lightweight-oppia-root', - templateUrl: './lightweight-oppia-root.component.html', +@NgModule({ + declarations: [CampaignBannerComponent], + imports: [CommonModule], + exports: [CampaignBannerComponent], }) -export class LightweightOppiaRootComponent {} +export class CampaignBannerModule {} diff --git a/core/templates/components/campaign-banner/campaign-banner.component.css b/core/templates/components/campaign-banner/campaign-banner.component.css new file mode 100644 index 0000000000000..ae610f6da58b1 --- /dev/null +++ b/core/templates/components/campaign-banner/campaign-banner.component.css @@ -0,0 +1,209 @@ +.oppia-campaign-overlay { + align-items: center; + background: rgba(0, 0, 0, 0.55); + display: flex; + font-family: 'capriola', sans-serif; + height: 100%; + justify-content: center; + left: 0; + position: fixed; + top: 0; + width: 100%; + z-index: 5000; +} + +.oppia-campaign-modal { + background: white; + border-radius: 10px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25); + max-height: 450px; + position: relative; +} + +.oppia-campaign-close { + background: transparent; + border: none; + cursor: pointer; + font-size: 22px; + position: absolute; + right: 14px; + top: 12px; +} + +.oppia-campaign-content { + display: flex; + flex-direction: row; +} + +.oppia-campaign-left { + flex: 1; + padding: 30px; + width: 650px; +} + +.oppia-campaign-left-containt { + height: 414px; + left: 43px; + top: 18px; + width: 565px; +} + +.oppia-campaign-right { + overflow: hidden; + width: 350px; +} + +.oppia-campaign-image { + border-bottom-left-radius: 0; + border-bottom-right-radius: 10px; + border-top-left-radius: 100px; + border-top-right-radius: 10px; + max-height: 450px; + object-fit: cover; + width: 100%; +} + +.oppia-campaign-title { + font-size: 28px; + font-style: 'normal'; + margin: 0; +} + +.highlight { + color: #009688; +} + +.oppia-campaign-description { + font-size: 16px; + padding: 25px 32px; +} + +.oppia-campaign-description > p { + font-family: 'Roboto', sans-serif; +} + +.oppia-campaign-impact { + align-items: center; + background: #12978c0d; + border-radius: 10px; + box-shadow: -8px -3px 10px rgba(0, 0, 0, 0.1); + display: flex; + height: 50px; + justify-content: center; + margin: 15px auto; + padding: 10px; + width: 323px; +} + +.oppia-impact-icon { + color: #ad8e1dcc; + font-size: 28px; + transform: rotate(-12deg); +} + +.oppia-campaign-subtext { + font-size: 16px; + margin-bottom: 15px; +} + +.oppia-donate-btn { + background: #00645c; + border-radius: 6px; + color: white; + height: 48px; + padding: 10px 18px; + text-align: center; + text-decoration: none; + width: 262px; +} + +.oppia-donate-btn:hover { + background: #00544d; + color: white; +} + +.oppia-campaign-end { + color: #f4a33b; + font-family: 'Roboto-bold', sans-serif; + font-size: 12px; + font-weight: 700; + margin-top: 8px; +} + +@media (max-width: 768px) { + .oppia-campaign-modal { + border-radius: 8px; + margin: 0 10px; + max-height: 90%; + max-width: 95%; + overflow-y: auto; + } + + .oppia-campaign-content { + flex-direction: column; + } + + .oppia-campaign-left { + padding: 20px; + width: 100%; + } + + .oppia-campaign-left-containt { + height: auto; + left: auto; + top: auto; + width: 100%; + } + + .oppia-campaign-right { + margin-top: 20px; + width: 100%; + } + + .oppia-campaign-image { + border-bottom-left-radius: 0; + border-bottom-right-radius: 8px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + max-height: 250px; + object-fit: cover; + } + + .oppia-campaign-title { + font-size: 20px; + margin: 5px; + } + + .oppia-campaign-description { + font-size: 14px; + padding: 15px 10px; + } + + .oppia-campaign-subtext { + font-size: 14px; + } + + .oppia-donate-btn { + color: white; + font-size: 16px; + padding: 10px; + width: 100%; + } + + .oppia-campaign-end { + font-size: 12px; + } + + .oppia-campaign-impact { + flex-direction: column; + height: auto; + margin: 10px 0; + padding: 8px; + width: 100%; + } + + .oppia-impact-icon { + font-size: 24px; + margin-bottom: 5px; + } +} diff --git a/core/templates/components/campaign-banner/campaign-banner.component.html b/core/templates/components/campaign-banner/campaign-banner.component.html new file mode 100644 index 0000000000000..f0ed0b42ecc14 --- /dev/null +++ b/core/templates/components/campaign-banner/campaign-banner.component.html @@ -0,0 +1,56 @@ +
+ +
+ + + +
+ +
+
+

+ Most children graduate school without ever learning + how money works. +

+ +
+

+ This {{ campaignEndMonth }}, Oppia is launching free, interactive financial literacy + lessons for learners who need them most. Your donation helps us + reach more of them. +

+ +
+ +
+ 50%+ +
Improvement in Learning Outcome
+
+
+ +

+ Your donation helps us reach more learners and empower the next + generation with essential financial skills. +

+
+
+ + Donate Now + + +
+ Campaign ends {{ campaignEndMonth }} {{ campaignEndDay }} +
+
+
+
+
+ Campaign image +
+
+
+
diff --git a/core/templates/components/campaign-banner/campaign-banner.component.spec.ts b/core/templates/components/campaign-banner/campaign-banner.component.spec.ts new file mode 100644 index 0000000000000..335bb4281c04f --- /dev/null +++ b/core/templates/components/campaign-banner/campaign-banner.component.spec.ts @@ -0,0 +1,323 @@ +// Copyright 2026 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Unit tests for CampaignBannerComponent. + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {HttpClientTestingModule} from '@angular/common/http/testing'; + +import {CampaignBannerComponent} from './campaign-banner.component'; +import {UrlInterpolationService} from 'domain/utilities/url-interpolation.service'; +import {PlatformFeatureService} from 'services/platform-feature.service'; +import {AppConstants} from 'app.constants'; +import {SiteAnalyticsService} from 'services/site-analytics.service'; +import {WindowRef} from 'services/contextual/window-ref.service'; + +interface CampaignConfig { + startDate: Date; + endDate: Date; + bannerReRenderIntervalMs: number; + bannerImageRelativePath: string; +} + +class MockUrlInterpolationService { + getStaticImageUrl(imagePath: string): string { + return `/assets/${imagePath}`; + } +} + +class MockPlatformFeatureService { + status = { + EnableCampaignBanner: { + isEnabled: true, + }, + EnableCampaignBannerTestMode: { + isEnabled: false, + }, + }; +} + +class MockWindowRef { + nativeWindow = { + location: { + pathname: '/learn/math', + href: '', + }, + gtag: () => {}, + }; +} + +class MockSiteAnalyticsService { + registerCampaignBannerDonateButtonClick(): void {} + registerCampaignBannerVisibility(): void {} +} + +describe('CampaignBannerComponent', () => { + let component: CampaignBannerComponent; + let fixture: ComponentFixture; + let platformFeatureService: MockPlatformFeatureService; + let siteAnalyticsService: SiteAnalyticsService; + let mockWindowRef: MockWindowRef; + + beforeEach(async () => { + mockWindowRef = new MockWindowRef(); + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + declarations: [CampaignBannerComponent], + providers: [ + { + provide: UrlInterpolationService, + useClass: MockUrlInterpolationService, + }, + { + provide: PlatformFeatureService, + useClass: MockPlatformFeatureService, + }, + { + provide: WindowRef, + useValue: mockWindowRef, + }, + { + provide: SiteAnalyticsService, + useClass: MockSiteAnalyticsService, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CampaignBannerComponent); + component = fixture.componentInstance; + + platformFeatureService = TestBed.inject( + PlatformFeatureService + ) as unknown as MockPlatformFeatureService; + siteAnalyticsService = TestBed.inject(SiteAnalyticsService); + spyOn(localStorage, 'getItem').and.callFake((key: string) => { + if (key === 'lang') { + return 'en'; + } + return null; + }); + + fixture.detectChanges(); + }); + + it('should create component', () => { + expect(component).toBeTruthy(); + }); + + it('should check if language is English correctly', () => { + (localStorage.getItem as jasmine.Spy).and.returnValue('en'); + expect(component.isLanguageEnglish()).toBe(true); + + (localStorage.getItem as jasmine.Spy).and.returnValue('hi'); + expect(component.isLanguageEnglish()).toBe(false); + }); + + it('should return static image url correctly', () => { + expect(component.getStaticImageUrl('test.webp')).toBe('/assets/test.webp'); + }); + + it('should set campaign end text correctly', () => { + component.setCampaignEndText(); + expect(component.campaignEndMonth).toBeDefined(); + expect(component.campaignEndDay).toBeDefined(); + }); + + it('should show banner when campaign active and lang is English', () => { + platformFeatureService.status.EnableCampaignBanner.isEnabled = true; + + component.setCampaignConfig(); + + const config = component.campaignConfig as CampaignConfig; + config.startDate = new Date(Date.now() - 100000); + config.endDate = new Date(Date.now() + 100000); + + component.computeBannerVisibility(); + + expect(component.shouldShowBanner).toBe(true); + }); + + it('should hide banner if language is not English', () => { + (localStorage.getItem as jasmine.Spy).and.returnValue('pt'); + + component.setCampaignConfig(); + component.computeBannerVisibility(); + + expect(component.shouldShowBanner).toBe(false); + }); + + it('should hide banner if recently closed', () => { + const now = Date.now(); + + (localStorage.getItem as jasmine.Spy).and.callFake((key: string) => { + if (key === 'lang') { + return 'en'; + } + if (key === 'campaignBannerClosedAt') { + return now.toString(); + } + return null; + }); + + component.setCampaignConfig(); + component.bannerReRenderInterval = 100000; + + component.computeBannerVisibility(); + expect(component.shouldShowBanner).toBe(false); + }); + + it('should show banner if closed long ago', () => { + const oldTime = Date.now() - 99999999; + (localStorage.getItem as jasmine.Spy).and.callFake((key: string) => { + if (key === 'lang') { + return 'en'; + } + if (key === 'campaignBannerClosedAt') { + return oldTime.toString(); + } + return null; + }); + component.setCampaignConfig(); + const config = component.campaignConfig as CampaignConfig; + config.startDate = new Date(Date.now() - 100000); + config.endDate = new Date(Date.now() + 100000); + + component.bannerReRenderInterval = 1000; + + component.computeBannerVisibility(); + + expect(component.shouldShowBanner).toBe(true); + }); + + it('should hide banner if feature flag is disabled', () => { + platformFeatureService.status.EnableCampaignBanner.isEnabled = false; + component.setCampaignConfig(); + component.computeBannerVisibility(); + expect(component.shouldShowBanner).toBe(false); + }); + + it('should hide banner if campaign is not active', () => { + component.setCampaignConfig(); + + const config = component.campaignConfig as CampaignConfig; + + config.startDate = new Date(Date.now() - 200000); + config.endDate = new Date(Date.now() - 100000); + + component.computeBannerVisibility(); + expect(component.shouldShowBanner).toBe(false); + }); + + it('should close banner and store timestamp', () => { + const setItemSpy = spyOn(localStorage, 'setItem'); + const computeVisibilitySpy = spyOn(component, 'computeBannerVisibility'); + + component.closeBanner(); + + expect(setItemSpy).toHaveBeenCalledWith( + 'campaignBannerClosedAt', + jasmine.any(String) + ); + + expect(computeVisibilitySpy).toHaveBeenCalled(); + }); + + it('should initialize campaign config on init', () => { + spyOn(component, 'setCampaignConfig').and.callThrough(); + + component.ngOnInit(); + + expect(component.setCampaignConfig).toHaveBeenCalled(); + + expect(component.campaignBannerImagePath).toBe( + AppConstants.FINANCIAL_LITERACY_CAMPAIGN_CONFIG_PROD + .bannerImageRelativePath + ); + + expect(component.bannerReRenderInterval).toBe( + AppConstants.FINANCIAL_LITERACY_CAMPAIGN_CONFIG_PROD + .bannerReRenderIntervalMs + ); + }); + + it('should set campaign config to PROD when prod flag enabled', () => { + platformFeatureService.status.EnableCampaignBanner.isEnabled = true; + platformFeatureService.status.EnableCampaignBannerTestMode.isEnabled = + false; + + component.setCampaignConfig(); + + expect(component.campaignConfig).toEqual( + AppConstants.FINANCIAL_LITERACY_CAMPAIGN_CONFIG_PROD + ); + }); + + it('should set campaign config to TEST when prod flag disabled', () => { + platformFeatureService.status.EnableCampaignBanner.isEnabled = false; + + component.setCampaignConfig(); + + expect(component.campaignConfig).toEqual( + AppConstants.FINANCIAL_LITERACY_CAMPAIGN_CONFIG_TEST + ); + }); + + it('should show banner when test mode flag enabled', () => { + platformFeatureService.status.EnableCampaignBanner.isEnabled = false; + platformFeatureService.status.EnableCampaignBannerTestMode.isEnabled = true; + + component.setCampaignConfig(); + component.computeBannerVisibility(); + + expect(component.shouldShowBanner).toBe(true); + }); + it('should navigate to donate page and register analytics event', () => { + spyOn(siteAnalyticsService, 'registerCampaignBannerDonateButtonClick'); + expect(mockWindowRef.nativeWindow.location.href).toBe(''); + + component.navigateToDonatePage(); + + expect( + siteAnalyticsService.registerCampaignBannerDonateButtonClick + ).toHaveBeenCalled(); + + expect(mockWindowRef.nativeWindow.location.href).toBe('/donate'); + }); + it('should call registerBannerVisibility when banner is visible', () => { + spyOn(siteAnalyticsService, 'registerCampaignBannerVisibility'); + + platformFeatureService.status.EnableCampaignBanner.isEnabled = true; + (localStorage.getItem as jasmine.Spy).and.callFake((key: string) => { + if (key === 'lang') { + return 'en'; + } + return null; + }); + + component.setCampaignConfig(); + + const config = component.campaignConfig as CampaignConfig; + config.startDate = new Date(Date.now() - 100000); + config.endDate = new Date(Date.now() + 100000); + + component.computeBannerVisibility(); + + expect(component.shouldShowBanner).toBe(true); + expect( + siteAnalyticsService.registerCampaignBannerVisibility + ).toHaveBeenCalled(); + }); +}); diff --git a/core/templates/components/campaign-banner/campaign-banner.component.ts b/core/templates/components/campaign-banner/campaign-banner.component.ts new file mode 100644 index 0000000000000..53edeed884f8b --- /dev/null +++ b/core/templates/components/campaign-banner/campaign-banner.component.ts @@ -0,0 +1,136 @@ +// Copyright 2026 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Component for the financial campaign banner displayed on the top of the page during + */ + +import {Component, OnInit} from '@angular/core'; +import './campaign-banner.component.css'; +import {UrlInterpolationService} from 'domain/utilities/url-interpolation.service'; +import {PlatformFeatureService} from 'services/platform-feature.service'; +import {SiteAnalyticsService} from 'services/site-analytics.service'; +import {AppConstants} from 'app.constants'; +import {WindowRef} from 'services/contextual/window-ref.service'; + +@Component({ + selector: 'campaign-banner', + templateUrl: './campaign-banner.component.html', + styleUrls: ['./campaign-banner.component.css'], +}) +export class CampaignBannerComponent implements OnInit { + constructor( + private windowRef: WindowRef, + private platformFeaturesService: PlatformFeatureService, + private urlInterpolationService: UrlInterpolationService, + private siteAnalyticsService: SiteAnalyticsService + ) {} + + shouldShowBanner = false; + + STORAGE_KEY = 'campaignBannerClosedAt'; + LANGUAGE_CODE_KEY = 'lang'; + + campaignEndMonth!: string; + campaignEndDay!: string; + campaignBannerImagePath!: string; + bannerReRenderInterval!: number; + + campaignConfig!: typeof AppConstants.FINANCIAL_LITERACY_CAMPAIGN_CONFIG_TEST; + + ngOnInit(): void { + this.setCampaignConfig(); + this.initializeCampaignConfig(); + this.setCampaignEndText(); + this.computeBannerVisibility(); + } + setCampaignConfig(): void { + const isProdMode = + this.platformFeaturesService.status.EnableCampaignBanner.isEnabled; + this.campaignConfig = isProdMode + ? AppConstants.FINANCIAL_LITERACY_CAMPAIGN_CONFIG_PROD + : AppConstants.FINANCIAL_LITERACY_CAMPAIGN_CONFIG_TEST; + } + + private initializeCampaignConfig(): void { + this.bannerReRenderInterval = this.campaignConfig.bannerReRenderIntervalMs; + + this.campaignBannerImagePath = this.campaignConfig.bannerImageRelativePath; + } + + computeBannerVisibility(): void { + const featureEnabled = + this.platformFeaturesService.status.EnableCampaignBanner.isEnabled || + this.platformFeaturesService.status.EnableCampaignBannerTestMode + .isEnabled; + + const active = this.isCampaignActive(); + + const closedAt = localStorage.getItem(this.STORAGE_KEY); + let recentlyClosed = false; + if (closedAt) { + const timeSinceClosed = Date.now() - Number(closedAt); + recentlyClosed = timeSinceClosed < this.bannerReRenderInterval; + } + + this.shouldShowBanner = + featureEnabled && this.isLanguageEnglish() && active && !recentlyClosed; + + if (this.shouldShowBanner) { + this.registerBannerVisibility(); + } + } + + isCampaignActive(): boolean { + const now = new Date(); + + return ( + now >= this.campaignConfig.startDate && now <= this.campaignConfig.endDate + ); + } + + setCampaignEndText(): void { + const endDate = this.campaignConfig.endDate; + + this.campaignEndMonth = endDate.toLocaleDateString('en-US', { + month: 'long', + }); + + this.campaignEndDay = endDate.toLocaleDateString('en-US', {day: 'numeric'}); + } + + getStaticImageUrl(imagePath: string): string { + return this.urlInterpolationService.getStaticImageUrl(imagePath); + } + + closeBanner(): void { + localStorage.setItem(this.STORAGE_KEY, Date.now().toString()); + this.computeBannerVisibility(); + } + + navigateToDonatePage(): void { + this.siteAnalyticsService.registerCampaignBannerDonateButtonClick(); + this.windowRef.nativeWindow.location.href = '/donate'; + this.closeBanner(); + } + + isLanguageEnglish(): boolean { + const languageCode = localStorage.getItem(this.LANGUAGE_CODE_KEY); + return languageCode === 'en'; + } + + registerBannerVisibility(): void { + this.siteAnalyticsService.registerCampaignBannerVisibility(); + } +} diff --git a/core/templates/components/ck-editor-helpers/ck-editor-4-rte.component.ts b/core/templates/components/ck-editor-helpers/ck-editor-4-rte.component.ts index 4b10d8fbcfdcb..ca5df9a3177fa 100644 --- a/core/templates/components/ck-editor-helpers/ck-editor-4-rte.component.ts +++ b/core/templates/components/ck-editor-helpers/ck-editor-4-rte.component.ts @@ -416,8 +416,7 @@ export class CkEditor4RteComponent forcePasteAsPlainText: true, sharedSpaces: sharedSpaces, skin: - 'bootstrapck,' + - '/third_party/static/ckeditor-bootstrapck-1.0.0/skins/bootstrapck/', + 'bootstrapck,' + '/third_party/ckeditor-bootstrapck/skins/bootstrapck/', toolbar: [ { name: 'basicstyles', @@ -629,7 +628,7 @@ export class CkEditor4RteComponent // Add external plugins. CKEDITOR.plugins.addExternal( 'sharedspace', - '/third_party/static/ckeditor-4.12.1/plugins/sharedspace/', + '/third_party/ckeditor/plugins/sharedspace/', 'plugin.js' ); // Pre plugin is not available for 4.12.1 version of CKEditor. This is diff --git a/core/templates/components/common-layout-directives/navigation-bars/side-navigation-bar.component.html b/core/templates/components/common-layout-directives/navigation-bars/side-navigation-bar.component.html index 7855153eb7923..925549d84f001 100644 --- a/core/templates/components/common-layout-directives/navigation-bars/side-navigation-bar.component.html +++ b/core/templates/components/common-layout-directives/navigation-bars/side-navigation-bar.component.html @@ -136,7 +136,7 @@ width: 22px; } .oppia-sidebar-submenu-text { - color: #767676; + color: #595959; font-family: "Roboto", Arial, sans-serif; font-size: 14px; margin-left: 26px; @@ -149,7 +149,7 @@ transform: rotate(-180deg); } .oppia-sidebar-learn-subtext { - color: #767676; + color: #595959; font-family: "Roboto", Arial, sans-serif; margin-top: 2px; width: 85%; @@ -197,19 +197,19 @@ color: #00645c; } i.partnerships-icon { - color: #f2994a; + color: #c76a14; font-size: 18px; } i.volunteer-icon { - color: #2d9cdb; + color: #0070b8; font-size: 16px; } i.donate-icon { - color: #eb5757; + color: #d32f2f; font-size: 18px; } i.contact-icon { - color: #b4bbc4; + color: #757575; font-size: 18px; } i.launch-icon { diff --git a/core/templates/components/common-layout-directives/navigation-bars/top-navigation-bar.component.css b/core/templates/components/common-layout-directives/navigation-bars/top-navigation-bar.component.css index e291080e39f0f..9452914fa3f56 100644 --- a/core/templates/components/common-layout-directives/navigation-bars/top-navigation-bar.component.css +++ b/core/templates/components/common-layout-directives/navigation-bars/top-navigation-bar.component.css @@ -66,19 +66,19 @@ padding: 0; } .oppia-top-navigation-bar .get-involved-dropdown .language { - color: #f2994a; + color: #c76a14; font-size: 18px; } .oppia-top-navigation-bar .get-involved-dropdown .volunteer { - color: #2d9cdb; + color: #0070b8; font-size: 18px; } .oppia-top-navigation-bar .get-involved-dropdown .contact { - color: #b4bbc3; + color: #757575; font-size: 18px; } .oppia-top-navigation-bar .get-involved-dropdown .fav { - color: #eb5757; + color: #d32f2f; font-size: 18px; } .oppia-top-navigation-bar .get-involved-dropdown .des { @@ -229,7 +229,7 @@ } .oppia-top-navigation-bar .about-dropdown-menu .about-link:hover, .oppia-top-navigation-bar .about-dropdown-menu .about-link:focus { - color: #009688 !important; + color: #00645c !important; } .oppia-top-navigation-bar .nav-item-left-head { color: #333; @@ -239,7 +239,7 @@ line-height: 18.75px; } .oppia-top-navigation-bar .learn-dropdown-menu .nav-content { - color: #767676; + color: #555; font-family: 'Roboto', sans-serif; font-size: 14px; font-weight: 400; @@ -383,7 +383,7 @@ margin-top: -5px; } .oppia-top-navigation-bar .oppia-launch-icon { - color: #009688; + color: #00645c; font-size: 22px; } .oppia-top-navigation-bar .oppia-user-avatar-icon { @@ -416,10 +416,10 @@ } .oppia-top-navigation-bar .oppia-navbar-dropdown .nav-link { - color: #009688; + color: #00645c; } .oppia-top-navigation-bar .oppia-navbar-dropdown .nav-link:hover { - color: #888; + color: #333; } .oppia-top-navigation-bar .oppia-signin-text { @@ -477,10 +477,9 @@ padding-right: 6px; } .oppia-top-navigation-bar .oppia-navbar-tab-content-desc { - color: #767676; + color: #555; line-height: 20px; margin-left: 28px; - opacity: 0.9; } .oppia-top-navigation-bar .oppia-navbar-dropdown-right { left: 0; diff --git a/core/templates/components/common-layout-directives/navigation-bars/top-navigation-bar.component.html b/core/templates/components/common-layout-directives/navigation-bars/top-navigation-bar.component.html index 69f979568a4a3..a8b7e085dc19f 100644 --- a/core/templates/components/common-layout-directives/navigation-bars/top-navigation-bar.component.html +++ b/core/templates/components/common-layout-directives/navigation-bars/top-navigation-bar.component.html @@ -106,7 +106,7 @@ }" (mouseover)="openSubmenu($event, 'learnMenu')" (mouseleave)="closeSubmenuIfNotMobile($event)" - role="menu"> + >
  • -

    - Mark translations as stale: - Mark the existing translations for the given content as stale. Select this if your changes will require the translations - to be updated, but the previous translations can still be used. -

    Modify existing translations: Select this if your changes mean that existing translations will need to be modified slightly.

    -

    +

    Mark as Stale: Keep the existing translations, but mark them and any English voiceovers for updates. Choose this if your changes are small and only need minor fixes. diff --git a/core/templates/components/question-directives/question-misconception-editor/question-misconception-editor.component.html b/core/templates/components/question-directives/question-misconception-editor/question-misconception-editor.component.html index 2e420afa09cc2..5eaaa0d1e3ffb 100644 --- a/core/templates/components/question-directives/question-misconception-editor/question-misconception-editor.component.html +++ b/core/templates/components/question-directives/question-misconception-editor/question-misconception-editor.component.html @@ -25,7 +25,7 @@ [selectedMisconceptionSkillId]="selectedMisconceptionSkillId" [taggedSkillMisconceptionId]="taggedSkillMisconceptionId"> - + -

    @@ -19,7 +19,14 @@
    -
    +
    + +
    +
    Use misconception feedback as answer group feedback. diff --git a/core/templates/components/question-directives/question-misconception-selector/question-misconception-selector.component.spec.ts b/core/templates/components/question-directives/question-misconception-selector/question-misconception-selector.component.spec.ts index b586c20e7b40b..5b59f0cafe67f 100644 --- a/core/templates/components/question-directives/question-misconception-selector/question-misconception-selector.component.spec.ts +++ b/core/templates/components/question-directives/question-misconception-selector/question-misconception-selector.component.spec.ts @@ -87,4 +87,18 @@ describe('Question Misconception Selector Component', () => { ); expect(component.selectedMisconceptionSkillId).toEqual('def'); }); + + it('should clear selected misconception when selectNoMisconception is called', () => { + const emitSpy = spyOn(component.updateMisconceptionValues, 'emit'); + + component.selectNoMisconception(); + + expect(component.selectedMisconception).toBeNull(); + expect(component.selectedMisconceptionSkillId).toBeNull(); + expect(emitSpy).toHaveBeenCalledWith({ + misconception: null, + skillId: null, + feedbackIsUsed: component.misconceptionFeedbackIsUsed, + }); + }); }); diff --git a/core/templates/components/question-directives/question-misconception-selector/question-misconception-selector.component.ts b/core/templates/components/question-directives/question-misconception-selector/question-misconception-selector.component.ts index 6e61c6da9c1e2..dfe51be1f6f3d 100644 --- a/core/templates/components/question-directives/question-misconception-selector/question-misconception-selector.component.ts +++ b/core/templates/components/question-directives/question-misconception-selector/question-misconception-selector.component.ts @@ -25,8 +25,8 @@ import { } from 'domain/skill/misconception.model'; interface UpdatedValues { - misconception: Misconception; - skillId: string; + misconception: Misconception | null; + skillId: string | null; feedbackIsUsed: boolean; } @@ -41,10 +41,10 @@ export class QuestionMisconceptionSelectorComponent implements OnInit { // These properties are initialized using Angular lifecycle hooks // and we need to do non-null assertion. For more information, see // https://github.com/oppia/oppia/wiki/Guide-on-defining-types#ts-7-1 - @Input() selectedMisconception!: Misconception; - @Input() selectedMisconceptionSkillId!: string; + @Input() selectedMisconception!: Misconception | null; + @Input() selectedMisconceptionSkillId!: string | null; @Input() misconceptionFeedbackIsUsed!: boolean; - @Input() taggedSkillMisconceptionId!: string; + @Input() taggedSkillMisconceptionId!: string | null; misconceptionsBySkill!: MisconceptionSkillMap; constructor(private stateEditorService: StateEditorService) {} @@ -68,6 +68,17 @@ export class QuestionMisconceptionSelectorComponent implements OnInit { this.updateMisconceptionValues.emit(updatedValues); } + selectNoMisconception(): void { + this.selectedMisconception = null; + this.selectedMisconceptionSkillId = null; + let updatedValues = { + misconception: null, + skillId: null, + feedbackIsUsed: this.misconceptionFeedbackIsUsed, + }; + this.updateMisconceptionValues.emit(updatedValues); + } + toggleMisconceptionFeedbackUsage(): void { this.misconceptionFeedbackIsUsed = !this.misconceptionFeedbackIsUsed; let updatedValues = { diff --git a/core/templates/components/review-material-editor/review-material-editor.component.spec.ts b/core/templates/components/review-material-editor/review-material-editor.component.spec.ts index 69c9c6db20750..eafd18178c3cc 100644 --- a/core/templates/components/review-material-editor/review-material-editor.component.spec.ts +++ b/core/templates/components/review-material-editor/review-material-editor.component.spec.ts @@ -21,32 +21,16 @@ import {ChangeDetectorRef, NO_ERRORS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; import {SubtitledHtml} from 'domain/exploration/subtitled-html.model'; import {ReviewMaterialEditorComponent} from './review-material-editor.component'; -import {PlatformFeatureService} from 'services/platform-feature.service'; - -class MockPlatformFeatureService { - status = { - EnableWorkedExamplesRteComponent: { - isEnabled: false, - }, - }; -} describe('Review Material Editor Component', () => { let component: ReviewMaterialEditorComponent; let fixture: ComponentFixture; - let platformFeatureService: PlatformFeatureService; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], declarations: [ReviewMaterialEditorComponent], - providers: [ - ChangeDetectorRef, - { - provide: PlatformFeatureService, - useClass: MockPlatformFeatureService, - }, - ], + providers: [ChangeDetectorRef], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); @@ -54,7 +38,6 @@ describe('Review Material Editor Component', () => { beforeEach(() => { fixture = TestBed.createComponent(ReviewMaterialEditorComponent); component = fixture.componentInstance; - platformFeatureService = TestBed.inject(PlatformFeatureService); component.bindableDict = { displayedConceptCardExplanation: 'Explanation', @@ -134,17 +117,6 @@ describe('Review Material Editor Component', () => { expect(component.getSchema()).toEqual(component.HTML_SCHEMA); }); - it('should get schema with ALL_COMPONENTS when feature is disabled', () => { - const schema = component.getSchema(); - - expect(schema).toEqual({ - type: 'html', - ui_config: { - rte_component_config_id: 'ALL_COMPONENTS', - }, - }); - }); - it('should update editableExplanation', () => { component.editableExplanation = 'Old Explanation'; @@ -162,10 +134,7 @@ describe('Review Material Editor Component', () => { expect(component.editableExplanation).toEqual('Same Explanation'); }); - it('should get schema with SKILL_AND_STUDY_GUIDE_EDITOR_COMPONENTS when feature is enabled', () => { - platformFeatureService.status.EnableWorkedExamplesRteComponent.isEnabled = - true; - + it('should get correct schema', () => { const schema = component.getSchema(); expect(schema).toEqual({ @@ -176,12 +145,6 @@ describe('Review Material Editor Component', () => { }); }); - it('should return correct value for isEnableWorkedexamplesRteComponentFeatureEnabled', () => { - expect(component.isEnableWorkedexamplesRteComponentFeatureEnabled()).toBe( - false - ); - }); - it('should save explanation memento when opening editor', () => { component.editableExplanation = 'Current Explanation'; diff --git a/core/templates/components/review-material-editor/review-material-editor.component.ts b/core/templates/components/review-material-editor/review-material-editor.component.ts index b8892aa8b4ef5..5d7204ef536ea 100644 --- a/core/templates/components/review-material-editor/review-material-editor.component.ts +++ b/core/templates/components/review-material-editor/review-material-editor.component.ts @@ -26,7 +26,6 @@ import { } from '@angular/core'; import {AppConstants} from 'app.constants'; import {SubtitledHtml} from 'domain/exploration/subtitled-html.model'; -import {PlatformFeatureService} from 'services/platform-feature.service'; interface HtmlSchema { type: 'html'; @@ -62,10 +61,7 @@ export class ReviewMaterialEditorComponent implements OnInit { skillEditorWorkedExampleLimit: number = AppConstants.SKILL_EDITOR_WORKED_EXAMPLE_LIMIT; - constructor( - private changeDetectorRef: ChangeDetectorRef, - private platformFeatureService: PlatformFeatureService - ) {} + constructor(private changeDetectorRef: ChangeDetectorRef) {} ngOnInit(): void { this.COMPONENT_NAME_EXPLANATION = AppConstants.COMPONENT_NAME_EXPLANATION; @@ -77,14 +73,6 @@ export class ReviewMaterialEditorComponent implements OnInit { // Remove this function when the schema-based editor // is migrated to Angular 2+. getSchema(): HtmlSchema { - if (!this.isEnableWorkedexamplesRteComponentFeatureEnabled()) { - this.HTML_SCHEMA = { - type: 'html', - ui_config: { - rte_component_config_id: 'ALL_COMPONENTS', - }, - }; - } return this.HTML_SCHEMA; } @@ -106,11 +94,6 @@ export class ReviewMaterialEditorComponent implements OnInit { return this.workedExampleLimitExceeded; } - isEnableWorkedexamplesRteComponentFeatureEnabled(): boolean { - return this.platformFeatureService.status.EnableWorkedExamplesRteComponent - .isEnabled; - } - openConceptCardExplanationEditor(): void { this.explanationMemento = this.editableExplanation; this.conceptCardExplanationEditorIsShown = true; diff --git a/core/templates/components/state-directives/answer-group-editor/answer-group-editor.component.html b/core/templates/components/state-directives/answer-group-editor/answer-group-editor.component.html index 70555df26fbf0..67098597a1097 100644 --- a/core/templates/components/state-directives/answer-group-editor/answer-group-editor.component.html +++ b/core/templates/components/state-directives/answer-group-editor/answer-group-editor.component.html @@ -27,7 +27,7 @@
    - +
    diff --git a/core/templates/components/state-directives/answer-group-editor/answer-group-editor.component.ts b/core/templates/components/state-directives/answer-group-editor/answer-group-editor.component.ts index f4ba45aee5b59..465b123e2d323 100644 --- a/core/templates/components/state-directives/answer-group-editor/answer-group-editor.component.ts +++ b/core/templates/components/state-directives/answer-group-editor/answer-group-editor.component.ts @@ -44,7 +44,7 @@ import {BaseTranslatableObject} from 'interactions/rule-input-defs'; import {PlatformFeatureService} from 'services/platform-feature.service'; interface TaggedMisconception { - skillId: string; + skillId: string | null; misconceptionId: number; } @@ -66,7 +66,8 @@ export class AnswerGroupEditor implements OnInit, OnDestroy { @Output() onSaveAnswerGroupDest = new EventEmitter(); @Output() onSaveAnswerGroupDestIfStuck = new EventEmitter(); @Output() onSaveAnswerGroupFeedback = new EventEmitter(); - @Output() onSaveTaggedMisconception = new EventEmitter(); + @Output() onSaveTaggedMisconception = + new EventEmitter(); rulesMemento: Rule[]; directiveSubscriptions = new Subscription(); @@ -86,7 +87,7 @@ export class AnswerGroupEditor implements OnInit, OnDestroy { private platformFeatureService: PlatformFeatureService ) {} - sendOnSaveTaggedMisconception(event: TaggedMisconception): void { + sendOnSaveTaggedMisconception(event: TaggedMisconception | null): void { this.onSaveTaggedMisconception.emit(event); } diff --git a/core/templates/components/state-editor/state-interaction-editor/state-interaction-editor.component.ts b/core/templates/components/state-editor/state-interaction-editor/state-interaction-editor.component.ts index e7d9c33f50e33..84a16e4f67496 100644 --- a/core/templates/components/state-editor/state-interaction-editor/state-interaction-editor.component.ts +++ b/core/templates/components/state-editor/state-interaction-editor/state-interaction-editor.component.ts @@ -320,7 +320,10 @@ export class StateInteractionEditorComponent implements OnInit, OnDestroy { ngOnInit(): void { this.interactionIsDisabled = false; - this.DEFAULT_TERMINAL_STATE_CONTENT = 'Congratulations, you have finished!'; + // State content is stored as an HTML string, so the default end-of-exploration + // text needs to be wrapped in a paragraph (

    ) tag. + this.DEFAULT_TERMINAL_STATE_CONTENT = + '

    Congratulations, you have finished!

    '; this.windowIsNarrow = this.windowDimensionsService.isWindowNarrow(); this.interactionEditorIsShown = true; diff --git a/core/templates/components/state-editor/state-responses-editor/state-responses.component.ts b/core/templates/components/state-editor/state-responses-editor/state-responses.component.ts index 6474811b854f7..5b26ba100daa7 100644 --- a/core/templates/components/state-editor/state-responses-editor/state-responses.component.ts +++ b/core/templates/components/state-editor/state-responses-editor/state-responses.component.ts @@ -355,11 +355,16 @@ export class StateResponsesComponent implements OnInit, OnDestroy { } } - saveTaggedMisconception(taggedMisconception: TaggedMisconception): void { - const {skillId, misconceptionId} = taggedMisconception; + saveTaggedMisconception( + taggedMisconception: TaggedMisconception | null + ): void { + const taggedSkillMisconceptionId = + taggedMisconception !== null + ? `${taggedMisconception.skillId}-${taggedMisconception.misconceptionId}` + : null; this.responsesService.updateActiveAnswerGroup( { - taggedSkillMisconceptionId: skillId + '-' + misconceptionId, + taggedSkillMisconceptionId, } as AnswerGroup, newAnswerGroups => { this.onSaveInteractionAnswerGroups.emit(newAnswerGroups); diff --git a/core/templates/components/summary-tile/exploration-summary-tile.component.css b/core/templates/components/summary-tile/exploration-summary-tile.component.css index 4a5979def29d1..3d92dabfeea1f 100644 --- a/core/templates/components/summary-tile/exploration-summary-tile.component.css +++ b/core/templates/components/summary-tile/exploration-summary-tile.component.css @@ -117,6 +117,24 @@ oppia-exploration-summary-tile .oppia-exploration-dashboard-card .layout-row { justify-content: space-between; max-width: 100%; } + +oppia-exploration-summary-tile .tags-section { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 8px; +} + +oppia-exploration-summary-tile .tag-badge { + background: #f0f0f0; + border: 1px solid #d0d0d0; + border-radius: 4px; + color: #333; + font-family: 'Roboto', Arial, sans-serif; + font-size: 11px; + padding: 2px 8px; +} + @media (max-width: 480px) { oppia-exploration-summary-tile .exploration-summary-btn { font-size: 13px; diff --git a/core/templates/components/summary-tile/exploration-summary-tile.component.html b/core/templates/components/summary-tile/exploration-summary-tile.component.html index 938cd19816db3..ddfce85c2b5a9 100644 --- a/core/templates/components/summary-tile/exploration-summary-tile.component.html +++ b/core/templates/components/summary-tile/exploration-summary-tile.component.html @@ -96,6 +96,11 @@
  • +
    + + {{ tag }} + +
    diff --git a/core/templates/components/summary-tile/exploration-summary-tile.component.spec.ts b/core/templates/components/summary-tile/exploration-summary-tile.component.spec.ts index 0abe93fca3112..248f1dc125d68 100644 --- a/core/templates/components/summary-tile/exploration-summary-tile.component.spec.ts +++ b/core/templates/components/summary-tile/exploration-summary-tile.component.spec.ts @@ -369,11 +369,24 @@ describe('Exploration Summary Tile Component', () => { expect(urlPathSpy).toHaveBeenCalled(); expect(component.mobileCardToBeShown).toBe(true); + urlPathSpy.and.returnValue('/create'); + + component.checkIfMobileCardToBeShown(); + + expect(component.mobileCardToBeShown).toBe(true); + urlPathSpy.and.returnValue('/not-community-library'); component.checkIfMobileCardToBeShown(); expect(component.mobileCardToBeShown).toBe(false); + + component.isWindowLarge = true; + urlPathSpy.and.returnValue('/create'); + + component.checkIfMobileCardToBeShown(); + + expect(component.mobileCardToBeShown).toBe(false); }); it('should set the hover state to true', () => { diff --git a/core/templates/components/summary-tile/exploration-summary-tile.component.ts b/core/templates/components/summary-tile/exploration-summary-tile.component.ts index 1a71d8c295b89..661b5fcf8b7f0 100644 --- a/core/templates/components/summary-tile/exploration-summary-tile.component.ts +++ b/core/templates/components/summary-tile/exploration-summary-tile.component.ts @@ -74,6 +74,7 @@ export class ExplorationSummaryTileComponent implements OnInit, OnDestroy { @Input() showLearnerDashboardIconsIfPossible!: string; @Input() isContainerNarrow: boolean = false; @Input() isOwnedByCurrentUser: boolean = false; + @Input() tags: string[] = []; activityType!: string; resizeSubscription!: Subscription; @@ -169,7 +170,8 @@ export class ExplorationSummaryTileComponent implements OnInit, OnDestroy { this.mobileCardToBeShown = !this.isWindowLarge && (currentPageUrl === '/community-library' || - currentPageUrl.includes('/explore')); + currentPageUrl.includes('/explore') || + currentPageUrl.includes('/create')); } setHoverState(hoverState: boolean): void { diff --git a/core/templates/components/summary-tile/lesson-card.component.spec.ts b/core/templates/components/summary-tile/lesson-card.component.spec.ts index fa36c1055e354..fa5a4ab8058ce 100644 --- a/core/templates/components/summary-tile/lesson-card.component.spec.ts +++ b/core/templates/components/summary-tile/lesson-card.component.spec.ts @@ -52,7 +52,8 @@ describe('LessonCardComponent', () => { status: 'public', category: 'Algebra', title: 'Test Title', - node_count: 0, + total_node_count: 0, + completed_node_count: 0, }; const sampleExploration = { @@ -78,6 +79,8 @@ describe('LessonCardComponent', () => { activity_type: 'exploration', category: 'Algebra', title: 'Test Title', + visited_checkpoints_count: 5, + total_checkpoints_count: 10, }; const sampleNode = { @@ -377,10 +380,59 @@ describe('LessonCardComponent', () => { expect(component.imgColor).toEqual(sampleExploration.thumbnail_bg_color); expect(component.title).toEqual(sampleExploration.title); + // Progress = floor((5 - 1) / 10 * 100) = 40. + expect(component.progress).toEqual(40); + expect(component.lessonTopic).toEqual('Community Lesson'); + })); + + it('should calculate progress as 0 when no checkpoints are visited', fakeAsync(() => { + const explorationWithNoCheckpoints = { + ...sampleExploration, + visited_checkpoints_count: 0, + total_checkpoints_count: 5, + }; + + component.story = LearnerExplorationSummary.createFromBackendDict( + explorationWithNoCheckpoints + ); + + fixture.detectChanges(); + tick(); + expect(component.progress).toEqual(0); expect(component.lessonTopic).toEqual('Community Lesson'); })); + it('should calculate progress as 0 when total checkpoints is 0', fakeAsync(() => { + const explorationWithNoCheckpoints = { + ...sampleExploration, + visited_checkpoints_count: 0, + total_checkpoints_count: 0, + }; + + component.story = LearnerExplorationSummary.createFromBackendDict( + explorationWithNoCheckpoints + ); + + fixture.detectChanges(); + tick(); + + expect(component.progress).toEqual(0); + expect(component.lessonTopic).toEqual('Community Lesson'); + })); + + it('should set progress to 100 when community lesson is complete', fakeAsync(() => { + component.isCommunityLessonComplete = true; + component.story = + LearnerExplorationSummary.createFromBackendDict(sampleExploration); + + fixture.detectChanges(); + tick(); + + expect(component.progress).toEqual(100); + expect(component.lessonTopic).toEqual('Community Lesson'); + })); + it('should set story to complete StorySummary and its non-url values to the respective fields', fakeAsync(() => { chapterProgressLoaderService.computeLessonProgress.and.returnValue(100); @@ -659,4 +711,48 @@ describe('LessonCardComponent', () => { expect(component.title).toBe('Chapter 2: Title 2'); expect(component.progress).toBe(50); })); + + it('should set collection progress to 100 when community lesson is complete', fakeAsync(() => { + component.isCommunityLessonComplete = true; + component.story = CollectionSummary.createFromBackendDict({ + ...sampleCollection, + total_node_count: 5, + completed_node_count: 2, + }); + + fixture.detectChanges(); + tick(); + + expect(component.progress).toEqual(100); + expect(component.lessonTopic).toEqual('Collections'); + })); + + it('should calculate collection progress based on completed node count', fakeAsync(() => { + component.story = CollectionSummary.createFromBackendDict({ + ...sampleCollection, + total_node_count: 5, + completed_node_count: 2, + }); + + fixture.detectChanges(); + tick(); + + // Progress = floor(2 / 5 * 100) = 40. + expect(component.progress).toEqual(40); + expect(component.lessonTopic).toEqual('Collections'); + })); + + it('should set collection progress to 0 when node count is 0', fakeAsync(() => { + component.story = CollectionSummary.createFromBackendDict({ + ...sampleCollection, + total_node_count: 0, + completed_node_count: 0, + }); + + fixture.detectChanges(); + tick(); + + expect(component.progress).toEqual(0); + expect(component.lessonTopic).toEqual('Collections'); + })); }); diff --git a/core/templates/components/summary-tile/lesson-card.component.ts b/core/templates/components/summary-tile/lesson-card.component.ts index 3fb7d58e6ab4e..79e3d6117366c 100644 --- a/core/templates/components/summary-tile/lesson-card.component.ts +++ b/core/templates/components/summary-tile/lesson-card.component.ts @@ -204,8 +204,16 @@ export class LessonCardComponent implements OnInit { collectionModel.thumbnailIconUrl ); - // TODO(#18384): Get correct progress and state for button text. - this.progress = this.isCommunityLessonComplete ? 100 : 0; + // Calculate progress from completed node counts. + let progress = 0; + if (this.isCommunityLessonComplete) { + progress = 100; + } else if (collectionModel.nodeCount > 0) { + progress = Math.floor( + (collectionModel.completedNodeCount / collectionModel.nodeCount) * 100 + ); + } + this.progress = progress; this.title = collectionModel.title; this.lessonUrl = `/collection/${collectionModel.id}`; this.lessonTopic = 'Collections'; @@ -218,8 +226,20 @@ export class LessonCardComponent implements OnInit { explorationModel.thumbnailIconUrl ); - // TODO(#18384): Get correct progress and state for button text. - this.progress = this.isCommunityLessonComplete ? 100 : 0; + // Calculate progress from checkpoint counts. + let progress = 0; + if (this.isCommunityLessonComplete) { + progress = 100; + } else if (explorationModel.totalCheckpointsCount > 0) { + const visitedCheckpoints = Math.max( + explorationModel.visitedCheckpointsCount - 1, + 0 + ); + progress = Math.floor( + (visitedCheckpoints / explorationModel.totalCheckpointsCount) * 100 + ); + } + this.progress = progress; this.title = explorationModel.title; this.lessonUrl = `/explore/${explorationModel.id}`; this.lessonTopic = 'Community Lesson'; diff --git a/core/templates/css/oppia-material.css b/core/templates/css/oppia-material.css index 2d745822315ad..fdf45f83af1b3 100644 --- a/core/templates/css/oppia-material.css +++ b/core/templates/css/oppia-material.css @@ -1874,6 +1874,9 @@ th.mat-header-cell, td.mat-cell, td.mat-footer-cell { background: white; color: rgba(0, 0, 0, 0.87); } +.oppia-learner-dash-goals-modal .mat-dialog-container { + overflow: hidden; } + .mat-divider { border-top-color: rgba(0, 0, 0, 0.12); } @@ -2644,4 +2647,4 @@ th.mat-header-cell, td.mat-cell, td.mat-footer-cell { .mat-simple-snackbar-action { color: #448aff; } - \ No newline at end of file + diff --git a/core/templates/css/oppia.css b/core/templates/css/oppia.css index f6bb0f99f060d..065169b2fe809 100644 --- a/core/templates/css/oppia.css +++ b/core/templates/css/oppia.css @@ -152,7 +152,7 @@ why they need to be overriden. */ } .mat-button-success { - color: #009688; + color: #00645c; } .mat-shadow-bottom-z-1, .mat-button.mat-raised:not([disabled]), @@ -653,7 +653,7 @@ p:last-child { .oppia-navbar-nav .show > a { background-color: #fff; - color: #009688; + color: #00645c; } .oppia-dashboard-container a:hover { text-decoration: underline; @@ -661,7 +661,7 @@ p:last-child { .oppia-navbar-tabs > li:hover > a { background-color: #fff; - color: #009688; + color: #00645c; } .oppia-activity-summary-tile span { vertical-align: middle; @@ -725,7 +725,7 @@ p:last-child { background-color: #18447e; } .label-success, .bg-success { - background-color: #009688; + background-color: #00645c; } /* diff --git a/core/templates/domain/blog/blog-homepage-backend-api.service.spec.ts b/core/templates/domain/blog/blog-homepage-backend-api.service.spec.ts index 93e129601c2c1..81c569c8e6c4c 100644 --- a/core/templates/domain/blog/blog-homepage-backend-api.service.spec.ts +++ b/core/templates/domain/blog/blog-homepage-backend-api.service.spec.ts @@ -110,11 +110,13 @@ describe('Blog home page backend api service', () => { search_offset: null, blog_post_summaries_list: [], list_of_default_tags: ['learners', 'news'], + total_matching_blog_posts: 0, }; searchResponseData = { searchOffset: null, blogPostSummariesList: [], listOfDefaultTags: ['learners', 'news'], + totalMatchingBlogPosts: 0, }; blogPostObject = BlogPostData.createFromBackendDict(blogPost); blogPostPageBackendResponse = { @@ -205,6 +207,8 @@ describe('Blog home page backend api service', () => { it('should successfully fetch search data', fakeAsync(() => { urlSearchQuery = '?q=testBlogSearch&tags=("News"%20OR%20"Mathematics")'; + searchResponseBackendDict.total_matching_blog_posts = 0; + searchResponseData.totalMatchingBlogPosts = 0; bhpbas .fetchBlogPostSearchResultAsync(urlSearchQuery) .then(successHandler, failHandler); @@ -253,6 +257,8 @@ describe('Blog home page backend api service', () => { it('should fetch search data with blog post summary data', fakeAsync(() => { urlSearchQuery = '?q=testBlogSearch&tags=("News"%20OR%20"Mathematics")'; + searchResponseBackendDict.total_matching_blog_posts = 0; + searchResponseData.totalMatchingBlogPosts = 0; searchResponseBackendDict.blog_post_summaries_list = [blogPostSummary]; searchResponseData.blogPostSummariesList = [blogPostSummaryObject]; bhpbas diff --git a/core/templates/domain/blog/blog-homepage-backend-api.service.ts b/core/templates/domain/blog/blog-homepage-backend-api.service.ts index 2580fe68ffa39..3d4acbe5a5a3a 100644 --- a/core/templates/domain/blog/blog-homepage-backend-api.service.ts +++ b/core/templates/domain/blog/blog-homepage-backend-api.service.ts @@ -45,6 +45,7 @@ export interface SearchResponseBackendDict { search_offset: number | null; blog_post_summaries_list: BlogPostSummaryBackendDict[]; list_of_default_tags: string[]; + total_matching_blog_posts: number; } export interface BlogPostPageBackendResponse { @@ -57,6 +58,7 @@ export interface SearchResponseData { searchOffset: number | null; blogPostSummariesList: BlogPostSummary[]; listOfDefaultTags: string[]; + totalMatchingBlogPosts: number; } export interface BlogHomePageData { @@ -170,6 +172,7 @@ export class BlogHomePageBackendApiService { return BlogPostSummary.createFromBackendDict(blogPostSummary); } ), + totalMatchingBlogPosts: response.total_matching_blog_posts ?? 0, }); }, errorResponse => { diff --git a/core/templates/domain/blog/blog-post-editor-backend-api.service.spec.ts b/core/templates/domain/blog/blog-post-editor-backend-api.service.spec.ts index 6e5272d27ea29..6cbbc45b4bfbb 100644 --- a/core/templates/domain/blog/blog-post-editor-backend-api.service.spec.ts +++ b/core/templates/domain/blog/blog-post-editor-backend-api.service.spec.ts @@ -182,7 +182,7 @@ describe('Blog Post Editor backend api service', () => { '/blogeditorhandler/data/sampleBlogId' ); expect(req.request.method).toEqual('PUT'); - req.flush({blog_post: blogPostEditorBackendResponse.blog_post_dict}); + req.flush({blog_post_dict: blogPostEditorBackendResponse.blog_post_dict}); flushMicrotasks(); diff --git a/core/templates/domain/blog/blog-post-editor-backend-api.service.ts b/core/templates/domain/blog/blog-post-editor-backend-api.service.ts index 6c5a53ce0c2cf..530c2da09d8e0 100644 --- a/core/templates/domain/blog/blog-post-editor-backend-api.service.ts +++ b/core/templates/domain/blog/blog-post-editor-backend-api.service.ts @@ -25,7 +25,7 @@ import {BlogPostChangeDict} from 'domain/blog/blog-post-update.service'; import {ImagesData} from 'services/image-local-storage.service'; interface BlogPostUpdateBackendDict { - blog_post: BlogPostBackendDict; + blog_post_dict: BlogPostBackendDict; } interface BlogPostUpdatedData { @@ -142,7 +142,7 @@ export class BlogPostEditorBackendApiService { response => { resolve({ blogPostDict: BlogPostData.createFromBackendDict( - response.blog_post + response.blog_post_dict ), }); }, diff --git a/core/templates/domain/cloud-task/cloud-task-run.model.ts b/core/templates/domain/cloud-task/cloud-task-run.model.ts index fd23a986ec48e..8988e1d47f252 100644 --- a/core/templates/domain/cloud-task/cloud-task-run.model.ts +++ b/core/templates/domain/cloud-task/cloud-task-run.model.ts @@ -63,7 +63,7 @@ export class CloudTaskRun { this.exceptionMessagesForFailedRuns = exceptionMessagesForFailedRuns; this.currentRetryAttempt = currentRetryAttempt; this.lastUpdated = lastUpdated; - this.createdOn = createdOn; + this.createdOn = new Date(createdOn); switch (this.latestJobState) { case 'RUNNING': diff --git a/core/templates/domain/collection/collection-summary.model.spec.ts b/core/templates/domain/collection/collection-summary.model.spec.ts index 204813f6c6792..67d3a33e6edc5 100644 --- a/core/templates/domain/collection/collection-summary.model.spec.ts +++ b/core/templates/domain/collection/collection-summary.model.spec.ts @@ -32,7 +32,7 @@ describe('Collection summary model', () => { status: 'public', category: 'Algebra', title: 'Test Title', - node_count: 0, + total_node_count: 0, }; let collectionSummaryObject = diff --git a/core/templates/domain/collection/collection-summary.model.ts b/core/templates/domain/collection/collection-summary.model.ts index 375139a757947..3bbd578791015 100644 --- a/core/templates/domain/collection/collection-summary.model.ts +++ b/core/templates/domain/collection/collection-summary.model.ts @@ -28,7 +28,9 @@ export interface CollectionSummaryBackendDict { thumbnail_bg_color: string; thumbnail_icon_url: string; title: string; - node_count: number; + total_node_count?: number; + node_count?: number; + completed_node_count?: number; } export class CollectionSummary { @@ -44,7 +46,8 @@ export class CollectionSummary { public thumbnailBgColor: string, public thumbnailIconUrl: string, public title: string, - public nodeCount: number + public nodeCount: number, + public completedNodeCount: number ) {} static createFromBackendDict( @@ -62,7 +65,10 @@ export class CollectionSummary { collectionSummaryDict.thumbnail_bg_color, collectionSummaryDict.thumbnail_icon_url, collectionSummaryDict.title, - collectionSummaryDict.node_count + collectionSummaryDict.total_node_count ?? + collectionSummaryDict.node_count ?? + 0, + collectionSummaryDict.completed_node_count ?? 0 ); } } diff --git a/core/templates/domain/collection/editable-collection-backend-api.service.spec.ts b/core/templates/domain/collection/editable-collection-backend-api.service.spec.ts index 435f7ae3e498f..1deb8caabaae7 100644 --- a/core/templates/domain/collection/editable-collection-backend-api.service.spec.ts +++ b/core/templates/domain/collection/editable-collection-backend-api.service.spec.ts @@ -29,7 +29,7 @@ describe('Editable collection backend API service', () => { let httpTestingController: HttpTestingController; // Sample collection object returnable from the backend. let sampleDataResults = { - collection: { + collection_dict: { id: '0', title: 'Collection Under Test', category: 'Test', @@ -104,7 +104,7 @@ describe('Editable collection backend API service', () => { flushMicrotasks(); - let collectionObject = Collection.create(sampleDataResults.collection); + let collectionObject = Collection.create(sampleDataResults.collection_dict); expect(successHandler).toHaveBeenCalledWith(collectionObject); expect(failHandler).not.toHaveBeenCalled(); @@ -173,16 +173,16 @@ describe('Editable collection backend API service', () => { flushMicrotasks(); expect(collection.id).toBe('0'); - collectionDict.collection.title = 'New Title'; - collectionDict.collection.version = 2; - collection = Collection.create(collectionDict.collection); + collectionDict.collection_dict.title = 'New Title'; + collectionDict.collection_dict.version = 2; + collection = Collection.create(collectionDict.collection_dict); // Send a request to update collection. editableCollectionBackendApiService .updateCollectionAsync( - collectionDict.collection.id, - collectionDict.collection.version, - collectionDict.collection.title, + collectionDict.collection_dict.id, + collectionDict.collection_dict.version, + collectionDict.collection_dict.title, [] ) .then(successHandler, failHandler); @@ -235,8 +235,8 @@ describe('Editable collection backend API service', () => { editableCollectionBackendApiService .updateCollectionAsync( 'invalidId', - collectionDict.collection.version, - collectionDict.collection.title, + collectionDict.collection_dict.version, + collectionDict.collection_dict.title, [] ) .then(successHandler, failHandler); diff --git a/core/templates/domain/collection/editable-collection-backend-api.service.ts b/core/templates/domain/collection/editable-collection-backend-api.service.ts index a2fe5cc9eba5f..58b2ae060b23b 100644 --- a/core/templates/domain/collection/editable-collection-backend-api.service.ts +++ b/core/templates/domain/collection/editable-collection-backend-api.service.ts @@ -29,7 +29,7 @@ import {UrlInterpolationService} from 'domain/utilities/url-interpolation.servic import {BackendChangeObject} from 'domain/editor/undo_redo/change.model'; interface EditableCollectionBackendResponse { - collection: CollectionBackendDict; + collection_dict: CollectionBackendDict; } // TODO(bhenning): I think that this might be better merged with the @@ -71,7 +71,7 @@ export class EditableCollectionBackendApiService { .toPromise() .then( response => { - var collectionObject = Collection.create(response.collection); + var collectionObject = Collection.create(response.collection_dict); if (successCallback) { successCallback(collectionObject); } @@ -113,7 +113,7 @@ export class EditableCollectionBackendApiService { .then( response => { // The returned data is an updated collection dict. - var collectionObject = Collection.create(response.collection); + var collectionObject = Collection.create(response.collection_dict); // Update the ReadOnlyCollectionBackendApiService's cache with the new // collection. diff --git a/core/templates/domain/collection/read-only-collection-backend-api.service.spec.ts b/core/templates/domain/collection/read-only-collection-backend-api.service.spec.ts index 69f36ebed74aa..40dd57af131bb 100644 --- a/core/templates/domain/collection/read-only-collection-backend-api.service.spec.ts +++ b/core/templates/domain/collection/read-only-collection-backend-api.service.spec.ts @@ -37,7 +37,7 @@ describe('Read only collection backend API service', () => { meta_name: 'meta_name', can_edit: false, meta_description: 'meta_description', - collection: { + collection_dict: { id: '0', title: 'Collection Under Test', category: 'Test', @@ -119,7 +119,7 @@ describe('Read only collection backend API service', () => { flushMicrotasks(); - var collectionObject = Collection.create(sampleDataResults.collection); + var collectionObject = Collection.create(sampleDataResults.collection_dict); expect(successHandler).toHaveBeenCalledWith(collectionObject); expect(failHandler).not.toHaveBeenCalled(); @@ -177,7 +177,7 @@ describe('Read only collection backend API service', () => { flushMicrotasks(); - var collectionObject = Collection.create(sampleDataResults.collection); + var collectionObject = Collection.create(sampleDataResults.collection_dict); expect(successHandler).toHaveBeenCalledWith(collectionObject); expect(failHandler).not.toHaveBeenCalled(); diff --git a/core/templates/domain/collection/read-only-collection-backend-api.service.ts b/core/templates/domain/collection/read-only-collection-backend-api.service.ts index 6b328cddce3b9..87fa787f10c28 100644 --- a/core/templates/domain/collection/read-only-collection-backend-api.service.ts +++ b/core/templates/domain/collection/read-only-collection-backend-api.service.ts @@ -49,7 +49,7 @@ export interface ReadOnlyCollectionBackendResponse { meta_name: string; can_edit: boolean; meta_description: string; - collection: CollectionBackendDict; + collection_dict: CollectionBackendDict; } // TODO(bhenning): For preview mode, this service should be replaced by a @@ -87,7 +87,7 @@ export class ReadOnlyCollectionBackendApiService { .then( response => { this._cacheCollectionDetails(response); - var collectionObject = Collection.create(response.collection); + var collectionObject = Collection.create(response.collection_dict); if (successCallback) { successCallback(collectionObject); this._collectionLoadedEventEmitter.emit(); @@ -104,10 +104,10 @@ export class ReadOnlyCollectionBackendApiService { private _cacheCollectionDetails( details: ReadOnlyCollectionBackendResponse ): void { - if (details.collection.id !== null) { - this._collectionDetailsCache[details.collection.id] = { + if (details.collection_dict.id !== null) { + this._collectionDetailsCache[details.collection_dict.id] = { canEdit: details.can_edit, - title: details.collection.title, + title: details.collection_dict.title, }; } } diff --git a/core/templates/domain/feature-flag/feature-status-summary.model.ts b/core/templates/domain/feature-flag/feature-status-summary.model.ts index 8a06eaa39d455..b467423f9b118 100644 --- a/core/templates/domain/feature-flag/feature-status-summary.model.ts +++ b/core/templates/domain/feature-flag/feature-status-summary.model.ts @@ -39,13 +39,15 @@ export enum FeatureNames { EnableMultipleClassrooms = 'enable_multiple_classrooms', RedesignedTopicViewerPage = 'redesigned_topic_viewer_page', AutomaticVoiceoverRegenerationFromExp = 'automatic_voiceover_regeneration_from_exp', + HighlightSentencesDuringAutomaticVoiceoverPlayback = 'highlight_sentences_during_automatic_voiceover_playback', ShowVoiceoverTabForNonCuratedExplorations = 'show_voiceover_tab_for_non_curated_explorations', ShowRestructuredStudyGuides = 'show_restructured_study_guides', EnableTranslationOppsWithNewOppModels = 'enable_translation_opps_with_new_opp_models', - EnableWorkedExamplesRteComponent = 'enable_worked_examples_rte_component', ShowRegeneratedVoiceoversToLearners = 'show_regenerated_voiceovers_to_learners', EnableBackgroundVoiceoverSynthesis = 'enable_background_voiceover_synthesis', EnableReadyForReviewTest = 'enable_ready_for_review_test', + EnableCampaignBanner = 'enable_financial_literacy_campaign_banner', + EnableCampaignBannerTestMode = 'enable_financial_literacy_campaign_banner_test_mode', } export interface FeatureStatusSummaryBackendDict { diff --git a/core/templates/domain/opportunity/exploration-opportunity-summary.model.spec.ts b/core/templates/domain/opportunity/exploration-opportunity-summary.model.spec.ts index 80c99e2d0c5a8..eb8869007921d 100644 --- a/core/templates/domain/opportunity/exploration-opportunity-summary.model.spec.ts +++ b/core/templates/domain/opportunity/exploration-opportunity-summary.model.spec.ts @@ -41,6 +41,7 @@ describe('Exploration opportunity summary model', () => { }, language_code: 'hi', is_pinned: false, + reviewer_only_content_count: 5, }; explorationOpportunitySummary = ExplorationOpportunitySummary.createFromBackendDict(backendDict); @@ -114,6 +115,7 @@ describe('Exploration opportunity summary model', () => { translation_in_review_counts: {}, language_code: 'en', is_pinned: false, + reviewer_only_content_count: 0, }; const explorationOpportunitySummaryForNoContents = ExplorationOpportunitySummary.createFromBackendDict( diff --git a/core/templates/domain/opportunity/exploration-opportunity-summary.model.ts b/core/templates/domain/opportunity/exploration-opportunity-summary.model.ts index 64b5a1e6234ed..cbb17790dfb36 100644 --- a/core/templates/domain/opportunity/exploration-opportunity-summary.model.ts +++ b/core/templates/domain/opportunity/exploration-opportunity-summary.model.ts @@ -30,6 +30,7 @@ export interface ExplorationOpportunitySummaryBackendDict { translation_in_review_counts: TranslationCountsDict; language_code: string; is_pinned: boolean; + reviewer_only_content_count: number; } export class ExplorationOpportunitySummary { @@ -42,6 +43,7 @@ export class ExplorationOpportunitySummary { translationInReviewCount: TranslationCountsDict; languageCode: string; isPinned: boolean; + reviewerOnlyContentCount: number; constructor( expId: string, @@ -52,7 +54,8 @@ export class ExplorationOpportunitySummary { translationCounts: TranslationCountsDict, translationInReviewCount: TranslationCountsDict, languageCode: string, - isPinned: boolean + isPinned: boolean, + reviewerOnlyContentCount: number ) { this.id = expId; this.topicName = topicName; @@ -63,6 +66,7 @@ export class ExplorationOpportunitySummary { this.translationInReviewCount = translationInReviewCount; this.languageCode = languageCode; this.isPinned = isPinned; + this.reviewerOnlyContentCount = reviewerOnlyContentCount; } static createFromBackendDict( @@ -77,7 +81,8 @@ export class ExplorationOpportunitySummary { backendDict.translation_counts, backendDict.translation_in_review_counts, backendDict.language_code, - backendDict.is_pinned + backendDict.is_pinned, + backendDict.reviewer_only_content_count ); } @@ -97,6 +102,10 @@ export class ExplorationOpportunitySummary { return this.contentCount; } + getReviewerOnlyContentCount(): number { + return this.reviewerOnlyContentCount; + } + getTranslationProgressPercentage(languageCode: string): number { let progressPercentage = 0; if ( diff --git a/core/templates/domain/skill/misconception.model.ts b/core/templates/domain/skill/misconception.model.ts index d818e41816911..d4b353901ee7b 100644 --- a/core/templates/domain/skill/misconception.model.ts +++ b/core/templates/domain/skill/misconception.model.ts @@ -29,7 +29,7 @@ export interface MisconceptionSkillMap { } export interface TaggedMisconception { - skillId: string; + skillId: string | null; misconceptionId: number; } diff --git a/core/templates/domain/skill/skill-backend-api.service.spec.ts b/core/templates/domain/skill/skill-backend-api.service.spec.ts index 82a8e5d70b1f4..8b297071ce9f7 100644 --- a/core/templates/domain/skill/skill-backend-api.service.spec.ts +++ b/core/templates/domain/skill/skill-backend-api.service.spec.ts @@ -119,7 +119,7 @@ describe('Skill backend API service', () => { }; const backendResponse = { - skill: skillBackendDict, + skill_dict: skillBackendDict, assigned_skill_topic_data_dict: assignedSkillTopicData, grouped_skill_summaries: groupedSkillSummaries, }; @@ -170,7 +170,7 @@ describe('Skill backend API service', () => { it('should make a request to update the skill in the backend.', fakeAsync(() => { skill = Skill.createFromBackendDict(skillBackendDict); const backendResponse = { - skill: skillBackendDict, + skill_dict: skillBackendDict, }; const changeList = { cmd: 'add_prerequisite_skill', diff --git a/core/templates/domain/skill/skill-backend-api.service.ts b/core/templates/domain/skill/skill-backend-api.service.ts index 61e2a16022e04..b4740bc7481f3 100644 --- a/core/templates/domain/skill/skill-backend-api.service.ts +++ b/core/templates/domain/skill/skill-backend-api.service.ts @@ -27,7 +27,7 @@ import {UrlInterpolationService} from 'domain/utilities/url-interpolation.servic import {Observable} from 'rxjs'; interface FetchSkillBackendResponse { - skill: SkillBackendDict; + skill_dict: SkillBackendDict; assigned_skill_topic_data_dict: { [topicName: string]: string; }; @@ -51,7 +51,7 @@ interface FetchMultiSkillsBackendResponse { } interface UpdateSkillBackendResponse { - skill: SkillBackendDict; + skill_dict: SkillBackendDict; } interface DoesSkillWithDescriptionExistBackendResponse { @@ -86,7 +86,7 @@ export class SkillBackendApiService { .then( response => { resolve({ - skill: Skill.createFromBackendDict(response.skill), + skill: Skill.createFromBackendDict(response.skill_dict), assignedSkillTopicData: response.assigned_skill_topic_data_dict, // TODO(nishantwrp): Refactor this property to return SkillSummary // domain objects instead of backend dicts. @@ -181,7 +181,7 @@ export class SkillBackendApiService { .toPromise() .then( response => { - resolve(Skill.createFromBackendDict(response.skill)); + resolve(Skill.createFromBackendDict(response.skill_dict)); }, errorResponse => { reject(errorResponse.error.error); diff --git a/core/templates/domain/story/editable-story-backend-api.service.spec.ts b/core/templates/domain/story/editable-story-backend-api.service.spec.ts index 8f96e26c34a10..e1e367ea2ace9 100644 --- a/core/templates/domain/story/editable-story-backend-api.service.spec.ts +++ b/core/templates/domain/story/editable-story-backend-api.service.spec.ts @@ -52,7 +52,7 @@ describe('Editable story backend API service', () => { // Sample story object returnable from the backend. sampleDataResults = { - story: { + story_dict: { id: 'storyId', title: 'Story title', description: 'Story description', @@ -125,7 +125,7 @@ describe('Editable story backend API service', () => { flushMicrotasks(); expect(successHandler).toHaveBeenCalledWith({ - story: sampleDataResults.story, + story: sampleDataResults.story_dict, topicName: sampleDataResults.topic_name, storyIsPublished: true, skillSummaries: sampleDataResults.skill_summaries, @@ -230,7 +230,7 @@ describe('Editable story backend API service', () => { story.title = 'New Title'; story.version = 2; var storyWrapper = { - story: story, + story_dict: story, }; // Send a request to update story. diff --git a/core/templates/domain/story/editable-story-backend-api.service.ts b/core/templates/domain/story/editable-story-backend-api.service.ts index 54185217fd5c6..e0efe6e734a48 100644 --- a/core/templates/domain/story/editable-story-backend-api.service.ts +++ b/core/templates/domain/story/editable-story-backend-api.service.ts @@ -26,7 +26,7 @@ import {StoryDomainConstants} from 'domain/story/story-domain.constants'; import {UrlInterpolationService} from 'domain/utilities/url-interpolation.service'; export interface FetchStoryBackendResponse { - story: StoryBackendDict; + story_dict: StoryBackendDict; topic_name: string; story_is_published: boolean; skill_summaries: SkillSummaryBackendDict[]; @@ -44,7 +44,7 @@ interface FetchStoryResponse { } interface UpdateStoryBackendResponse { - story: StoryBackendDict; + story_dict: StoryBackendDict; } interface StoryUrlFragmentExistsBackendResponse { @@ -83,7 +83,7 @@ export class EditableStoryBackendApiService { response => { if (successCallback) { successCallback({ - story: response.story, + story: response.story_dict, topicName: response.topic_name, storyIsPublished: response.story_is_published, skillSummaries: response.skill_summaries, @@ -125,7 +125,7 @@ export class EditableStoryBackendApiService { .put(editableStoryDataUrl, putData) .toPromise() .then( - response => successCallback(response.story), + response => successCallback(response.story_dict), errorResponse => errorCallback(errorResponse.error.error) ); } diff --git a/core/templates/domain/summary/learner-exploration-summary.model.spec.ts b/core/templates/domain/summary/learner-exploration-summary.model.spec.ts index 2cffb2ca036a0..8646b96fc7586 100644 --- a/core/templates/domain/summary/learner-exploration-summary.model.spec.ts +++ b/core/templates/domain/summary/learner-exploration-summary.model.spec.ts @@ -71,4 +71,178 @@ describe('Exploration summary model', () => { expect(expSummaryObject.category).toEqual('Algebra'); expect(expSummaryObject.title).toEqual('Test Title'); }); + + it('should correctly calculate progress with no checkpoints', () => { + let backendDict = { + last_updated_msec: 1591296737470.528, + community_owned: false, + objective: 'Test Objective', + id: '44LKoKLlIbGe', + num_views: 0, + thumbnail_icon_url: '/subjects/Algebra.svg', + human_readable_contributors_summary: {}, + language_code: 'en', + thumbnail_bg_color: '#cc4b00', + created_on_msec: 1591296635736.666, + ratings: { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + }, + status: 'public', + tags: [], + activity_type: 'exploration', + category: 'Algebra', + title: 'Test Title', + visited_checkpoints_count: 0, + total_checkpoints_count: 0, + }; + + let expSummaryObject = + LearnerExplorationSummary.createFromBackendDict(backendDict); + + expect(expSummaryObject.visitedCheckpointsCount).toEqual(0); + expect(expSummaryObject.totalCheckpointsCount).toEqual(0); + }); + + it('should correctly calculate progress with partial checkpoint completion', () => { + let backendDict = { + last_updated_msec: 1591296737470.528, + community_owned: false, + objective: 'Test Objective', + id: '44LKoKLlIbGe', + num_views: 0, + thumbnail_icon_url: '/subjects/Algebra.svg', + human_readable_contributors_summary: {}, + language_code: 'en', + thumbnail_bg_color: '#cc4b00', + created_on_msec: 1591296635736.666, + ratings: { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + }, + status: 'public', + tags: [], + activity_type: 'exploration', + category: 'Algebra', + title: 'Test Title', + visited_checkpoints_count: 3, + total_checkpoints_count: 5, + }; + + let expSummaryObject = + LearnerExplorationSummary.createFromBackendDict(backendDict); + + expect(expSummaryObject.visitedCheckpointsCount).toEqual(3); + expect(expSummaryObject.totalCheckpointsCount).toEqual(5); + }); + + it('should correctly calculate progress when starting an exploration', () => { + let backendDict = { + last_updated_msec: 1591296737470.528, + community_owned: false, + objective: 'Test Objective', + id: '44LKoKLlIbGe', + num_views: 0, + thumbnail_icon_url: '/subjects/Algebra.svg', + human_readable_contributors_summary: {}, + language_code: 'en', + thumbnail_bg_color: '#cc4b00', + created_on_msec: 1591296635736.666, + ratings: { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + }, + status: 'public', + tags: [], + activity_type: 'exploration', + category: 'Algebra', + title: 'Test Title', + visited_checkpoints_count: 1, + total_checkpoints_count: 5, + }; + + let expSummaryObject = + LearnerExplorationSummary.createFromBackendDict(backendDict); + + // Just verify checkpoint counts are set correctly. + expect(expSummaryObject.visitedCheckpointsCount).toEqual(1); + expect(expSummaryObject.totalCheckpointsCount).toEqual(5); + }); + + it('should correctly set checkpoint counts with all checkpoints visited', () => { + let backendDict = { + last_updated_msec: 1591296737470.528, + community_owned: false, + objective: 'Test Objective', + id: '44LKoKLlIbGe', + num_views: 0, + thumbnail_icon_url: '/subjects/Algebra.svg', + human_readable_contributors_summary: {}, + language_code: 'en', + thumbnail_bg_color: '#cc4b00', + created_on_msec: 1591296635736.666, + ratings: { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + }, + status: 'public', + tags: [], + activity_type: 'exploration', + category: 'Algebra', + title: 'Test Title', + visited_checkpoints_count: 5, + total_checkpoints_count: 5, + }; + + let expSummaryObject = + LearnerExplorationSummary.createFromBackendDict(backendDict); + + expect(expSummaryObject.visitedCheckpointsCount).toEqual(5); + expect(expSummaryObject.totalCheckpointsCount).toEqual(5); + }); + + it('should set default checkpoint counts when not provided in backend dict', () => { + let backendDict = { + last_updated_msec: 1591296737470.528, + community_owned: false, + objective: 'Test Objective', + id: '44LKoKLlIbGe', + num_views: 0, + thumbnail_icon_url: '/subjects/Algebra.svg', + human_readable_contributors_summary: {}, + language_code: 'en', + thumbnail_bg_color: '#cc4b00', + created_on_msec: 1591296635736.666, + ratings: { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + }, + status: 'public', + tags: [], + activity_type: 'exploration', + category: 'Algebra', + title: 'Test Title', + }; + + let expSummaryObject = + LearnerExplorationSummary.createFromBackendDict(backendDict); + + expect(expSummaryObject.visitedCheckpointsCount).toEqual(0); + expect(expSummaryObject.totalCheckpointsCount).toEqual(0); + }); }); diff --git a/core/templates/domain/summary/learner-exploration-summary.model.ts b/core/templates/domain/summary/learner-exploration-summary.model.ts index 25fc09b6c65b2..b413435900093 100644 --- a/core/templates/domain/summary/learner-exploration-summary.model.ts +++ b/core/templates/domain/summary/learner-exploration-summary.model.ts @@ -43,6 +43,8 @@ export interface LearnerExplorationSummaryBackendDict { thumbnail_bg_color: string; thumbnail_icon_url: string; title: string; + visited_checkpoints_count?: number; + total_checkpoints_count?: number; } export class LearnerExplorationSummary { @@ -62,12 +64,19 @@ export class LearnerExplorationSummary { public lastUpdatedMsec: number, public createdOnMsec: number, public ratings: ExplorationRatings, - public humanReadableContributorsSummary: HumanReadableContributorsSummary + public humanReadableContributorsSummary: HumanReadableContributorsSummary, + public visitedCheckpointsCount: number = 0, + public totalCheckpointsCount: number = 0 ) {} static createFromBackendDict( expSummaryBacknedDict: LearnerExplorationSummaryBackendDict ): LearnerExplorationSummary { + const visitedCheckpointsCount = + expSummaryBacknedDict.visited_checkpoints_count ?? 0; + const totalCheckpointsCount = + expSummaryBacknedDict.total_checkpoints_count ?? 0; + return new LearnerExplorationSummary( expSummaryBacknedDict.category, expSummaryBacknedDict.community_owned, @@ -84,7 +93,9 @@ export class LearnerExplorationSummary { expSummaryBacknedDict.last_updated_msec, expSummaryBacknedDict.created_on_msec, expSummaryBacknedDict.ratings, - expSummaryBacknedDict.human_readable_contributors_summary + expSummaryBacknedDict.human_readable_contributors_summary, + visitedCheckpointsCount, + totalCheckpointsCount ); } } diff --git a/core/templates/domain/topic/editable-topic-backend-api.service.spec.ts b/core/templates/domain/topic/editable-topic-backend-api.service.spec.ts index 9e8803098034a..f3bf602c33272 100644 --- a/core/templates/domain/topic/editable-topic-backend-api.service.spec.ts +++ b/core/templates/domain/topic/editable-topic-backend-api.service.spec.ts @@ -71,7 +71,7 @@ describe('Editable topic backend API service', () => { classroom_name: 'math', curriculum_admin_usernames: ['admin1'], skill_question_count_dict: {}, - subtopic_page: { + subtopic_page_dict: { id: 'topicId-1', topicId: 'topicId', page_contents: { @@ -87,7 +87,7 @@ describe('Editable topic backend API service', () => { }, language_code: 'en', }, - study_guide: { + study_guide_dict: { id: 'topicId-1', topicId: 'topicId', sections: [ @@ -191,7 +191,7 @@ describe('Editable topic backend API service', () => { flushMicrotasks(); expect(successHandler).toHaveBeenCalledWith( - sampleDataResults.subtopic_page + sampleDataResults.subtopic_page_dict ); expect(failHandler).not.toHaveBeenCalled(); })); @@ -237,7 +237,9 @@ describe('Editable topic backend API service', () => { flushMicrotasks(); - expect(successHandler).toHaveBeenCalledWith(sampleDataResults.study_guide); + expect(successHandler).toHaveBeenCalledWith( + sampleDataResults.study_guide_dict + ); expect(failHandler).not.toHaveBeenCalled(); })); diff --git a/core/templates/domain/topic/editable-topic-backend-api.service.ts b/core/templates/domain/topic/editable-topic-backend-api.service.ts index 4b1fc6fba4040..14f6713baacb1 100644 --- a/core/templates/domain/topic/editable-topic-backend-api.service.ts +++ b/core/templates/domain/topic/editable-topic-backend-api.service.ts @@ -72,11 +72,11 @@ interface FetchStoriesBackendResponse { } interface FetchSubtopicPageBackendResponse { - subtopic_page: SubtopicPageBackendDict; + subtopic_page_dict: SubtopicPageBackendDict; } interface FetchStudyGuideBackendResponse { - study_guide: StudyGuideBackendDict; + study_guide_dict: StudyGuideBackendDict; } interface DeleteTopicBackendResponse { @@ -212,7 +212,7 @@ export class EditableTopicBackendApiService { .toPromise() .then( response => { - let topic = response.subtopic_page; + let topic = response.subtopic_page_dict; if (successCallback) { successCallback(topic); } @@ -242,7 +242,7 @@ export class EditableTopicBackendApiService { .toPromise() .then( response => { - let topic = response.study_guide; + let topic = response.study_guide_dict; if (successCallback) { successCallback(topic); } diff --git a/core/templates/domain/voiceover/voiceover-backend-api.service.spec.ts b/core/templates/domain/voiceover/voiceover-backend-api.service.spec.ts index beae4fa369828..9d8c23240af08 100644 --- a/core/templates/domain/voiceover/voiceover-backend-api.service.spec.ts +++ b/core/templates/domain/voiceover/voiceover-backend-api.service.spec.ts @@ -26,7 +26,10 @@ import {VoiceoverBackendApiService} from '../../domain/voiceover/voiceover-backe import {VoiceoverDomainConstants} from './voiceover-domain.constants'; import {EntityVoiceovers} from './entity-voiceovers.model'; import {VoiceoverBackendDict} from 'domain/exploration/voiceover.model'; -import {CloudTaskRun} from 'domain/cloud-task/cloud-task-run.model'; +import { + CloudTaskRun, + CloudTaskRunBackendDict, +} from 'domain/cloud-task/cloud-task-run.model'; describe('Voiceover backend API service', function () { let voiceoverBackendApiService: VoiceoverBackendApiService; @@ -153,7 +156,7 @@ describe('Voiceover backend API service', function () { .then(successHandler, failHandler); let req = httpTestingController.expectOne( - '/regenerate_voiceover_on_exp_update/expId/1/Exp%20title' + '/regenerate_voiceover_on_exp_update/expId/1' ); expect(req.request.method).toEqual('POST'); expect(req.request.body).toEqual(payload); @@ -405,9 +408,9 @@ describe('Voiceover backend API service', function () { let req = httpTestingController.expectOne(expectedUrl); expect(req.request.method).toEqual('GET'); - let automaticVoiceoverRegenerationRecords = [ + let automaticVoiceoverRegenerationRecords: CloudTaskRunBackendDict[] = [ { - id: '123', + task_run_id: '123', cloud_task_name: 'Test Task', latest_job_state: 'RUNNING', function_id: 'function_456', diff --git a/core/templates/domain/voiceover/voiceover-backend-api.service.ts b/core/templates/domain/voiceover/voiceover-backend-api.service.ts index 2f889cf092b16..9f53569bb05aa 100644 --- a/core/templates/domain/voiceover/voiceover-backend-api.service.ts +++ b/core/templates/domain/voiceover/voiceover-backend-api.service.ts @@ -244,7 +244,6 @@ export class VoiceoverBackendApiService { { exploration_id: explorationID, exploration_version: String(explorationVersion), - exploration_title: explorationTitle, } ), {} diff --git a/core/templates/domain/voiceover/voiceover-domain.constants.ts b/core/templates/domain/voiceover/voiceover-domain.constants.ts index 3c72ba877fe84..2ae18141414f7 100644 --- a/core/templates/domain/voiceover/voiceover-domain.constants.ts +++ b/core/templates/domain/voiceover/voiceover-domain.constants.ts @@ -27,7 +27,7 @@ export const VoiceoverDomainConstants = { REGENERATE_AUTOMATIC_VOICEOVER_HANDLER_URL: '/regenerate_automatic_voiceover/', REGENERATE_VOICEOVER_ON_EXP_UPDATE_URL: - '/regenerate_voiceover_on_exp_update///', + '/regenerate_voiceover_on_exp_update//', GET_EXPLORATION_VOICEOVERS_DATA_URL: '/exploration_voiceovers_data/', REGENERATE_VOICEOVERS_FOR_EXPLORATION_URL: diff --git a/core/templates/pages/about-page/about-page-root.component.html b/core/templates/pages/about-page/about-page-root.component.html index 16b78a115e166..21fa5adc716d6 100644 --- a/core/templates/pages/about-page/about-page-root.component.html +++ b/core/templates/pages/about-page/about-page-root.component.html @@ -10,6 +10,7 @@ + diff --git a/core/templates/pages/about-page/about-page.module.ts b/core/templates/pages/about-page/about-page.module.ts index 839ebcd9a8c53..7e1466805418e 100644 --- a/core/templates/pages/about-page/about-page.module.ts +++ b/core/templates/pages/about-page/about-page.module.ts @@ -24,6 +24,7 @@ import {CommonModule} from '@angular/common'; import {SharedComponentsModule} from 'components/shared-component.module'; import {BarChartComponent} from './charts/bar-chart.component'; import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; +import {CampaignBannerModule} from 'components/campaign-banner/campaign-banner-module'; @NgModule({ imports: [ @@ -31,6 +32,7 @@ import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; SharedComponentsModule, AboutPageRoutingModule, NgbModule, + CampaignBannerModule, ], declarations: [AboutPageComponent, AboutPageRootComponent, BarChartComponent], }) diff --git a/core/templates/pages/admin-page/activities-tab/admin-dev-mode-activities-tab.component.spec.ts b/core/templates/pages/admin-page/activities-tab/admin-dev-mode-activities-tab.component.spec.ts index 23e547bdfa07e..d6c93327aa51b 100644 --- a/core/templates/pages/admin-page/activities-tab/admin-dev-mode-activities-tab.component.spec.ts +++ b/core/templates/pages/admin-page/activities-tab/admin-dev-mode-activities-tab.component.spec.ts @@ -31,6 +31,10 @@ import { AdminBackendApiService, AdminPageData, } from 'domain/admin/admin-backend-api.service'; +import {SkillSummary} from 'domain/skill/skill-summary.model'; +import {StoryContents} from 'domain/story/story-contents-object.model'; +import {Story} from 'domain/story/story.model'; +import {CreatorTopicSummary} from 'domain/topic/creator-topic-summary.model'; import {WindowRef} from 'services/contextual/window-ref.service'; import {AdminDataService} from '../services/admin-data.service'; import {AdminTaskManagerService} from '../services/admin-task-manager.service'; @@ -43,31 +47,59 @@ describe('Admin dev mode activities tab', () => { let adminDataService: AdminDataService; let adminTaskManagerService: AdminTaskManagerService; let windowRef: WindowRef; - let adminDataObject = { + let topicSummary = new CreatorTopicSummary( + 'topic_id', + 'Topic Name', + 1, + 1, + 1, + 1, + 0, + 'en', + 'description', + 1, + 0, + 0, + 0, + true, + false, + null, + 'thumbnail.svg', + '#C6DCDA', + 'topic-name', + 0, + 0, + [1], + [1] + ); + let skillSummary = new SkillSummary('skill_id', 'Skill 1', 'en', 1, 0, 0, 0); + let story = new Story( + 'story_id', + 'story_title', + 'description', + '', + new StoryContents('node_1', [], 'node_2'), + 'en', + 1, + 'topic_id', + '#C6DCDA', + 'thumbnail.svg', + 'story-title', + 'meta' + ); + let adminDataObject: AdminPageData = { demoExplorationIds: ['expId'], demoExplorations: [['0', 'welcome.yaml']], demoCollections: [['collectionId']], - skillList: [ - { - id: 'Fg6LbD9h2Eg4', - description: 'Skill1', - }, - ], - topicSummaries: [ - { - id: 'topid_id', - name: 'topic_name', - description: 'description', - }, - ], - storyList: [ - { - id: 'story_id', - title: 'story_title', - description: 'description', - }, - ], - } as AdminPageData; + updatableRoles: [], + roleToActions: {}, + viewableRoles: [], + humanReadableRoles: {}, + topicSummaries: [topicSummary], + platformParameters: [], + skillList: [skillSummary], + storyList: [story], + }; let mockConfirmResult: (val: boolean) => void; beforeEach(async(() => { diff --git a/core/templates/pages/admin-page/admin-auth.guard.spec.ts b/core/templates/pages/admin-page/admin-auth.guard.spec.ts index 61407ead81617..23364a6070e43 100644 --- a/core/templates/pages/admin-page/admin-auth.guard.spec.ts +++ b/core/templates/pages/admin-page/admin-auth.guard.spec.ts @@ -52,6 +52,10 @@ describe('AdminAuthGuard', () => { router = TestBed.inject(Router); }); + afterEach(() => { + window.sessionStorage.clear(); + }); + it('should redirect user to 401 page if user is not super admin', done => { const getUserInfoAsyncSpy = spyOn( userService, diff --git a/core/templates/pages/admin-page/misc-tab/admin-misc-tab.component.spec.ts b/core/templates/pages/admin-page/misc-tab/admin-misc-tab.component.spec.ts index f58114ccdfc8d..196abbeea74c7 100644 --- a/core/templates/pages/admin-page/misc-tab/admin-misc-tab.component.spec.ts +++ b/core/templates/pages/admin-page/misc-tab/admin-misc-tab.component.spec.ts @@ -110,7 +110,6 @@ describe('Admin misc tab component ', () => { // parameter of type 'HTMLImageElement'.". We need to suppress this // error because 'HTMLImageElement' has around 250 more properties. // We have only defined the properties we need in 'mockReaderObject'. - // @ts-expect-error spyOn(window, 'FileReader').and.returnValue(new MockReaderObject()); }); @@ -387,7 +386,6 @@ describe('Admin misc tab component ', () => { // actual 'getElementById' returns more properties than just "files". // We need to suppress this error because we need only "files" // property for testing. - // @ts-expect-error spyOn(document, 'getElementById').and.callFake(() => { return { files: null, @@ -416,12 +414,12 @@ describe('Admin misc tab component ', () => { () => { let message = 'message'; // Pre-checks. - expect(component.showDataExtractionQueryStatus).toBeFalse(); + expect(component.showDataExtractionQueryStatus).toBe(false); expect(component.dataExtractionQueryStatusMessage).toBeUndefined(); component.setDataExtractionQueryStatusMessage(message); - expect(component.showDataExtractionQueryStatus).toBeTrue(); + expect(component.showDataExtractionQueryStatus).toBe(true); expect(component.dataExtractionQueryStatusMessage).toBe(message); } ); @@ -1009,7 +1007,7 @@ describe('Admin misc tab component ', () => { tick(); expect(getAzureAdminConfigSpy).toHaveBeenCalled(); - expect(component.voiceoverAutogenerationIsEnabled).toBeTrue(); + expect(component.voiceoverAutogenerationIsEnabled).toBe(true); })); it('should be able to update azure admin config data', fakeAsync(() => { diff --git a/core/templates/pages/admin-page/navbar/admin-navbar.component.html b/core/templates/pages/admin-page/navbar/admin-navbar.component.html index 1ab8178bb8b17..a133b79a860ca 100644 --- a/core/templates/pages/admin-page/navbar/admin-navbar.component.html +++ b/core/templates/pages/admin-page/navbar/admin-navbar.component.html @@ -85,7 +85,6 @@