Merge branch 'master' into fix-FB-share
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
"rules": {
|
||||
"func-names": "off",
|
||||
"indent": ["error", 4],
|
||||
"react/jsx-indent": ["error", 4],
|
||||
"new-cap": "off",
|
||||
"no-else-return": "off",
|
||||
"no-shadow": "error",
|
||||
@@ -40,23 +41,16 @@
|
||||
"camelcase": "off",
|
||||
"comma-dangle": "off",
|
||||
"consistent-return": "off",
|
||||
"curly": "off",
|
||||
"eqeqeq": "off",
|
||||
"function-call-argument-newline": "off",
|
||||
"function-paren-newline": "off",
|
||||
"implicit-arrow-linebreak": "off",
|
||||
"import/extensions": "off",
|
||||
"import/no-amd": "off",
|
||||
"import/no-dynamic-require": "off",
|
||||
"import/no-unresolved": "off",
|
||||
"linebreak-style": "off",
|
||||
"lines-around-directive": "off",
|
||||
"max-len": "off",
|
||||
"newline-per-chained-call": "off",
|
||||
"no-console": "off",
|
||||
"no-lonely-if": "off",
|
||||
"no-multi-spaces": "off",
|
||||
"no-multiple-empty-lines": "off",
|
||||
"no-param-reassign": "off",
|
||||
"no-proto": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
@@ -70,20 +64,14 @@
|
||||
"no-use-before-define": "off",
|
||||
"no-useless-escape": "off",
|
||||
"no-var": "off",
|
||||
"object-curly-newline": "off",
|
||||
"object-shorthand": "off",
|
||||
"operator-linebreak": "off",
|
||||
"prefer-arrow-callback": "off",
|
||||
"prefer-destructuring": "off",
|
||||
"prefer-rest-params": "off",
|
||||
"prefer-template": "off",
|
||||
"quotes": "off",
|
||||
"radix": "off",
|
||||
"react/jsx-indent": "off",
|
||||
"react/jsx-indent-props": "off",
|
||||
"react/jsx-wrap-multilines": "off",
|
||||
"react/jsx-indent-props": ["error", 4],
|
||||
"react/prop-types": "off",
|
||||
"semi": "off",
|
||||
"vars-on-top": "off"
|
||||
}
|
||||
}
|
||||
|
||||
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@@ -46,3 +46,7 @@ openedx/features/discounts/
|
||||
# Ping tCRIL On-call if someone uses the QuickStart
|
||||
# https://docs.openedx.org/en/latest/developers/quickstarts/first_openedx_pr.html
|
||||
lms/templates/dashboard.html @openedx/tcril-oncall
|
||||
|
||||
# Ensure minimal.yml stays minimal, this could be a team in the future
|
||||
# but it's just me for now, others can sign up if they care as well.
|
||||
lms/envs/minimal.yml @feanil
|
||||
|
||||
9
.github/dependabot.yml
vendored
Normal file
9
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Adding new check for github-actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
reviewers:
|
||||
- "openedx/arbi-bom"
|
||||
2
.github/workflows/check-for-tutorial-prs.yml
vendored
2
.github/workflows/check-for-tutorial-prs.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Comment PR
|
||||
uses: thollander/actions-comment-pull-request@v1
|
||||
uses: thollander/actions-comment-pull-request@v2
|
||||
with:
|
||||
message: |
|
||||
Thank you for your pull request! Congratulations on completing the Open edX tutorial! A team member will be by to take a look shortly.
|
||||
|
||||
4
.github/workflows/ci-static-analysis.yml
vendored
4
.github/workflows/ci-static-analysis.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
- name: Cache pip dependencies
|
||||
id: cache-dependencies
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pip-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements/edx/development.txt') }}
|
||||
|
||||
4
.github/workflows/docs-build-check.yml
vendored
4
.github/workflows/docs-build-check.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
- name: Cache pip dependencies
|
||||
id: cache-dependencies
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pip-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements/edx/development.txt') }}
|
||||
|
||||
4
.github/workflows/js-tests.yml
vendored
4
.github/workflows/js-tests.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
run: sudo apt-get update && sudo apt-get install libxmlsec1-dev ubuntu-restricted-extras xvfb
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
|
||||
- name: Cache pip dependencies
|
||||
id: cache-dependencies
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pip-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements/edx/base.txt') }}
|
||||
|
||||
4
.github/workflows/lint-imports.yml
vendored
4
.github/workflows/lint-imports.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.8'
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Cache pip dependencies
|
||||
id: cache-dependencies
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pip-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements/edx/development.txt') }}
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Cache pip dependencies
|
||||
id: cache-dependencies
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pip-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements/edx/development.txt') }}
|
||||
|
||||
4
.github/workflows/migrations-check.yml
vendored
4
.github/workflows/migrations-check.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Cache pip dependencies
|
||||
id: cache-dependencies
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pip-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements/edx/development.txt') }}
|
||||
|
||||
4
.github/workflows/pylint-checks.yml
vendored
4
.github/workflows/pylint-checks.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
run: sudo apt-get update && sudo apt-get install libxmlsec1-dev
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.8
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
- name: Cache pip dependencies
|
||||
id: cache-dependencies
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pip-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements/edx/development.txt') }}
|
||||
|
||||
4
.github/workflows/quality-checks.yml
vendored
4
.github/workflows/quality-checks.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
run: sudo apt-get update && sudo apt-get install libxmlsec1-dev
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Cache pip dependencies
|
||||
id: cache-dependencies
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pip-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements/edx/testing.txt') }}
|
||||
|
||||
4
.github/workflows/static-assets-check.yml
vendored
4
.github/workflows/static-assets-check.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
- name: Cache pip dependencies
|
||||
id: cache-dependencies
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pip-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements/edx/development.txt') }}
|
||||
|
||||
1
.github/workflows/unit-test-shards.json
vendored
1
.github/workflows/unit-test-shards.json
vendored
@@ -126,6 +126,7 @@
|
||||
"openedx/core/djangoapps/lang_pref/",
|
||||
"openedx/core/djangoapps/models/",
|
||||
"openedx/core/djangoapps/monkey_patch/",
|
||||
"openedx/core/djangoapps/notifications/",
|
||||
"openedx/core/djangoapps/oauth_dispatch/",
|
||||
"openedx/core/djangoapps/olx_rest_api/",
|
||||
"openedx/core/djangoapps/password_policy/",
|
||||
|
||||
43
.github/workflows/unit-tests-gh-hosted.yml
vendored
43
.github/workflows/unit-tests-gh-hosted.yml
vendored
@@ -17,23 +17,24 @@ jobs:
|
||||
python-version: [ '3.8' ]
|
||||
django-version:
|
||||
- "pinned"
|
||||
shard_name: [
|
||||
"lms-1",
|
||||
"lms-2",
|
||||
"lms-3",
|
||||
"lms-4",
|
||||
"lms-5",
|
||||
"lms-6",
|
||||
"openedx-1",
|
||||
"openedx-2",
|
||||
"openedx-3",
|
||||
"openedx-4",
|
||||
"cms-1",
|
||||
"cms-2",
|
||||
"common-1",
|
||||
"common-2",
|
||||
"common-3",
|
||||
]
|
||||
# When updating the shards, remember to make the same changes in
|
||||
# .github/workflows/unit-tests.yml
|
||||
shard_name:
|
||||
- "lms-1"
|
||||
- "lms-2"
|
||||
- "lms-3"
|
||||
- "lms-4"
|
||||
- "lms-5"
|
||||
- "lms-6"
|
||||
- "openedx-1"
|
||||
- "openedx-2"
|
||||
- "openedx-3"
|
||||
- "openedx-4"
|
||||
- "cms-1"
|
||||
- "cms-2"
|
||||
- "common-1"
|
||||
- "common-2"
|
||||
- "xmodule-1"
|
||||
name: gh-hosted-python-${{ matrix.python-version }},django-${{ matrix.django-version }},${{ matrix.shard_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@@ -47,7 +48,7 @@ jobs:
|
||||
mongodb-version: 4.4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -58,7 +59,7 @@ jobs:
|
||||
|
||||
- name: Cache pip dependencies
|
||||
id: cache-dependencies
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pip-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements/edx/testing.txt') }}
|
||||
@@ -92,7 +93,7 @@ jobs:
|
||||
run: sudo apt-get update && sudo apt-get install libxmlsec1-dev
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -103,7 +104,7 @@ jobs:
|
||||
|
||||
- name: Cache pip dependencies
|
||||
id: cache-dependencies
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pip-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements/edx/testing.txt') }}
|
||||
|
||||
4
.github/workflows/unit-tests.yml
vendored
4
.github/workflows/unit-tests.yml
vendored
@@ -18,6 +18,8 @@ jobs:
|
||||
django-version:
|
||||
- "pinned"
|
||||
#- "4.0"
|
||||
# When updating the shards, remember to make the same changes in
|
||||
# .github/workflows/unit-tests-gh-hosted.yml
|
||||
shard_name:
|
||||
- "lms-1"
|
||||
- "lms-2"
|
||||
@@ -80,7 +82,7 @@ jobs:
|
||||
# https://github.com/orgs/community/discussions/33579
|
||||
success:
|
||||
name: Tests successful
|
||||
if: always()
|
||||
if: (github.repository == 'edx/edx-platform-private') || (github.repository == 'openedx/edx-platform' && (startsWith(github.base_ref, 'open-release') == false))
|
||||
needs:
|
||||
- run-tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
97
.github/workflows/upgrade-one-python-dependency.yml
vendored
Normal file
97
.github/workflows/upgrade-one-python-dependency.yml
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
name: Upgrade one Python dependency
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: 'Target branch to create requirements PR against'
|
||||
required: true
|
||||
default: 'master'
|
||||
type: string
|
||||
package:
|
||||
description: 'Name of package to upgrade'
|
||||
required: true
|
||||
type: string
|
||||
change_desc:
|
||||
description: |
|
||||
Description of change, for commit message and PR. (What does the new version add or fix?)
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash # making this explicit opts into -e -o pipefail
|
||||
|
||||
jobs:
|
||||
upgrade-one-python-dependency-workflow:
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
steps:
|
||||
- name: Check out target branch
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: "${{ inputs.branch }}"
|
||||
|
||||
- name: Set up Python environment
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.8"
|
||||
|
||||
- name: Run make upgrade-package
|
||||
env:
|
||||
PACKAGE: "${{ inputs.package }}"
|
||||
run: |
|
||||
make upgrade-package package="$PACKAGE"
|
||||
|
||||
- name: PR preflight
|
||||
env:
|
||||
CHANGE_DESC: "${{ inputs.change_desc }}"
|
||||
run: |
|
||||
if git diff --exit-code; then
|
||||
# Fail early (and avoid quiet failure of create-pull-request action)
|
||||
echo "Error: No changes, so not creating PR." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
else
|
||||
# There are changes to commit, so prep some info for the PR.
|
||||
|
||||
# This is honestly a lot to go through just to say "add two newlines
|
||||
# on the end if the variable isn't empty" but I guess this is what we
|
||||
# have to do to get a conditional delimiter.
|
||||
if [[ -z "$CHANGE_DESC" ]]; then
|
||||
echo "body_prefix=" >> "$GITHUB_ENV"
|
||||
else
|
||||
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
|
||||
echo "body_prefix<<$EOF" >> "$GITHUB_ENV"
|
||||
echo "$CHANGE_DESC" >> "$GITHUB_ENV"
|
||||
echo "" >> "$GITHUB_ENV"
|
||||
echo "" >> "$GITHUB_ENV"
|
||||
echo "$EOF" >> "$GITHUB_ENV"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Make a PR
|
||||
id: make-pr
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
branch: "${{ github.triggering_actor }}/upgrade-${{ inputs.package }}"
|
||||
branch-suffix: short-commit-hash
|
||||
add-paths: requirements
|
||||
commit-message: |
|
||||
feat: Upgrade Python dependency ${{ inputs.package }}
|
||||
|
||||
${{ env.body_prefix }}Commit generated by workflow `${{ github.workflow_ref }}`
|
||||
title: "feat: Upgrade Python dependency ${{ inputs.package }}"
|
||||
body: |
|
||||
${{ env.body_prefix }}PR generated by workflow `${{ github.workflow_ref }}` on behalf of @${{ github.triggering_actor }}.
|
||||
assignees: "${{ github.triggering_actor }}"
|
||||
|
||||
- name: Job summary
|
||||
env:
|
||||
PR_URL: "${{ steps.make-pr.outputs.pull-request-url }}"
|
||||
run: |
|
||||
if [[ -z "$PR_URL" ]]; then
|
||||
echo "PR not created; see log for more information" | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
else
|
||||
echo "PR created or updated: $PR_URL" | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
227
CONTRIBUTING.rst
227
CONTRIBUTING.rst
@@ -1,227 +0,0 @@
|
||||
########################
|
||||
Contributing to the Open edX Platform
|
||||
########################
|
||||
|
||||
Contributions to the Open edX Platform are very welcome, and strongly encouraged! We've
|
||||
put together `some documentation that describes our contribution process`_,
|
||||
but here's a step-by-step guide that should help you get started.
|
||||
|
||||
.. _some documentation that describes our contribution process: https://edx.readthedocs.org/projects/edx-developer-guide/en/latest/process/index.html
|
||||
|
||||
Step 0: Join the Conversation
|
||||
=============================
|
||||
|
||||
Got an idea for how to improve the codebase? Fantastic, we'd love to hear about
|
||||
it! Before you dive in and spend a lot of time and effort making a pull request,
|
||||
it's a good idea to discuss your idea with other interested developers and/or the
|
||||
edX product team. You may get some valuable feedback that changes how you think
|
||||
about your idea, or you may find other developers who have the same idea and want
|
||||
to work together.
|
||||
|
||||
Documentation
|
||||
-------------
|
||||
|
||||
The Open edX documentation is at `https://docs.edx.org
|
||||
<https://docs.edx.org>`_. A number of topics are covered there, from
|
||||
operations to development.
|
||||
|
||||
JIRA
|
||||
----
|
||||
|
||||
If you've got an idea for a new feature or new functionality for an existing feature,
|
||||
please start a discussion in the `development <https://discuss.openedx.org/c/development>`__
|
||||
category of the discussion forum list to get feedback from the community about the idea
|
||||
and your implementation choices.
|
||||
|
||||
If you then plan to contribute your code upstream, please `start a discussion on JIRA`_
|
||||
(you may first need to `create a free JIRA account`_).
|
||||
Start a discussion by visiting the JIRA website and clicking the "Create" button at the
|
||||
top of the page. Choose the project "Open Source Pull Requests" and the issue type
|
||||
"Feature Proposal". In the description give us as much detail as you can for the feature
|
||||
or functionality you are thinking about implementing. Include a link to any relevant
|
||||
forum discussions about your idea. We encourage you to do this before you begin
|
||||
implementing your feature, in order to get valuable feedback from the edX product team
|
||||
early on in your journey and increase the likelihood of a successful pull request.
|
||||
|
||||
.. _start a discussion on JIRA: https://openedx.atlassian.net/secure/Dashboard.jspa
|
||||
.. _create a free JIRA account: https://openedx.atlassian.net/admin/users/sign-up
|
||||
|
||||
.. _forum:
|
||||
|
||||
Discussion forum
|
||||
----------------
|
||||
|
||||
To ask technical questions and chat with the community, do not hesitate to join the
|
||||
`Open edX discussion forum <https://discuss.openedx.org/>`__. There are different
|
||||
categories for different topics:
|
||||
|
||||
- `Site operators <https://discuss.openedx.org/c/operators>`__ to get help running production sites of Open edX
|
||||
- `Development <https://discuss.openedx.org/c/development>`__, where Open edX developers
|
||||
unite
|
||||
- `Community <https://discuss.openedx.org/c/community>`__ to discuss organizational
|
||||
matters in the open source community
|
||||
- `Announcements <https://discuss.openedx.org/c/announcements>`__ where official Open edX
|
||||
announcement are made
|
||||
- `Educators <https://discuss.openedx.org/c/educators>`__, to discuss online learning in general
|
||||
|
||||
Slack
|
||||
-----
|
||||
|
||||
To talk with others in the Open edX community, join us on `Slack`_.
|
||||
`Sign up for a free account`_ and join the conversation!
|
||||
The group tends to be most active Monday through Friday
|
||||
between 13:00 and 21:00 UTC (9am to 5pm US Eastern time),
|
||||
but interesting conversations can happen at any time.
|
||||
There are many different channels available for different topics, including:
|
||||
|
||||
* ``#events`` for upcoming events related to Open edX project
|
||||
* ``#content`` for discussions about course content and creating the best courses
|
||||
|
||||
And lots more! You can also make your own channels to discuss new topics.
|
||||
|
||||
Note that Slack is no longer the recommended communication channel to ask about
|
||||
technical issues. To do so, you should instead join the `official forum <#forum>`__.
|
||||
|
||||
.. _Slack: https://slack.com/
|
||||
.. _Sign up for a free account: https://openedx.org/slack
|
||||
|
||||
Byte-sized Tasks & Bugs
|
||||
-----------------------
|
||||
|
||||
If you are contributing for the first time and want a gentle introduction,
|
||||
or if you aren't sure what to work on, have a look at the list of
|
||||
`byte-sized bugs and tasks`_ in the tracker. These tasks are selected for their
|
||||
small size, and usually don't require a broad knowledge of the edX platform.
|
||||
It makes them good candidates for a first task, allowing you to focus on getting
|
||||
familiar with the development environment and the contribution process.
|
||||
|
||||
.. _byte-sized bugs and tasks: https://github.com/search?q=user%3Aopenedx+label%3A%22help+wanted%22&type=Issues&ref=advsearch&l=&l=
|
||||
|
||||
Once you have identified a bug or task, `create an account on the tracker`_ and
|
||||
then comment on the ticket to indicate that you are working on it. Don't hesitate
|
||||
to ask clarifying questions on the ticket as needed, too, if anything is unclear.
|
||||
|
||||
.. _create an account on the tracker: https://openedx.atlassian.net/admin/users/sign-up
|
||||
|
||||
Step 1: Sign a Contribution Agreement
|
||||
=====================================
|
||||
|
||||
Before edX can accept any code contributions from you, you'll need to sign the
|
||||
`Individual Contributor Agreement`_. This confirms that you have the authority
|
||||
to contribute the code in the pull request and ensures that edX can re-license
|
||||
it.
|
||||
|
||||
.. _Individual Contributor Agreement: https://openedx.org/cla
|
||||
|
||||
If you will be contributing code on behalf of your employer or another
|
||||
institution you are affiliated with, please reach out by email to oscm@tcril.org
|
||||
to request the Entity Contributor Agreement.
|
||||
|
||||
Once we have received and processed your agreement, we will reach out to you by
|
||||
email to confirm. After that we can begin reviewing and merging your work.
|
||||
|
||||
Step 2: Fork, Commit, and Pull Request
|
||||
======================================
|
||||
|
||||
GitHub has some great documentation on `how to fork a git repository`_. Once
|
||||
you've done that, make your changes and `send us a pull request`_! Be sure to
|
||||
include a detailed description for your pull request, so that a community
|
||||
manager can understand *what* change you're making, *why* you're making it, *how*
|
||||
it should work now, and how you can *test* that it's working correctly.
|
||||
|
||||
.. _how to fork a git repository: https://help.github.com/articles/fork-a-repo
|
||||
.. _send us a pull request: https://help.github.com/articles/creating-a-pull-request
|
||||
|
||||
Step 3: Meet PR Requirements
|
||||
============================
|
||||
|
||||
Our `contributor documentation`_ includes a long list of requirements that pull
|
||||
requests must meet in order to be reviewed by a core committer. These requirements
|
||||
include things like documentation and passing tests: see the
|
||||
`contributor documentation`_ page for the full list.
|
||||
|
||||
.. _contributor documentation: https://edx.readthedocs.org/projects/edx-developer-guide/en/latest/process/contributor.html
|
||||
|
||||
|
||||
Areas of particular concern with their own detailed guidelines are:
|
||||
|
||||
* `Accessibility`_: making sure our applications can
|
||||
be used by people with disabilities, in keeping with the edX
|
||||
`website accessibility policy`_.
|
||||
* `Internationalization`_: enabling translation for use
|
||||
around the world.
|
||||
|
||||
|
||||
.. _Accessibility: https://edx.readthedocs.org/projects/edx-developer-guide/en/latest/conventions/accessibility.html
|
||||
.. _website accessibility policy: https://www.edx.org/accessibility
|
||||
.. _Internationalization: https://edx.readthedocs.io/projects/edx-developer-guide/en/latest/internationalization/index.html
|
||||
|
||||
Step 4: Approval by Community Manager and Product Owner
|
||||
=======================================================
|
||||
|
||||
A community manager will read the description of your pull request. If the
|
||||
description is understandable, the community manager will send the pull request
|
||||
to a product owner. The product owner will evaluate if the pull request is a
|
||||
good idea for the Open edX platform, and if not, your pull request will be rejected. This
|
||||
is another good reason why you should discuss your ideas with other members
|
||||
of the community before working on a pull request!
|
||||
|
||||
Step 5: Code Review by Core Committer(s)
|
||||
========================================
|
||||
|
||||
If your pull request meets the requirements listed in the
|
||||
`contributor documentation`_, and it hasn't been rejected by a product owner,
|
||||
then it will be scheduled for code review by one or more core committers. This
|
||||
process sometimes takes awhile: most of the core committers on the project
|
||||
are employees of edX, and they have to balance their time between code review
|
||||
and new development.
|
||||
|
||||
Once the code review process has started, please be responsive to comments on
|
||||
the pull request, so we can keep the review process moving forward.
|
||||
If you are unable to respond for a few days, that's fine, but
|
||||
please add a comment informing us of that -- otherwise, it looks like you're
|
||||
abandoning your work!
|
||||
|
||||
Step 6: Merge!
|
||||
==============
|
||||
|
||||
Once the core committers are satisfied that your pull request is ready to go,
|
||||
one of them will merge it for you. Your code will end up on the edX production
|
||||
servers in the next release, which usually which happens every week. Congrats!
|
||||
|
||||
|
||||
############################
|
||||
Expectations We Have of You
|
||||
############################
|
||||
|
||||
By opening up a pull request, we expect the following things:
|
||||
|
||||
1. You've read and understand the instructions in this contributing file and
|
||||
the contribution process documentation.
|
||||
|
||||
2. You are ready to engage with the edX community. Engaging means you will be
|
||||
prompt in following up with review comments and critiques. Do not open up a
|
||||
pull request right before a vacation or heavy workload that will render you
|
||||
unable to participate in the review process.
|
||||
|
||||
3. If you have questions, you will ask them by either commenting on the pull
|
||||
request or asking us in the discussion forum or on Slack.
|
||||
|
||||
4. If you do not respond to comments on your pull request within 7 days, we
|
||||
will close it. You are welcome to re-open it when you are ready to engage.
|
||||
|
||||
############################
|
||||
Expectations You Have of Us
|
||||
############################
|
||||
|
||||
1. Within a week of opening up a pull request, one of our community managers
|
||||
will triage it, starting the documented contribution process. (Please
|
||||
give us a little extra time if you open the PR on a weekend or
|
||||
around a US holiday! We may take a little longer getting to it.)
|
||||
|
||||
2. We promise to engage in an active dialogue with you from the time we begin
|
||||
reviewing until either the PR is merged (by a core committer), or we
|
||||
decide that, for whatever reason, it should be closed.
|
||||
|
||||
3. Once we have determined through visual review that your code is not
|
||||
malicious, we will run a Jenkins build on your branch.
|
||||
14
Makefile
14
Makefile
@@ -1,6 +1,10 @@
|
||||
# Do things in edx-platform
|
||||
.PHONY: clean extract_translations help pull pull_translations push_translations requirements shell upgrade
|
||||
.PHONY: api-docs docs guides swagger
|
||||
.PHONY: api-docs-sphinx api-docs base-requirements check-types clean \
|
||||
compile-requirements detect_changed_source_translations dev-requirements \
|
||||
docker_auth docker_build docker_push docker_tag docs extract_translations \
|
||||
guides help lint-imports local-requirements pre-requirements pull \
|
||||
pull_translations push_translations requirements shell swagger \
|
||||
technical-docs test-requirements ubuntu-requirements upgrade-package upgrade
|
||||
|
||||
# Careful with mktemp syntax: it has to work on Mac and Ubuntu, which have differences.
|
||||
PRIVATE_FILES := $(shell mktemp -u /tmp/private_files.XXXXXX)
|
||||
@@ -113,7 +117,7 @@ COMMON_CONSTRAINTS_TXT=requirements/common_constraints.txt
|
||||
.PHONY: $(COMMON_CONSTRAINTS_TXT)
|
||||
$(COMMON_CONSTRAINTS_TXT):
|
||||
wget -O "$(@)" https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt || touch "$(@)"
|
||||
echo "$(COMMON_CONSTRAINTS_TEMP_COMMENT)" | cat - $(@) > temp && mv temp $(@)
|
||||
printf "$(COMMON_CONSTRAINTS_TEMP_COMMENT)" | cat - $(@) > temp && mv temp $(@)
|
||||
|
||||
compile-requirements: export CUSTOM_COMPILE_COMMAND=make upgrade
|
||||
compile-requirements: pre-requirements $(COMMON_CONSTRAINTS_TXT) ## Re-compile *.in requirements to *.txt
|
||||
@@ -138,6 +142,10 @@ compile-requirements: pre-requirements $(COMMON_CONSTRAINTS_TXT) ## Re-compile *
|
||||
upgrade: ## update the pip requirements files to use the latest releases satisfying our constraints
|
||||
$(MAKE) compile-requirements COMPILE_OPTS="--upgrade"
|
||||
|
||||
upgrade-package: ## update just one package to the latest usable release
|
||||
@test -n "$(package)" || { echo "\nUsage: make upgrade_package package=...\n"; exit 1; }
|
||||
$(MAKE) compile-requirements COMPILE_OPTS="--upgrade-package $(package)"
|
||||
|
||||
check-types: ## run static type-checking tests
|
||||
mypy
|
||||
|
||||
|
||||
51
README.rst
51
README.rst
@@ -1,4 +1,3 @@
|
||||
#################
|
||||
Open edX Platform
|
||||
#################
|
||||
| |License: AGPL v3| |Status| |Python CI|
|
||||
@@ -12,7 +11,7 @@ Open edX Platform
|
||||
.. |Status| image:: https://img.shields.io/badge/status-maintained-31c653
|
||||
|
||||
Purpose
|
||||
-------
|
||||
*******
|
||||
The `Open edX Platform <https://openedx.org>`_ is a service-oriented platform for authoring and
|
||||
delivering online learning at any scale. The platform is written in
|
||||
Python and JavaScript and makes extensive use of the Django
|
||||
@@ -27,7 +26,7 @@ platform. Functionally, the edx-platform repository provides two services:
|
||||
* LMS (Learning Management Service), which delivers learning content.
|
||||
|
||||
Installation
|
||||
------------
|
||||
************
|
||||
|
||||
Installing and running an Open edX instance is not simple. We strongly
|
||||
recommend that you use a service provider to run the software for you. They
|
||||
@@ -43,8 +42,28 @@ so, `Open edX Installation Options`_ explains your options.
|
||||
.. _Open edX Developer Stack: https://github.com/openedx/devstack
|
||||
.. _Open edX Installation Options: https://openedx.atlassian.net/wiki/spaces/OpenOPS/pages/60227779/Open+edX+Installation+Options
|
||||
|
||||
Dependencies
|
||||
============
|
||||
|
||||
In order to build and run this code you'll need the following available on your
|
||||
system:
|
||||
|
||||
Interperters/Tools:
|
||||
|
||||
* Python 3.8
|
||||
|
||||
* Node 16
|
||||
|
||||
Services:
|
||||
|
||||
* MySQL 5.7
|
||||
|
||||
* Mongo 4.x
|
||||
|
||||
* Memcached
|
||||
|
||||
License
|
||||
-------
|
||||
*******
|
||||
|
||||
The code in this repository is licensed under version 3 of the AGPL
|
||||
unless otherwise noted. Please see the `LICENSE`_ file for details.
|
||||
@@ -53,7 +72,7 @@ unless otherwise noted. Please see the `LICENSE`_ file for details.
|
||||
|
||||
|
||||
More about Open edX
|
||||
-------------------
|
||||
*******************
|
||||
|
||||
See the `Open edX site`_ to learn more about the Open edX world. You can find
|
||||
information about hosting, extending, and contributing to Open edX software. In
|
||||
@@ -63,13 +82,13 @@ and other rich community resources.
|
||||
.. _Open edX site: https://openedx.org
|
||||
|
||||
Documentation
|
||||
-------------
|
||||
*************
|
||||
|
||||
Documentation can be found at https://docs.edx.org.
|
||||
Documentation can be found at https://docs.openedx.org.
|
||||
|
||||
|
||||
Getting Help
|
||||
------------
|
||||
************
|
||||
|
||||
If you're having trouble, we have discussion forums at
|
||||
https://discuss.openedx.org where you can connect with others in the community.
|
||||
@@ -85,18 +104,18 @@ For more information about these options, see the `Getting Help`_ page.
|
||||
|
||||
|
||||
Issue Tracker
|
||||
-------------
|
||||
*************
|
||||
|
||||
We use JIRA for our issue tracker, not GitHub issues. You can search
|
||||
`previously reported issues`_. If you need to report a problem,
|
||||
please make a free account on our JIRA and `create a new issue`_.
|
||||
We use Github Issues for our issue tracker. You can search
|
||||
`previously reported issues`_. If you need to report a bug, or want to discuss
|
||||
a new feature before you implement it, please `create a new issue`_.
|
||||
|
||||
.. _previously reported issues: https://openedx.atlassian.net/projects/CRI/issues
|
||||
.. _create a new issue: https://openedx.atlassian.net/secure/CreateIssue.jspa?issuetype=1&pid=11900
|
||||
.. _previously reported issues: https://github.com/openedx/edx-platform/issues
|
||||
.. _create a new issue: https://github.com/openedx/edx-platform/issues/new/choose
|
||||
|
||||
|
||||
How to Contribute
|
||||
-----------------
|
||||
*****************
|
||||
|
||||
Contributions are welcome! The first step is to submit a signed
|
||||
`individual contributor agreement`_. See our `CONTRIBUTING`_ file for more
|
||||
@@ -105,7 +124,7 @@ quality, which will make your contribution more likely to be accepted.
|
||||
|
||||
|
||||
Reporting Security Issues
|
||||
-------------------------
|
||||
*************************
|
||||
|
||||
Please do not report security issues in public. Please email
|
||||
security@edx.org.
|
||||
|
||||
@@ -70,12 +70,20 @@ def register_exams(course_key):
|
||||
timed_exam.is_practice_exam,
|
||||
timed_exam.is_onboarding_exam
|
||||
)
|
||||
|
||||
exams_list.append({
|
||||
'course_id': str(course_key),
|
||||
'content_id': str(timed_exam.location),
|
||||
'exam_name': timed_exam.display_name,
|
||||
'time_limit_mins': timed_exam.default_time_limit_minutes,
|
||||
'due_date': timed_exam.due.isoformat() if timed_exam.due and not course.self_paced else None,
|
||||
# If the subsection has no due date, then infer a due date from the course end date. This behavior is a
|
||||
# departure from the legacy register_exams function used by the edx-proctoring plugin because
|
||||
# edx-proctoring makes a direct call to edx-when API when computing an exam's due date.
|
||||
# By sending the course end date when registering exams, we can avoid calling to the platform from the
|
||||
# exam service. Also note that we no longer consider the pacing type of the course - this applies to both
|
||||
# self-paced and indstructor-paced courses. Therefore, this effectively opts out exams powered by edx-exams
|
||||
# from personalized learner schedules/relative dates.
|
||||
'due_date': timed_exam.due.isoformat() if timed_exam.due else course.end.isoformat(),
|
||||
'exam_type': exam_type,
|
||||
'is_active': True,
|
||||
'hide_after_due': timed_exam.hide_after_due,
|
||||
|
||||
@@ -25,7 +25,7 @@ def print_course(course):
|
||||
print('num type name')
|
||||
for index, item in enumerate(course.tabs):
|
||||
print(index + 1, '"' + item.get('type') + '"', '"' + item.get('name', '') + '"')
|
||||
# If a course is bad we will get an error descriptor here, dump it and die instead of
|
||||
# If a course is bad we will get an error here, dump it and die instead of
|
||||
# just sending up the error that .id doesn't exist.
|
||||
except AttributeError:
|
||||
print(course)
|
||||
|
||||
@@ -81,3 +81,13 @@ class CourseAdvanceSettingViewTest(CourseTestCase, MilestonesTestCaseMixin):
|
||||
assert field in content.keys()
|
||||
for field in absent_fields:
|
||||
assert field not in content.keys()
|
||||
|
||||
@ddt.data(
|
||||
("ENABLE_EDXNOTES", "edxnotes"),
|
||||
("ENABLE_OTHER_COURSE_SETTINGS", "other_course_settings"),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_disabled_fetch_all_query_param(self, setting, excluded_field):
|
||||
with override_settings(FEATURES={setting: False}):
|
||||
resp = self.client.get(self.url, {"fetch_all": 0})
|
||||
assert excluded_field not in resp.data
|
||||
|
||||
@@ -10,6 +10,7 @@ from rest_framework.views import APIView
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
|
||||
from cms.djangoapps.contentstore.api.views.utils import get_bool_param
|
||||
from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
|
||||
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes
|
||||
from ..serializers import CourseAdvancedSettingsSerializer
|
||||
@@ -39,9 +40,14 @@ class AdvancedCourseSettingsView(DeveloperErrorViewMixin, APIView):
|
||||
apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"),
|
||||
apidocs.string_parameter(
|
||||
"filter_fields",
|
||||
apidocs.ParameterLocation.PATH,
|
||||
apidocs.ParameterLocation.QUERY,
|
||||
description="Comma separated list of fields to filter",
|
||||
),
|
||||
apidocs.string_parameter(
|
||||
"fetch_all",
|
||||
apidocs.ParameterLocation.QUERY,
|
||||
description="Specifies whether to fetch all settings or only enabled ones",
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: CourseAdvancedSettingsSerializer,
|
||||
@@ -112,7 +118,13 @@ class AdvancedCourseSettingsView(DeveloperErrorViewMixin, APIView):
|
||||
if not has_studio_read_access(request.user, course_key):
|
||||
self.permission_denied(request)
|
||||
course_block = modulestore().get_course(course_key)
|
||||
return Response(CourseMetadata.fetch_all(
|
||||
fetch_all = get_bool_param(request, 'fetch_all', True)
|
||||
if fetch_all:
|
||||
return Response(CourseMetadata.fetch_all(
|
||||
course_block,
|
||||
filter_fields=filter_query_data.cleaned_data['filter_fields'],
|
||||
))
|
||||
return Response(CourseMetadata.fetch(
|
||||
course_block,
|
||||
filter_fields=filter_query_data.cleaned_data['filter_fields'],
|
||||
))
|
||||
|
||||
42
cms/djangoapps/contentstore/rest_api/v1/mixins.py
Normal file
42
cms/djangoapps/contentstore/rest_api/v1/mixins.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Common mixins for module.
|
||||
"""
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
class PermissionAccessMixin:
|
||||
"""
|
||||
Mixin for testing permission access for views.
|
||||
"""
|
||||
|
||||
def get_and_check_developer_response(self, response):
|
||||
"""
|
||||
Make basic asserting about the presence of an error response, and return the developer response.
|
||||
"""
|
||||
content = json.loads(response.content.decode("utf-8"))
|
||||
assert "developer_message" in content
|
||||
return content["developer_message"]
|
||||
|
||||
def test_permissions_unauthenticated(self):
|
||||
"""
|
||||
Test that an error is returned in the absence of auth credentials.
|
||||
"""
|
||||
self.client.logout()
|
||||
response = self.client.get(self.url)
|
||||
error = self.get_and_check_developer_response(response)
|
||||
self.assertEqual(error, "Authentication credentials were not provided.")
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
@patch.dict('django.conf.settings.FEATURES', {'DISABLE_ADVANCED_SETTINGS': True})
|
||||
def test_permissions_unauthorized(self):
|
||||
"""
|
||||
Test that an error is returned if the user is unauthorised.
|
||||
"""
|
||||
client, _ = self.create_non_staff_authed_user_client()
|
||||
response = client.get(self.url)
|
||||
error = self.get_and_check_developer_response(response)
|
||||
self.assertEqual(error, "You do not have permission to perform this action.")
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Serializers for v1 contentstore API.
|
||||
"""
|
||||
from .settings import CourseSettingsSerializer
|
||||
from .course_details import CourseDetailsSerializer
|
||||
from .proctoring import (
|
||||
LimitedProctoredExamSettingsSerializer,
|
||||
ProctoredExamConfigurationSerializer,
|
||||
ProctoredExamSettingsSerializer,
|
||||
ProctoringErrorsSerializer,
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
API Serializers for course details
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from openedx.core.lib.api.serializers import CourseKeyField
|
||||
|
||||
|
||||
class InstructorInfoSerializer(serializers.Serializer):
|
||||
""" Serializer for instructor info """
|
||||
name = serializers.CharField(allow_blank=True)
|
||||
title = serializers.CharField(allow_blank=True)
|
||||
organization = serializers.CharField(allow_blank=True)
|
||||
image = serializers.CharField(allow_blank=True)
|
||||
bio = serializers.CharField(allow_blank=True)
|
||||
|
||||
|
||||
class InstructorsSerializer(serializers.Serializer):
|
||||
""" Serializer for instructors """
|
||||
instructors = InstructorInfoSerializer(many=True, allow_empty=True)
|
||||
|
||||
|
||||
class CourseDetailsSerializer(serializers.Serializer):
|
||||
""" Serializer for course details """
|
||||
about_sidebar_html = serializers.CharField(allow_null=True, allow_blank=True)
|
||||
banner_image_name = serializers.CharField(allow_blank=True)
|
||||
banner_image_asset_path = serializers.CharField()
|
||||
certificate_available_date = serializers.DateTimeField()
|
||||
certificates_display_behavior = serializers.CharField(allow_null=True)
|
||||
course_id = serializers.CharField()
|
||||
course_image_asset_path = serializers.CharField(allow_blank=True)
|
||||
course_image_name = serializers.CharField(allow_blank=True)
|
||||
description = serializers.CharField(allow_blank=True)
|
||||
duration = serializers.CharField(allow_blank=True)
|
||||
effort = serializers.CharField(allow_null=True, allow_blank=True)
|
||||
end_date = serializers.DateTimeField(allow_null=True)
|
||||
enrollment_end = serializers.DateTimeField(allow_null=True)
|
||||
enrollment_start = serializers.DateTimeField(allow_null=True)
|
||||
entrance_exam_enabled = serializers.CharField(allow_blank=True)
|
||||
entrance_exam_id = serializers.CharField(allow_blank=True)
|
||||
entrance_exam_minimum_score_pct = serializers.CharField(allow_blank=True)
|
||||
instructor_info = InstructorsSerializer()
|
||||
intro_video = serializers.CharField(allow_null=True)
|
||||
language = serializers.CharField(allow_null=True)
|
||||
learning_info = serializers.ListField(child=serializers.CharField(allow_blank=True))
|
||||
license = serializers.CharField(allow_null=True)
|
||||
org = serializers.CharField()
|
||||
overview = serializers.CharField(allow_blank=True)
|
||||
pre_requisite_courses = serializers.ListField(child=CourseKeyField())
|
||||
run = serializers.CharField()
|
||||
self_paced = serializers.BooleanField()
|
||||
short_description = serializers.CharField(allow_blank=True)
|
||||
start_date = serializers.DateTimeField()
|
||||
subtitle = serializers.CharField(allow_blank=True)
|
||||
syllabus = serializers.CharField(allow_null=True)
|
||||
title = serializers.CharField(allow_blank=True)
|
||||
video_thumbnail_image_asset_path = serializers.CharField()
|
||||
video_thumbnail_image_name = serializers.CharField(allow_blank=True)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
API Serializers for Contentstore
|
||||
API Serializers for proctoring
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
@@ -29,3 +29,31 @@ class ProctoredExamConfigurationSerializer(serializers.Serializer):
|
||||
proctored_exam_settings = ProctoredExamSettingsSerializer()
|
||||
available_proctoring_providers = serializers.ChoiceField(get_available_providers())
|
||||
course_start_date = serializers.DateTimeField()
|
||||
|
||||
|
||||
class ProctoringErrorModelSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for proctoring error model item.
|
||||
"""
|
||||
deprecated = serializers.BooleanField()
|
||||
display_name = serializers.CharField()
|
||||
help = serializers.CharField()
|
||||
hide_on_enabled_publisher = serializers.BooleanField()
|
||||
value = serializers.CharField()
|
||||
|
||||
|
||||
class ProctoringErrorListSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for proctoring error list.
|
||||
"""
|
||||
key = serializers.CharField()
|
||||
message = serializers.CharField()
|
||||
model = ProctoringErrorModelSerializer()
|
||||
|
||||
|
||||
class ProctoringErrorsSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for proctoring errors with url to proctored exam settings.
|
||||
"""
|
||||
mfe_proctored_exam_settings_url = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
proctoring_errors = ProctoringErrorListSerializer(many=True)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
API Serializers for course settings
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from openedx.core.lib.api.serializers import CourseKeyField
|
||||
|
||||
|
||||
class PossiblePreRequisiteCourseSerializer(serializers.Serializer):
|
||||
""" Serializer for possible pre requisite course """
|
||||
course_key = CourseKeyField()
|
||||
display_name = serializers.CharField()
|
||||
lms_link = serializers.CharField()
|
||||
number = serializers.CharField()
|
||||
org = serializers.CharField()
|
||||
rerun_link = serializers.CharField()
|
||||
run = serializers.CharField()
|
||||
url = serializers.CharField()
|
||||
|
||||
|
||||
class CourseSettingsSerializer(serializers.Serializer):
|
||||
""" Serializer for course settings """
|
||||
about_page_editable = serializers.BooleanField()
|
||||
can_show_certificate_available_date_field = serializers.BooleanField()
|
||||
course_display_name = serializers.CharField()
|
||||
course_display_name_with_default = serializers.CharField()
|
||||
credit_eligibility_enabled = serializers.BooleanField()
|
||||
credit_requirements = serializers.DictField(required=False)
|
||||
enable_extended_course_details = serializers.BooleanField()
|
||||
enrollment_end_editable = serializers.BooleanField()
|
||||
is_credit_course = serializers.BooleanField()
|
||||
is_entrance_exams_enabled = serializers.BooleanField()
|
||||
is_prerequisite_courses_enabled = serializers.BooleanField()
|
||||
language_options = serializers.ListField(child=serializers.ListField(child=serializers.CharField()))
|
||||
lms_link_for_about_page = serializers.URLField()
|
||||
marketing_enabled = serializers.BooleanField()
|
||||
mfe_proctored_exam_settings_url = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
possible_pre_requisite_courses = PossiblePreRequisiteCourseSerializer(required=False, many=True)
|
||||
short_description_editable = serializers.BooleanField()
|
||||
show_min_grade_warning = serializers.BooleanField()
|
||||
sidebar_html_enabled = serializers.BooleanField()
|
||||
upgrade_deadline = serializers.DateTimeField(allow_null=True)
|
||||
use_v2_cert_display_settings = serializers.BooleanField()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Unit tests for course details views.
|
||||
"""
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import ddt
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
|
||||
|
||||
from ..mixins import PermissionAccessMixin
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class CourseDetailsViewTest(CourseTestCase, PermissionAccessMixin):
|
||||
"""
|
||||
Tests for CourseDetailsView.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.url = reverse(
|
||||
'cms.djangoapps.contentstore:v1:course_details',
|
||||
kwargs={"course_id": self.course.id},
|
||||
)
|
||||
|
||||
def test_put_permissions_unauthenticated(self):
|
||||
"""
|
||||
Test that an error is returned in the absence of auth credentials.
|
||||
"""
|
||||
self.client.logout()
|
||||
response = self.client.put(self.url)
|
||||
error = self.get_and_check_developer_response(response)
|
||||
self.assertEqual(error, "Authentication credentials were not provided.")
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_put_permissions_unauthorized(self):
|
||||
"""
|
||||
Test that an error is returned if the user is unauthorised.
|
||||
"""
|
||||
client, _ = self.create_non_staff_authed_user_client()
|
||||
response = client.put(self.url)
|
||||
error = self.get_and_check_developer_response(response)
|
||||
self.assertEqual(error, "You do not have permission to perform this action.")
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True})
|
||||
def test_put_invalid_pre_requisite_course(self):
|
||||
pre_requisite_course_keys = [str(self.course.id), 'invalid_key']
|
||||
request_data = {"pre_requisite_courses": pre_requisite_course_keys}
|
||||
response = self.client.put(path=self.url, data=json.dumps(request_data), content_type="application/json")
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(response.json()['error'], 'Invalid prerequisite course key')
|
||||
|
||||
def test_put_course_details(self):
|
||||
request_data = {
|
||||
"about_sidebar_html": "",
|
||||
"banner_image_name": "images_course_image.jpg",
|
||||
"banner_image_asset_path": "/asset-v1:edX+E2E-101+course+type@asset+block@images_course_image.jpg",
|
||||
"certificate_available_date": "2029-01-02T00:00:00Z",
|
||||
"certificates_display_behavior": "end",
|
||||
"course_id": "E2E-101",
|
||||
"course_image_asset_path": "/static/studio/images/pencils.jpg",
|
||||
"course_image_name": "bar_course_image_name",
|
||||
"description": "foo_description",
|
||||
"duration": "",
|
||||
"effort": None,
|
||||
"end_date": "2023-08-01T01:30:00Z",
|
||||
"enrollment_end": "2023-05-30T01:00:00Z",
|
||||
"enrollment_start": "2023-05-29T01:00:00Z",
|
||||
"entrance_exam_enabled": "",
|
||||
"entrance_exam_id": "",
|
||||
"entrance_exam_minimum_score_pct": "50",
|
||||
"intro_video": None,
|
||||
"language": "creative-commons: ver=4.0 BY NC ND",
|
||||
"learning_info": [
|
||||
"foo",
|
||||
"bar"
|
||||
],
|
||||
"license": "creative-commons: ver=4.0 BY NC ND",
|
||||
"org": "edX",
|
||||
"overview": "<section class=\"about\"></section>",
|
||||
"pre_requisite_courses": [],
|
||||
"run": "course",
|
||||
"self_paced": None,
|
||||
"short_description": "",
|
||||
"start_date": "2023-06-01T01:30:00Z",
|
||||
"subtitle": "",
|
||||
"syllabus": None,
|
||||
"title": "",
|
||||
"video_thumbnail_image_asset_path": "/asset-v1:edX+E2E-101+course+type@asset+block@images_course_image.jpg",
|
||||
"video_thumbnail_image_name": "images_course_image.jpg",
|
||||
"instructor_info": {
|
||||
"instructors": [
|
||||
{
|
||||
"name": "foo bar",
|
||||
"title": "title",
|
||||
"organization": "org",
|
||||
"image": "image",
|
||||
"bio": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
response = self.client.put(path=self.url, data=json.dumps(request_data), content_type="application/json")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
Unit tests for Contentstore views.
|
||||
"""
|
||||
|
||||
import ddt
|
||||
from mock import patch
|
||||
from django.conf import settings
|
||||
@@ -12,6 +11,7 @@ from opaque_keys.edx.keys import CourseKey
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
|
||||
from common.djangoapps.student.tests.factories import GlobalStaffFactory
|
||||
from common.djangoapps.student.tests.factories import InstructorFactory
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
@@ -20,6 +20,8 @@ from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disa
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
from ..mixins import PermissionAccessMixin
|
||||
|
||||
|
||||
class ProctoringExamSettingsTestMixin():
|
||||
""" setup for proctored exam settings tests """
|
||||
@@ -437,3 +439,28 @@ class ProctoringExamSettingsPostTests(ProctoringExamSettingsTestMixin, ModuleSto
|
||||
updated = modulestore().get_item(self.course.location)
|
||||
assert updated.enable_proctored_exams is False
|
||||
assert updated.proctoring_provider == 'null'
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class CourseProctoringErrorsViewTest(CourseTestCase, PermissionAccessMixin):
|
||||
"""
|
||||
Tests for ProctoringErrorsView.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.url = reverse(
|
||||
'cms.djangoapps.contentstore:v1:proctoring_errors',
|
||||
kwargs={"course_id": self.course.id},
|
||||
)
|
||||
self.non_staff_client, _ = self.create_non_staff_authed_user_client()
|
||||
|
||||
@ddt.data(False, True)
|
||||
def test_disable_advanced_settings_feature(self, disable_advanced_settings):
|
||||
"""
|
||||
If this feature is enabled, only Django Staff/Superuser should be able to see the proctoring errors.
|
||||
For non-staff users the proctoring errors should be unavailable.
|
||||
"""
|
||||
with override_settings(FEATURES={'DISABLE_ADVANCED_SETTINGS': disable_advanced_settings}):
|
||||
response = self.non_staff_client.get(self.url)
|
||||
self.assertEqual(response.status_code, 403 if disable_advanced_settings else 200)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Unit tests for course settings views.
|
||||
"""
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from mock import patch
|
||||
from rest_framework import status
|
||||
|
||||
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
|
||||
from cms.djangoapps.contentstore.utils import get_proctored_exam_settings_url
|
||||
from common.djangoapps.util.course import get_link_for_about_page
|
||||
from openedx.core.djangoapps.credit.tests.factories import CreditCourseFactory
|
||||
|
||||
from ..mixins import PermissionAccessMixin
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class CourseSettingsViewTest(CourseTestCase, PermissionAccessMixin):
|
||||
"""
|
||||
Tests for CourseSettingsView.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.url = reverse(
|
||||
'cms.djangoapps.contentstore:v1:course_settings',
|
||||
kwargs={"course_id": self.course.id},
|
||||
)
|
||||
|
||||
def test_course_settings_response(self):
|
||||
""" Check successful response content """
|
||||
response = self.client.get(self.url)
|
||||
expected_response = {
|
||||
'about_page_editable': True,
|
||||
'can_show_certificate_available_date_field': False,
|
||||
'course_display_name': self.course.display_name,
|
||||
'course_display_name_with_default': self.course.display_name_with_default,
|
||||
'credit_eligibility_enabled': True,
|
||||
'enrollment_end_editable': True,
|
||||
'enable_extended_course_details': False,
|
||||
'is_credit_course': False,
|
||||
'is_entrance_exams_enabled': True,
|
||||
'is_prerequisite_courses_enabled': False,
|
||||
'language_options': settings.ALL_LANGUAGES,
|
||||
'lms_link_for_about_page': get_link_for_about_page(self.course),
|
||||
'marketing_enabled': False,
|
||||
'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(self.course.id),
|
||||
'short_description_editable': True,
|
||||
'sidebar_html_enabled': False,
|
||||
'show_min_grade_warning': False,
|
||||
'upgrade_deadline': None,
|
||||
'use_v2_cert_display_settings': False,
|
||||
}
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertDictEqual(expected_response, response.data)
|
||||
|
||||
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_CREDIT_ELIGIBILITY': True})
|
||||
def test_credit_eligibility_setting(self):
|
||||
"""
|
||||
Make sure if the feature flag is enabled we have updated the dict keys in response.
|
||||
"""
|
||||
_ = CreditCourseFactory(course_key=self.course.id, enabled=True)
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn('credit_requirements', response.data)
|
||||
self.assertTrue(response.data['is_credit_course'])
|
||||
|
||||
@patch.dict('django.conf.settings.FEATURES', {
|
||||
'ENABLE_PREREQUISITE_COURSES': True,
|
||||
'MILESTONES_APP': True,
|
||||
})
|
||||
def test_prerequisite_courses_enabled_setting(self):
|
||||
"""
|
||||
Make sure if the feature flags are enabled we have updated the dict keys in response.
|
||||
"""
|
||||
response = self.client.get(self.url)
|
||||
self.assertIn('possible_pre_requisite_courses', response.data)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -4,14 +4,34 @@ from django.urls import re_path
|
||||
|
||||
from openedx.core.constants import COURSE_ID_PATTERN
|
||||
|
||||
from . import views
|
||||
from .views import (
|
||||
CourseDetailsView,
|
||||
CourseSettingsView,
|
||||
ProctoredExamSettingsView,
|
||||
ProctoringErrorsView,
|
||||
)
|
||||
|
||||
app_name = 'v1'
|
||||
|
||||
urlpatterns = [
|
||||
re_path(
|
||||
fr'^proctored_exam_settings/{COURSE_ID_PATTERN}$',
|
||||
views.ProctoredExamSettingsView.as_view(),
|
||||
ProctoredExamSettingsView.as_view(),
|
||||
name="proctored_exam_settings"
|
||||
),
|
||||
re_path(
|
||||
fr'^proctoring_errors/{COURSE_ID_PATTERN}$',
|
||||
ProctoringErrorsView.as_view(),
|
||||
name="proctoring_errors"
|
||||
),
|
||||
re_path(
|
||||
fr'^course_settings/{COURSE_ID_PATTERN}$',
|
||||
CourseSettingsView.as_view(),
|
||||
name="course_settings"
|
||||
),
|
||||
re_path(
|
||||
fr'^course_details/{COURSE_ID_PATTERN}$',
|
||||
CourseDetailsView.as_view(),
|
||||
name="course_details"
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Views for v1 contentstore API.
|
||||
"""
|
||||
from .course_details import CourseDetailsView
|
||||
from .settings import CourseSettingsView
|
||||
from .proctoring import ProctoredExamSettingsView, ProctoringErrorsView
|
||||
155
cms/djangoapps/contentstore/rest_api/v1/views/course_details.py
Normal file
155
cms/djangoapps/contentstore/rest_api/v1/views/course_details.py
Normal file
@@ -0,0 +1,155 @@
|
||||
""" API Views for course details """
|
||||
|
||||
import edx_api_doc_tools as apidocs
|
||||
from django.core.exceptions import ValidationError
|
||||
from common.djangoapps.util.json_request import JsonResponseBadRequest
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from common.djangoapps.student.auth import has_studio_read_access
|
||||
from openedx.core.djangoapps.models.course_details import CourseDetails
|
||||
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from ..serializers import CourseDetailsSerializer
|
||||
from ....utils import update_course_details
|
||||
|
||||
|
||||
@view_auth_classes(is_authenticated=True)
|
||||
class CourseDetailsView(DeveloperErrorViewMixin, APIView):
|
||||
"""
|
||||
View for getting and setting the course details.
|
||||
"""
|
||||
@apidocs.schema(
|
||||
parameters=[
|
||||
apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"),
|
||||
],
|
||||
responses={
|
||||
200: CourseDetailsSerializer,
|
||||
401: "The requester is not authenticated.",
|
||||
403: "The requester cannot access the specified course.",
|
||||
404: "The requested course does not exist.",
|
||||
},
|
||||
)
|
||||
@verify_course_exists()
|
||||
def get(self, request: Request, course_id: str):
|
||||
"""
|
||||
Get an object containing all the course details.
|
||||
|
||||
**Example Request**
|
||||
|
||||
GET /api/contentstore/v1/course_details/{course_id}
|
||||
|
||||
**Response Values**
|
||||
|
||||
If the request is successful, an HTTP 200 "OK" response is returned.
|
||||
|
||||
The HTTP 200 response contains a single dict that contains keys that
|
||||
are the course's details.
|
||||
|
||||
**Example Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"about_sidebar_html": "",
|
||||
"banner_image_name": "images_course_image.jpg",
|
||||
"banner_image_asset_path": "/asset-v1:edX+E2E-101+course+type@asset+block@images_course_image.jpg",
|
||||
"certificate_available_date": "2029-01-02T00:00:00Z",
|
||||
"certificates_display_behavior": "end",
|
||||
"course_id": "E2E-101",
|
||||
"course_image_asset_path": "/static/studio/images/pencils.jpg",
|
||||
"course_image_name": "",
|
||||
"description": "",
|
||||
"duration": "",
|
||||
"effort": null,
|
||||
"end_date": "2023-08-01T01:30:00Z",
|
||||
"enrollment_end": "2023-05-30T01:00:00Z",
|
||||
"enrollment_start": "2023-05-29T01:00:00Z",
|
||||
"entrance_exam_enabled": "",
|
||||
"entrance_exam_id": "",
|
||||
"entrance_exam_minimum_score_pct": "50",
|
||||
"intro_video": null,
|
||||
"language": "creative-commons: ver=4.0 BY NC ND",
|
||||
"learning_info": [],
|
||||
"license": "creative-commons: ver=4.0 BY NC ND",
|
||||
"org": "edX",
|
||||
"overview": "<section class='about'></section>",
|
||||
"pre_requisite_courses": [],
|
||||
"run": "course",
|
||||
"self_paced": false,
|
||||
"short_description": "",
|
||||
"start_date": "2023-06-01T01:30:00Z",
|
||||
"subtitle": "",
|
||||
"syllabus": null,
|
||||
"title": "",
|
||||
"video_thumbnail_image_asset_path": "/asset-v1:edX+E2E-101+course+type@asset+block@images_course_image.jpg",
|
||||
"video_thumbnail_image_name": "images_course_image.jpg",
|
||||
"instructor_info": {
|
||||
"instructors": [{
|
||||
"name": "foo bar",
|
||||
"title": "title",
|
||||
"organization": "org",
|
||||
"image": "image",
|
||||
"bio": ""
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
if not has_studio_read_access(request.user, course_key):
|
||||
self.permission_denied(request)
|
||||
|
||||
course_details = CourseDetails.fetch(course_key)
|
||||
serializer = CourseDetailsSerializer(course_details)
|
||||
return Response(serializer.data)
|
||||
|
||||
@apidocs.schema(
|
||||
body=CourseDetailsSerializer,
|
||||
parameters=[
|
||||
apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"),
|
||||
],
|
||||
responses={
|
||||
200: CourseDetailsSerializer,
|
||||
401: "The requester is not authenticated.",
|
||||
403: "The requester cannot access the specified course.",
|
||||
404: "The requested course does not exist.",
|
||||
},
|
||||
)
|
||||
@verify_course_exists()
|
||||
def put(self, request: Request, course_id: str):
|
||||
"""
|
||||
Update a course's details.
|
||||
|
||||
**Example Request**
|
||||
|
||||
PUT /api/contentstore/v1/course_details/{course_id}
|
||||
|
||||
**PUT Parameters**
|
||||
|
||||
The data sent for a put request should follow a similar format as
|
||||
is returned by a ``GET`` request. Multiple details can be updated in
|
||||
a single request, however only the ``value`` field can be updated
|
||||
any other fields, if included, will be ignored.
|
||||
|
||||
Example request data that updates the ``course_details`` the same as in GET method
|
||||
|
||||
**Response Values**
|
||||
|
||||
If the request is successful, an HTTP 200 "OK" response is returned,
|
||||
along with all the course's details similar to a ``GET`` request.
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
if not has_studio_read_access(request.user, course_key):
|
||||
self.permission_denied(request)
|
||||
|
||||
course_block = modulestore().get_course(course_key)
|
||||
|
||||
try:
|
||||
updated_data = update_course_details(request, course_key, request.data, course_block)
|
||||
except ValidationError as err:
|
||||
return JsonResponseBadRequest({"error": err.message})
|
||||
|
||||
serializer = CourseDetailsSerializer(updated_data)
|
||||
return Response(serializer.data)
|
||||
@@ -1,23 +1,29 @@
|
||||
"Contentstore Views"
|
||||
""" API Views for proctored exam settings and proctoring error """
|
||||
import copy
|
||||
|
||||
from django.conf import settings
|
||||
import edx_api_doc_tools as apidocs
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from cms.djangoapps.contentstore.views.course import get_course_and_check_access
|
||||
from cms.djangoapps.contentstore.utils import get_proctored_exam_settings_url
|
||||
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
|
||||
from common.djangoapps.student.auth import has_studio_advanced_settings_access
|
||||
from xmodule.course_block import get_available_providers # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from openedx.core.djangoapps.course_apps.toggles import exams_ida_enabled
|
||||
from openedx.core.lib.api.view_utils import view_auth_classes
|
||||
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes
|
||||
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
from .serializers import (
|
||||
from ..serializers import (
|
||||
LimitedProctoredExamSettingsSerializer,
|
||||
ProctoredExamConfigurationSerializer,
|
||||
ProctoredExamSettingsSerializer
|
||||
ProctoredExamSettingsSerializer,
|
||||
ProctoringErrorsSerializer,
|
||||
)
|
||||
|
||||
|
||||
@@ -182,3 +188,80 @@ class ProctoredExamSettingsView(APIView):
|
||||
)
|
||||
|
||||
return course_block
|
||||
|
||||
|
||||
@view_auth_classes(is_authenticated=True)
|
||||
class ProctoringErrorsView(DeveloperErrorViewMixin, APIView):
|
||||
"""
|
||||
View for getting the proctoring errors for a course with url to proctored exam settings.
|
||||
"""
|
||||
@apidocs.schema(
|
||||
parameters=[
|
||||
apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"),
|
||||
],
|
||||
responses={
|
||||
200: ProctoringErrorsSerializer,
|
||||
401: "The requester is not authenticated.",
|
||||
403: "The requester cannot access the specified course.",
|
||||
404: "The requested course does not exist.",
|
||||
},
|
||||
)
|
||||
@verify_course_exists()
|
||||
def get(self, request: Request, course_id: str) -> Response:
|
||||
"""
|
||||
Get an object containing proctoring errors in a course.
|
||||
|
||||
**Example Request**
|
||||
|
||||
GET /api/contentstore/v1/proctoring_errors/{course_id}
|
||||
|
||||
**Response Values**
|
||||
|
||||
If the request is successful, an HTTP 200 "OK" response is returned.
|
||||
|
||||
The HTTP 200 response contains a list of object proctoring errors.
|
||||
Also response contains mfe proctored exam settings url.
|
||||
For each item returned an object that contains the following fields:
|
||||
|
||||
* **key**: This is proctoring settings key.
|
||||
* **message**: This is a description for proctoring error.
|
||||
* **model**: This is proctoring provider model object.
|
||||
|
||||
**Example Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"mfe_proctored_exam_settings_url": "http://course-authoring-mfe/course/course_key/proctored-exam-settings",
|
||||
"proctoring_errors": [
|
||||
{
|
||||
"key": "proctoring_provider",
|
||||
"message": "The proctoring provider cannot be modified after a course has started.",
|
||||
"model": {
|
||||
"value": "null",
|
||||
"display_name": "Proctoring Provider",
|
||||
"help": "Enter the proctoring provider you want to use for this course run.",
|
||||
"deprecated": false,
|
||||
"hide_on_enabled_publisher": false
|
||||
}}
|
||||
],
|
||||
}
|
||||
```
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
if not has_studio_advanced_settings_access(request.user):
|
||||
self.permission_denied(request)
|
||||
|
||||
course_block = modulestore().get_course(course_key)
|
||||
advanced_dict = CourseMetadata.fetch(course_block)
|
||||
if settings.FEATURES.get('DISABLE_MOBILE_COURSE_AVAILABLE', False):
|
||||
advanced_dict.get('mobile_available')['deprecated'] = True
|
||||
|
||||
proctoring_errors = CourseMetadata.validate_proctoring_settings(course_block, advanced_dict, request.user)
|
||||
proctoring_context = {
|
||||
'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course_key),
|
||||
'proctoring_errors': proctoring_errors,
|
||||
}
|
||||
|
||||
serializer = ProctoringErrorsSerializer(data=proctoring_context)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return Response(serializer.data)
|
||||
115
cms/djangoapps/contentstore/rest_api/v1/views/settings.py
Normal file
115
cms/djangoapps/contentstore/rest_api/v1/views/settings.py
Normal file
@@ -0,0 +1,115 @@
|
||||
""" API Views for course settings """
|
||||
|
||||
import edx_api_doc_tools as apidocs
|
||||
from django.conf import settings
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from common.djangoapps.student.auth import has_studio_read_access
|
||||
from lms.djangoapps.certificates.api import can_show_certificate_available_date_field
|
||||
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from ..serializers import CourseSettingsSerializer
|
||||
from ....utils import get_course_settings
|
||||
|
||||
|
||||
@view_auth_classes(is_authenticated=True)
|
||||
class CourseSettingsView(DeveloperErrorViewMixin, APIView):
|
||||
"""
|
||||
View for getting the settings for a course.
|
||||
"""
|
||||
|
||||
@apidocs.schema(
|
||||
parameters=[
|
||||
apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"),
|
||||
],
|
||||
responses={
|
||||
200: CourseSettingsSerializer,
|
||||
401: "The requester is not authenticated.",
|
||||
403: "The requester cannot access the specified course.",
|
||||
404: "The requested course does not exist.",
|
||||
},
|
||||
)
|
||||
@verify_course_exists()
|
||||
def get(self, request: Request, course_id: str):
|
||||
"""
|
||||
Get an object containing all the course settings.
|
||||
|
||||
**Example Request**
|
||||
|
||||
GET /api/contentstore/v1/course_settings/{course_id}
|
||||
|
||||
**Response Values**
|
||||
|
||||
If the request is successful, an HTTP 200 "OK" response is returned.
|
||||
|
||||
The HTTP 200 response contains a single dict that contains keys that
|
||||
are the course's settings.
|
||||
|
||||
**Example Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"about_page_editable": false,
|
||||
"can_show_certificate_available_date_field": false,
|
||||
"course_display_name": "E2E Test Course",
|
||||
"course_display_name_with_default": "E2E Test Course",
|
||||
"credit_eligibility_enabled": true,
|
||||
"enable_extended_course_details": true,
|
||||
"enrollment_end_editable": true,
|
||||
"is_credit_course": false,
|
||||
"is_entrance_exams_enabled": true,
|
||||
"is_prerequisite_courses_enabled": true,
|
||||
"language_options": [
|
||||
[
|
||||
"aa",
|
||||
"Afar"
|
||||
],
|
||||
[
|
||||
"uk",
|
||||
"Ukrainian"
|
||||
],
|
||||
...
|
||||
],
|
||||
"lms_link_for_about_page": "http://localhost:18000/courses/course-v1:edX+E2E-101+course/about",
|
||||
"marketing_enabled": true,
|
||||
"mfe_proctored_exam_settings_url": "",
|
||||
"possible_pre_requisite_courses": [
|
||||
{
|
||||
"course_key": "course-v1:edX+M12+2T2023",
|
||||
"display_name": "Differential Equations",
|
||||
"lms_link": "//localhost:18000/courses/course-v1:edX+M1...",
|
||||
"number": "M12",
|
||||
"org": "edX",
|
||||
"rerun_link": "/course_rerun/course-v1:edX+M12+2T2023",
|
||||
"run": "2T2023",
|
||||
"url": "/course/course-v1:edX+M12+2T2023"
|
||||
},
|
||||
],
|
||||
"short_description_editable": true,
|
||||
"show_min_grade_warning": false,
|
||||
"sidebar_html_enabled": true,
|
||||
"upgrade_deadline": null,
|
||||
"use_v2_cert_display_settings": false
|
||||
}
|
||||
```
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
if not has_studio_read_access(request.user, course_key):
|
||||
self.permission_denied(request)
|
||||
|
||||
with modulestore().bulk_operations(course_key):
|
||||
course_block = modulestore().get_course(course_key)
|
||||
settings_context = get_course_settings(request, course_key, course_block)
|
||||
settings_context.update({
|
||||
'can_show_certificate_available_date_field': can_show_certificate_available_date_field(course_block),
|
||||
'course_display_name': course_block.display_name,
|
||||
'course_display_name_with_default': course_block.display_name_with_default,
|
||||
'use_v2_cert_display_settings': settings.FEATURES.get("ENABLE_V2_CERT_DISPLAY_SETTINGS", False),
|
||||
})
|
||||
|
||||
serializer = CourseSettingsSerializer(settings_context)
|
||||
return Response(serializer.data)
|
||||
@@ -28,7 +28,7 @@ from cms.djangoapps.contentstore.courseware_index import (
|
||||
LibrarySearchIndexer,
|
||||
)
|
||||
from common.djangoapps.track.event_transaction_utils import get_event_transaction_id, get_event_transaction_type
|
||||
from common.djangoapps.util.block_utils import yield_dynamic_descriptor_descendants
|
||||
from common.djangoapps.util.block_utils import yield_dynamic_block_descendants
|
||||
from lms.djangoapps.grades.api import task_compute_all_grades_for_course
|
||||
from openedx.core.djangoapps.content.learning_sequences.api import key_supports_outlines
|
||||
from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task
|
||||
@@ -252,7 +252,7 @@ def handle_item_deleted(**kwargs):
|
||||
usage_key = usage_key.for_branch(None)
|
||||
course_key = usage_key.course_key
|
||||
deleted_block = modulestore().get_item(usage_key)
|
||||
for block in yield_dynamic_descriptor_descendants(deleted_block, kwargs.get('user_id')):
|
||||
for block in yield_dynamic_block_descendants(deleted_block, kwargs.get('user_id')):
|
||||
# Remove prerequisite milestone data
|
||||
gating_api.remove_prerequisite(block.location)
|
||||
# Remove any 'requires' course content milestone relationships
|
||||
|
||||
@@ -1713,17 +1713,17 @@ class MetadataSaveTestCase(ContentStoreTestCase):
|
||||
"""
|
||||
video_data = VideoBlock.parse_video_xml(video_sample_xml)
|
||||
video_data.pop('source')
|
||||
self.video_descriptor = BlockFactory.create(
|
||||
self.video_block = BlockFactory.create(
|
||||
parent_location=course.location, category='video',
|
||||
**video_data
|
||||
)
|
||||
|
||||
def test_metadata_not_persistence(self):
|
||||
"""
|
||||
Test that descriptors which set metadata fields in their
|
||||
Test that blocks which set metadata fields in their
|
||||
constructor are correctly deleted.
|
||||
"""
|
||||
self.assertIn('html5_sources', own_metadata(self.video_descriptor))
|
||||
self.assertIn('html5_sources', own_metadata(self.video_block))
|
||||
attrs_to_strip = {
|
||||
'show_captions',
|
||||
'youtube_id_1_0',
|
||||
@@ -1736,13 +1736,13 @@ class MetadataSaveTestCase(ContentStoreTestCase):
|
||||
'track'
|
||||
}
|
||||
|
||||
location = self.video_descriptor.location
|
||||
location = self.video_block.location
|
||||
|
||||
for field_name in attrs_to_strip:
|
||||
delattr(self.video_descriptor, field_name)
|
||||
delattr(self.video_block, field_name)
|
||||
|
||||
self.assertNotIn('html5_sources', own_metadata(self.video_descriptor))
|
||||
self.store.update_item(self.video_descriptor, self.user.id)
|
||||
self.assertNotIn('html5_sources', own_metadata(self.video_block))
|
||||
self.store.update_item(self.video_block, self.user.id)
|
||||
block = self.store.get_item(location)
|
||||
|
||||
self.assertNotIn('html5_sources', own_metadata(block))
|
||||
@@ -2047,12 +2047,12 @@ class ContentLicenseTest(ContentStoreTestCase):
|
||||
def test_video_license_export(self):
|
||||
content_store = contentstore()
|
||||
root_dir = path(mkdtemp_clean())
|
||||
video_descriptor = BlockFactory.create(
|
||||
video_block = BlockFactory.create(
|
||||
parent_location=self.course.location, category='video',
|
||||
license="all-rights-reserved"
|
||||
)
|
||||
export_course_to_xml(self.store, content_store, self.course.id, root_dir, 'test_license')
|
||||
fname = f"{video_descriptor.scope_ids.usage_id.block_id}.xml"
|
||||
fname = f"{video_block.scope_ids.usage_id.block_id}.xml"
|
||||
video_file_path = root_dir / "test_license" / "video" / fname
|
||||
with video_file_path.open() as f:
|
||||
video_xml = etree.parse(f)
|
||||
|
||||
@@ -106,7 +106,12 @@ class CourseAdvanceSettingViewTest(CourseTestCase, MilestonesTestCaseMixin):
|
||||
super().setUp()
|
||||
self.fullcourse = CourseFactory.create()
|
||||
self.course_setting_url = get_url(self.course.id, 'advanced_settings_handler')
|
||||
self.non_staff_client, _ = self.create_non_staff_authed_user_client()
|
||||
|
||||
self.non_staff_client, self.nonstaff = self.create_non_staff_authed_user_client()
|
||||
# "nonstaff" means "non Django staff" here. We assign this user to course staff
|
||||
# role to check that even so they won't have advanced settings access when explicitly
|
||||
# restricted.
|
||||
CourseStaffRole(self.course.id).add_users(self.nonstaff)
|
||||
|
||||
@override_settings(FEATURES={'DISABLE_MOBILE_COURSE_AVAILABLE': True})
|
||||
def test_mobile_field_available(self):
|
||||
@@ -145,16 +150,50 @@ class CourseAdvanceSettingViewTest(CourseTestCase, MilestonesTestCaseMixin):
|
||||
self.assertEqual('discussion_blackouts' in response, fields_visible)
|
||||
self.assertEqual('discussion_topics' in response, fields_visible)
|
||||
|
||||
@override_settings(FEATURES={'DISABLE_ADVANCED_SETTINGS': True})
|
||||
def test_disable_advanced_settings_feature(self):
|
||||
@ddt.data(False, True)
|
||||
def test_disable_advanced_settings_feature(self, disable_advanced_settings):
|
||||
"""
|
||||
If this feature is enabled, only staff should be able to access the advanced settings page.
|
||||
If this feature is enabled, only Django Staff/Superuser should be able to access the "Advanced Settings" page.
|
||||
For non-staff users the "Advanced Settings" tab link should not be visible.
|
||||
"""
|
||||
response = self.non_staff_client.get_html(self.course_setting_url)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
advanced_settings_link_html = f"<a href=\"{self.course_setting_url}\">Advanced Settings</a>".encode('utf-8')
|
||||
|
||||
response = self.client.get_html(self.course_setting_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
with override_settings(FEATURES={'DISABLE_ADVANCED_SETTINGS': disable_advanced_settings}):
|
||||
for handler in (
|
||||
'import_handler',
|
||||
'export_handler',
|
||||
'course_team_handler',
|
||||
'course_info_handler',
|
||||
'assets_handler',
|
||||
'tabs_handler',
|
||||
'settings_handler',
|
||||
'grading_handler',
|
||||
'textbooks_list_handler',
|
||||
):
|
||||
# Test that non-staff users don't see the "Advanced Settings" tab link.
|
||||
response = self.non_staff_client.get_html(
|
||||
get_url(self.course.id, handler)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
if disable_advanced_settings:
|
||||
self.assertNotIn(advanced_settings_link_html, response.content)
|
||||
else:
|
||||
self.assertIn(advanced_settings_link_html, response.content)
|
||||
|
||||
# Test that staff users see the "Advanced Settings" tab link.
|
||||
response = self.client.get_html(
|
||||
get_url(self.course.id, handler)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(advanced_settings_link_html, response.content)
|
||||
|
||||
# Test that non-staff users can't access the "Advanced Settings" page.
|
||||
response = self.non_staff_client.get_html(self.course_setting_url)
|
||||
self.assertEqual(response.status_code, 403 if disable_advanced_settings else 200)
|
||||
|
||||
# Test that staff users can access the "Advanced Settings" page.
|
||||
response = self.client.get_html(self.course_setting_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@@ -885,33 +924,33 @@ class CourseGradingTest(CourseTestCase):
|
||||
@mock.patch('cms.djangoapps.contentstore.signals.signals.GRADING_POLICY_CHANGED.send')
|
||||
def test_update_section_grader_type(self, send_signal, tracker, uuid):
|
||||
uuid.return_value = 'mockUUID'
|
||||
# Get the descriptor and the section_grader_type and assert they are the default values
|
||||
descriptor = modulestore().get_item(self.course.location)
|
||||
# Get the block and the section_grader_type and assert they are the default values
|
||||
block = modulestore().get_item(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
||||
|
||||
self.assertEqual('notgraded', section_grader_type['graderType'])
|
||||
self.assertEqual(None, descriptor.format)
|
||||
self.assertEqual(False, descriptor.graded)
|
||||
self.assertEqual(None, block.format)
|
||||
self.assertEqual(False, block.graded)
|
||||
|
||||
# Change the default grader type to Homework, which should also mark the section as graded
|
||||
CourseGradingModel.update_section_grader_type(self.course, 'Homework', self.user)
|
||||
descriptor = modulestore().get_item(self.course.location)
|
||||
block = modulestore().get_item(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
||||
grading_policy_1 = self._grading_policy_hash_for_course()
|
||||
|
||||
self.assertEqual('Homework', section_grader_type['graderType'])
|
||||
self.assertEqual('Homework', descriptor.format)
|
||||
self.assertEqual(True, descriptor.graded)
|
||||
self.assertEqual('Homework', block.format)
|
||||
self.assertEqual(True, block.graded)
|
||||
|
||||
# Change the grader type back to notgraded, which should also unmark the section as graded
|
||||
CourseGradingModel.update_section_grader_type(self.course, 'notgraded', self.user)
|
||||
descriptor = modulestore().get_item(self.course.location)
|
||||
block = modulestore().get_item(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
||||
grading_policy_2 = self._grading_policy_hash_for_course()
|
||||
|
||||
self.assertEqual('notgraded', section_grader_type['graderType'])
|
||||
self.assertEqual(None, descriptor.format)
|
||||
self.assertEqual(False, descriptor.graded)
|
||||
self.assertEqual(None, block.format)
|
||||
self.assertEqual(False, block.graded)
|
||||
|
||||
# one for each call to update_section_grader_type()
|
||||
send_signal.assert_has_calls([
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Test the exams service integration into Studio
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
import ddt
|
||||
@@ -147,8 +147,15 @@ class TestExamService(ModuleStoreTestCase):
|
||||
listen_for_course_publish(self, self.course.id)
|
||||
mock_patch_course_exams.assert_not_called()
|
||||
|
||||
def test_self_paced_no_due_dates(self, mock_patch_course_exams):
|
||||
self.course.self_paced = True
|
||||
@ddt.data(True, False)
|
||||
def test_no_due_dates(self, is_self_paced, mock_patch_course_exams):
|
||||
"""
|
||||
Test that the coures end date is registered as the due date when the subsection does not have a due date for
|
||||
both self-paced and instructor-paced exams.
|
||||
"""
|
||||
self.course.self_paced = is_self_paced
|
||||
end_date = datetime(2035, 1, 1, 0, 0, tzinfo=timezone.utc)
|
||||
self.course.end = end_date
|
||||
self.course = self.update_course(self.course, 1)
|
||||
BlockFactory.create(
|
||||
parent=self.chapter,
|
||||
@@ -159,18 +166,40 @@ class TestExamService(ModuleStoreTestCase):
|
||||
default_time_limit_minutes=60,
|
||||
is_proctored_enabled=False,
|
||||
is_practice_exam=False,
|
||||
due=datetime.now(UTC) + timedelta(minutes=60),
|
||||
due=None,
|
||||
hide_after_due=True,
|
||||
is_onboarding_exam=False,
|
||||
)
|
||||
listen_for_course_publish(self, self.course.id)
|
||||
called_exams, called_course = mock_patch_course_exams.call_args[0]
|
||||
assert called_exams[0]['due_date'] is None
|
||||
|
||||
# now switch to instructor paced
|
||||
# the exam will be updated with a due date
|
||||
self.course.self_paced = False
|
||||
self.course = self.update_course(self.course, 1)
|
||||
listen_for_course_publish(self, self.course.id)
|
||||
called_exams, called_course = mock_patch_course_exams.call_args[0]
|
||||
assert called_exams[0]['due_date'] is not None
|
||||
assert called_exams[0]['due_date'] == end_date.isoformat()
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_subsection_due_date_prioritized(self, is_self_paced, mock_patch_course_exams):
|
||||
"""
|
||||
Test that the subsection due date is registered as the due date when both the subsection has a due date and the
|
||||
course has an end date for both self-paced and instructor-paced exams.
|
||||
"""
|
||||
self.course.self_paced = is_self_paced
|
||||
self.course.end = datetime(2035, 1, 1, 0, 0)
|
||||
self.course = self.update_course(self.course, 1)
|
||||
|
||||
sequential_due_date = datetime.now(UTC) + timedelta(minutes=60)
|
||||
BlockFactory.create(
|
||||
parent=self.chapter,
|
||||
category='sequential',
|
||||
display_name='Test Proctored Exam',
|
||||
graded=True,
|
||||
is_time_limited=True,
|
||||
default_time_limit_minutes=60,
|
||||
is_proctored_enabled=False,
|
||||
is_practice_exam=False,
|
||||
due=sequential_due_date,
|
||||
hide_after_due=True,
|
||||
is_onboarding_exam=False,
|
||||
)
|
||||
|
||||
listen_for_course_publish(self, self.course.id)
|
||||
called_exams, called_course = mock_patch_course_exams.call_args[0]
|
||||
assert called_exams[0]['due_date'] == sequential_due_date.isoformat()
|
||||
|
||||
@@ -69,19 +69,19 @@ class TestXBlockI18nService(ModuleStoreTestCase):
|
||||
self.request = mock.Mock()
|
||||
self.course = CourseFactory.create()
|
||||
self.field_data = mock.Mock()
|
||||
self.descriptor = BlockFactory(category="pure", parent=self.course)
|
||||
self.block = BlockFactory(category="pure", parent=self.course)
|
||||
_prepare_runtime_for_preview(
|
||||
self.request,
|
||||
self.descriptor,
|
||||
self.block,
|
||||
self.field_data,
|
||||
)
|
||||
self.addCleanup(translation.deactivate)
|
||||
|
||||
def get_block_i18n_service(self, descriptor):
|
||||
def get_block_i18n_service(self, block):
|
||||
"""
|
||||
return the block i18n service.
|
||||
"""
|
||||
i18n_service = self.descriptor.runtime.service(descriptor, 'i18n')
|
||||
i18n_service = self.block.runtime.service(block, 'i18n')
|
||||
self.assertIsNotNone(i18n_service)
|
||||
self.assertIsInstance(i18n_service, XBlockI18nService)
|
||||
return i18n_service
|
||||
@@ -113,7 +113,7 @@ class TestXBlockI18nService(ModuleStoreTestCase):
|
||||
self.module.ugettext = self.old_ugettext
|
||||
self.module.gettext = self.old_ugettext
|
||||
|
||||
i18n_service = self.get_block_i18n_service(self.descriptor)
|
||||
i18n_service = self.get_block_i18n_service(self.block)
|
||||
|
||||
# Activate french, so that if the fr files haven't been loaded, they will be loaded now.
|
||||
with translation.override("fr"):
|
||||
@@ -150,13 +150,13 @@ class TestXBlockI18nService(ModuleStoreTestCase):
|
||||
translation.activate("es")
|
||||
with mock.patch('gettext.translation', return_value=_translator(domain='text', localedir=localedir,
|
||||
languages=[get_language()])):
|
||||
i18n_service = self.get_block_i18n_service(self.descriptor)
|
||||
i18n_service = self.get_block_i18n_service(self.block)
|
||||
self.assertEqual(i18n_service.ugettext('Hello'), 'es-hello-world')
|
||||
|
||||
translation.activate("ar")
|
||||
with mock.patch('gettext.translation', return_value=_translator(domain='text', localedir=localedir,
|
||||
languages=[get_language()])):
|
||||
i18n_service = self.get_block_i18n_service(self.descriptor)
|
||||
i18n_service = self.get_block_i18n_service(self.block)
|
||||
self.assertEqual(get_gettext(i18n_service)('Hello'), 'Hello')
|
||||
self.assertNotEqual(get_gettext(i18n_service)('Hello'), 'fr-hello-world')
|
||||
self.assertNotEqual(get_gettext(i18n_service)('Hello'), 'es-hello-world')
|
||||
@@ -164,14 +164,14 @@ class TestXBlockI18nService(ModuleStoreTestCase):
|
||||
translation.activate("fr")
|
||||
with mock.patch('gettext.translation', return_value=_translator(domain='text', localedir=localedir,
|
||||
languages=[get_language()])):
|
||||
i18n_service = self.get_block_i18n_service(self.descriptor)
|
||||
i18n_service = self.get_block_i18n_service(self.block)
|
||||
self.assertEqual(i18n_service.ugettext('Hello'), 'fr-hello-world')
|
||||
|
||||
def test_i18n_service_callable(self):
|
||||
"""
|
||||
Test: i18n service should be callable in studio.
|
||||
"""
|
||||
self.assertTrue(callable(self.descriptor.runtime._services.get('i18n'))) # pylint: disable=protected-access
|
||||
self.assertTrue(callable(self.block.runtime._services.get('i18n'))) # pylint: disable=protected-access
|
||||
|
||||
|
||||
class InternationalizationTest(ModuleStoreTestCase):
|
||||
|
||||
@@ -15,8 +15,8 @@ from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
|
||||
from xmodule.x_module import STUDIO_VIEW
|
||||
|
||||
from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient, parse_json
|
||||
from cms.djangoapps.contentstore.utils import reverse_library_url, reverse_url, reverse_usage_url
|
||||
from cms.djangoapps.contentstore.views.block import _duplicate_block
|
||||
from cms.djangoapps.contentstore.utils import reverse_library_url, reverse_url, \
|
||||
reverse_usage_url, duplicate_block
|
||||
from cms.djangoapps.contentstore.views.preview import _load_preview_block
|
||||
from cms.djangoapps.contentstore.views.tests.test_library import LIBRARY_REST_URL
|
||||
from cms.djangoapps.course_creators.views import add_user_with_status_granted
|
||||
@@ -114,7 +114,7 @@ class LibraryTestCase(ModuleStoreTestCase):
|
||||
self.assertEqual(response.status_code, status_code_expected)
|
||||
return modulestore().get_item(lib_content_block.location)
|
||||
|
||||
def _bind_block(self, descriptor, user=None):
|
||||
def _bind_block(self, block, user=None):
|
||||
"""
|
||||
Helper to use the CMS's module system so we can access student-specific fields.
|
||||
"""
|
||||
@@ -123,7 +123,7 @@ class LibraryTestCase(ModuleStoreTestCase):
|
||||
if user not in self.session_data:
|
||||
self.session_data[user] = {}
|
||||
request = Mock(user=user, session=self.session_data[user])
|
||||
_load_preview_block(request, descriptor)
|
||||
_load_preview_block(request, block)
|
||||
|
||||
def _update_block(self, usage_key, metadata):
|
||||
"""
|
||||
@@ -174,13 +174,13 @@ class TestLibraries(LibraryTestCase):
|
||||
lc_block = self._refresh_children(lc_block)
|
||||
|
||||
# Now, we want to make sure that .children has the total # of potential
|
||||
# children, and that get_child_descriptors() returns the actual children
|
||||
# children, and that get_child_blocks() returns the actual children
|
||||
# chosen for a given student.
|
||||
# In order to be able to call get_child_descriptors(), we must first
|
||||
# In order to be able to call get_child_blocks(), we must first
|
||||
# call bind_for_student:
|
||||
self._bind_block(lc_block)
|
||||
self.assertEqual(len(lc_block.children), num_to_create)
|
||||
self.assertEqual(len(lc_block.get_child_descriptors()), num_expected)
|
||||
self.assertEqual(len(lc_block.get_child_blocks()), num_expected)
|
||||
|
||||
def test_consistent_children(self):
|
||||
"""
|
||||
@@ -204,7 +204,7 @@ class TestLibraries(LibraryTestCase):
|
||||
"""
|
||||
Fetch the child shown to the current user.
|
||||
"""
|
||||
children = block.get_child_descriptors()
|
||||
children = block.get_child_blocks()
|
||||
self.assertEqual(len(children), 1)
|
||||
return children[0]
|
||||
|
||||
@@ -947,7 +947,7 @@ class TestOverrides(LibraryTestCase):
|
||||
if duplicate:
|
||||
# Check that this also works when the RCB is duplicated.
|
||||
self.lc_block = modulestore().get_item(
|
||||
_duplicate_block(self.course.location, self.lc_block.location, self.user)
|
||||
duplicate_block(self.course.location, self.lc_block.location, self.user)
|
||||
)
|
||||
self.problem_in_course = modulestore().get_item(self.lc_block.children[0])
|
||||
else:
|
||||
@@ -1006,7 +1006,7 @@ class TestOverrides(LibraryTestCase):
|
||||
|
||||
# Duplicate self.lc_block:
|
||||
duplicate = store.get_item(
|
||||
_duplicate_block(self.course.location, self.lc_block.location, self.user)
|
||||
duplicate_block(self.course.location, self.lc_block.location, self.user)
|
||||
)
|
||||
# The duplicate should have identical children to the original:
|
||||
self.assertEqual(len(duplicate.children), 1)
|
||||
|
||||
@@ -620,7 +620,7 @@ class GetUserPartitionInfoTest(ModuleStoreTestCase):
|
||||
self.assertEqual(partitions[0]["scheme"], "random")
|
||||
|
||||
def _set_partitions(self, partitions):
|
||||
"""Set the user partitions of the course descriptor. """
|
||||
"""Set the user partitions of the course block. """
|
||||
self.course.user_partitions = partitions
|
||||
self.course = self.store.update_item(self.course, ModuleStoreEnum.UserID.test)
|
||||
|
||||
|
||||
@@ -191,8 +191,8 @@ class CourseTestCase(ProceduralCourseTestMixin, ModuleStoreTestCase):
|
||||
""" Test getting the editing HTML for each vertical. """
|
||||
# assert is here to make sure that the course being tested actually has verticals (units) to check.
|
||||
self.assertGreater(len(items), 0, "Course has no verticals (units) to check")
|
||||
for descriptor in items:
|
||||
resp = self.client.get_html(get_url('container_handler', descriptor.location))
|
||||
for block in items:
|
||||
resp = self.client.get_html(get_url('container_handler', block.location))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def assertAssetsEqual(self, asset_son, course1_id, course2_id):
|
||||
|
||||
@@ -122,6 +122,24 @@ def use_new_video_editor():
|
||||
return ENABLE_NEW_VIDEO_EDITOR_FLAG.is_enabled()
|
||||
|
||||
|
||||
# .. toggle_name: new_core_editors.use_video_gallery_flow
|
||||
# .. toggle_implementation: WaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: This flag enables the use the video selection gallery on the flow of the new core video xblock editor
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2023-04-03
|
||||
# .. toggle_target_removal_date: 2023-6-01
|
||||
# .. toggle_warning: You need to activate the `new_core_editors.use_new_video_editor` flag to use this new flow.
|
||||
ENABLE_VIDEO_GALLERY_FLOW_FLAG = WaffleFlag('new_core_editors.use_video_gallery_flow', __name__)
|
||||
|
||||
|
||||
def use_video_gallery_flow():
|
||||
"""
|
||||
Returns a boolean = true if the video gallery flow is enabled
|
||||
"""
|
||||
return ENABLE_VIDEO_GALLERY_FLOW_FLAG.is_enabled()
|
||||
|
||||
|
||||
# .. toggle_name: new_core_editors.use_new_problem_editor
|
||||
# .. toggle_implementation: WaffleFlag
|
||||
# .. toggle_default: False
|
||||
@@ -175,3 +193,262 @@ ENABLE_COPY_PASTE_FEATURE = WaffleFlag(
|
||||
__name__,
|
||||
CONTENTSTORE_LOG_PREFIX,
|
||||
)
|
||||
|
||||
|
||||
# .. toggle_name: new_studio_mfe.use_new_home_page
|
||||
# .. toggle_implementation: WaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: This flag enables the use of the new studio home page mfe
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2023-5-15
|
||||
# .. toggle_target_removal_date: 2023-8-31
|
||||
# .. toggle_tickets: TNL-9306
|
||||
# .. toggle_warning:
|
||||
ENABLE_NEW_STUDIO_HOME_PAGE = WaffleFlag('new_studio_mfe.use_new_home_page', __name__)
|
||||
|
||||
|
||||
def use_new_home_page():
|
||||
"""
|
||||
Returns a boolean if new studio home page mfe is enabled
|
||||
"""
|
||||
return ENABLE_NEW_STUDIO_HOME_PAGE.is_enabled()
|
||||
|
||||
|
||||
# .. toggle_name: new_studio_mfe.use_new_custom_pages
|
||||
# .. toggle_implementation: CourseWaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: This flag enables the use of the new studio custom pages mfe
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2023-5-15
|
||||
# .. toggle_target_removal_date: 2023-8-31
|
||||
# .. toggle_tickets: TNL-10619
|
||||
# .. toggle_warning:
|
||||
ENABLE_NEW_STUDIO_CUSTOM_PAGES = CourseWaffleFlag(
|
||||
f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_custom_pages', __name__)
|
||||
|
||||
|
||||
def use_new_custom_pages():
|
||||
"""
|
||||
Returns a boolean if new studio custom pages mfe is enabled
|
||||
"""
|
||||
return ENABLE_NEW_STUDIO_CUSTOM_PAGES.is_enabled()
|
||||
|
||||
|
||||
# .. toggle_name: new_studio_mfe.use_new_schedule_details_page
|
||||
# .. toggle_implementation: CourseWaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: This flag enables the use of the new studio schedule and details mfe
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2023-5-15
|
||||
# .. toggle_target_removal_date: 2023-8-31
|
||||
# .. toggle_tickets: TNL-10619
|
||||
# .. toggle_warning:
|
||||
ENABLE_NEW_STUDIO_SCHEDULE_DETAILS_PAGE = CourseWaffleFlag(
|
||||
f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_schedule_details_page', __name__)
|
||||
|
||||
|
||||
def use_new_schedule_details_page(course_key):
|
||||
"""
|
||||
Returns a boolean if new studio schedule and details mfe is enabled
|
||||
"""
|
||||
return ENABLE_NEW_STUDIO_SCHEDULE_DETAILS_PAGE.is_enabled(course_key)
|
||||
|
||||
|
||||
# .. toggle_name: new_studio_mfe.use_new_advanced_settings_page
|
||||
# .. toggle_implementation: CourseWaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: This flag enables the use of the new studio advanced settings page mfe
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2023-5-15
|
||||
# .. toggle_target_removal_date: 2023-8-31
|
||||
# .. toggle_tickets: TNL-10619
|
||||
# .. toggle_warning:
|
||||
ENABLE_NEW_STUDIO_ADVANCED_SETTINGS_PAGE = CourseWaffleFlag(
|
||||
f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_advanced_settings_page', __name__)
|
||||
|
||||
|
||||
def use_new_advanced_settings_page(course_key):
|
||||
"""
|
||||
Returns a boolean if new studio advanced settings pafe mfe is enabled
|
||||
"""
|
||||
return ENABLE_NEW_STUDIO_ADVANCED_SETTINGS_PAGE.is_enabled(course_key)
|
||||
|
||||
|
||||
# .. toggle_name: new_studio_mfe.use_new_grading_page
|
||||
# .. toggle_implementation: CourseWaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: This flag enables the use of the new studio grading page mfe
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2023-5-15
|
||||
# .. toggle_target_removal_date: 2023-8-31
|
||||
# .. toggle_tickets: TNL-10619
|
||||
# .. toggle_warning:
|
||||
ENABLE_NEW_STUDIO_GRADING_PAGE = CourseWaffleFlag(
|
||||
f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_grading_page', __name__)
|
||||
|
||||
|
||||
def use_new_grading_page(course_key):
|
||||
"""
|
||||
Returns a boolean if new studio grading mfe is enabled
|
||||
"""
|
||||
return ENABLE_NEW_STUDIO_GRADING_PAGE.is_enabled(course_key)
|
||||
|
||||
|
||||
# .. toggle_name: new_studio_mfe.use_new_updates_page
|
||||
# .. toggle_implementation: CourseWaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: This flag enables the use of the new studio updates page mfe
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2023-5-15
|
||||
# .. toggle_target_removal_date: 2023-8-31
|
||||
# .. toggle_tickets: TNL-10619
|
||||
# .. toggle_warning:
|
||||
ENABLE_NEW_STUDIO_UPDATES_PAGE = CourseWaffleFlag(
|
||||
f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_updates_page', __name__)
|
||||
|
||||
|
||||
def use_new_updates_page(course_key):
|
||||
"""
|
||||
Returns a boolean if new studio updates mfe is enabled
|
||||
"""
|
||||
return ENABLE_NEW_STUDIO_UPDATES_PAGE.is_enabled(course_key)
|
||||
|
||||
|
||||
# .. toggle_name: new_studio_mfe.use_new_import_page
|
||||
# .. toggle_implementation: CourseWaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: This flag enables the use of the new studio import page mfe
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2023-5-15
|
||||
# .. toggle_target_removal_date: 2023-8-31
|
||||
# .. toggle_tickets: TNL-10619
|
||||
# .. toggle_warning:
|
||||
ENABLE_NEW_STUDIO_IMPORT_PAGE = CourseWaffleFlag(
|
||||
f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_import_page', __name__)
|
||||
|
||||
|
||||
def use_new_import_page(course_key):
|
||||
"""
|
||||
Returns a boolean if new studio import mfe is enabled
|
||||
"""
|
||||
return ENABLE_NEW_STUDIO_IMPORT_PAGE.is_enabled(course_key)
|
||||
|
||||
|
||||
# .. toggle_name: new_studio_mfe.use_new_export_page
|
||||
# .. toggle_implementation: CourseWaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: This flag enables the use of the new studio export page mfe
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2023-5-15
|
||||
# .. toggle_target_removal_date: 2023-8-31
|
||||
# .. toggle_tickets: TNL-10619
|
||||
# .. toggle_warning:
|
||||
ENABLE_NEW_STUDIO_EXPORT_PAGE = CourseWaffleFlag(
|
||||
f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_export_page', __name__)
|
||||
|
||||
|
||||
def use_new_export_page(course_key):
|
||||
"""
|
||||
Returns a boolean if new studio export mfe is enabled
|
||||
"""
|
||||
return ENABLE_NEW_STUDIO_EXPORT_PAGE.is_enabled(course_key)
|
||||
|
||||
|
||||
# .. toggle_name: new_studio_mfe.use_new_files_uploads_page
|
||||
# .. toggle_implementation: CourseWaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: This flag enables the use of the new studio files and uploads page mfe
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2023-5-15
|
||||
# .. toggle_target_removal_date: 2023-8-31
|
||||
# .. toggle_tickets: TNL-10619
|
||||
# .. toggle_warning:
|
||||
ENABLE_NEW_STUDIO_FILES_UPLOADS_PAGE = CourseWaffleFlag(
|
||||
f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_files_uploads_page', __name__)
|
||||
|
||||
|
||||
def use_new_files_uploads_page(course_key):
|
||||
"""
|
||||
Returns a boolean if new studio files and uploads mfe is enabled
|
||||
"""
|
||||
return ENABLE_NEW_STUDIO_FILES_UPLOADS_PAGE.is_enabled(course_key)
|
||||
|
||||
|
||||
# .. toggle_name: new_studio_mfe.use_new_video_uploads_page
|
||||
# .. toggle_implementation: CourseWaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: This flag enables the use of the new video uploads page mfe
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2023-5-15
|
||||
# .. toggle_target_removal_date: 2023-8-31
|
||||
# .. toggle_tickets: TNL-10619
|
||||
# .. toggle_warning:
|
||||
ENABLE_NEW_STUDIO_VIDEO_UPLOADS_PAGE = CourseWaffleFlag(
|
||||
f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_video_uploads_page', __name__)
|
||||
|
||||
|
||||
def use_new_video_uploads_page(course_key):
|
||||
"""
|
||||
Returns a boolean if new studio video uploads mfe is enabled
|
||||
"""
|
||||
return ENABLE_NEW_STUDIO_VIDEO_UPLOADS_PAGE.is_enabled(course_key)
|
||||
|
||||
|
||||
# .. toggle_name: new_studio_mfe.use_new_course_outline_page
|
||||
# .. toggle_implementation: CourseWaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: This flag enables the use of the new studio course outline page mfe
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2023-5-15
|
||||
# .. toggle_target_removal_date: 2023-8-31
|
||||
# .. toggle_tickets: TNL-10619
|
||||
# .. toggle_warning:
|
||||
ENABLE_NEW_STUDIO_COURSE_OUTLINE_PAGE = CourseWaffleFlag(
|
||||
f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_course_outline_page', __name__)
|
||||
|
||||
|
||||
def use_new_course_outline_page(course_key):
|
||||
"""
|
||||
Returns a boolean if new studio course outline mfe is enabled
|
||||
"""
|
||||
return ENABLE_NEW_STUDIO_COURSE_OUTLINE_PAGE.is_enabled(course_key)
|
||||
|
||||
|
||||
# .. toggle_name: new_studio_mfe.use_new_unit_page
|
||||
# .. toggle_implementation: CourseWaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: This flag enables the use of the new studio course outline page mfe
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2023-5-15
|
||||
# .. toggle_target_removal_date: 2023-8-31
|
||||
# .. toggle_tickets: TNL-10619
|
||||
# .. toggle_warning:
|
||||
ENABLE_NEW_STUDIO_UNIT_PAGE = CourseWaffleFlag(
|
||||
f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_unit_page', __name__)
|
||||
|
||||
|
||||
def use_new_unit_page(course_key):
|
||||
"""
|
||||
Returns a boolean if new studio course outline mfe is enabled
|
||||
"""
|
||||
return ENABLE_NEW_STUDIO_UNIT_PAGE.is_enabled(course_key)
|
||||
|
||||
|
||||
# .. toggle_name: new_studio_mfe.use_new_course_team_page
|
||||
# .. toggle_implementation: CourseWaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: This flag enables the use of the new studio course team page mfe
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2023-5-15
|
||||
# .. toggle_target_removal_date: 2023-8-31
|
||||
# .. toggle_tickets: TNL-10619
|
||||
# .. toggle_warning:
|
||||
ENABLE_NEW_STUDIO_COURSE_TEAM_PAGE = CourseWaffleFlag(
|
||||
f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_course_team_page', __name__)
|
||||
|
||||
|
||||
def use_new_course_team_page(course_key):
|
||||
"""
|
||||
Returns a boolean if new studio course team mfe is enabled
|
||||
"""
|
||||
return ENABLE_NEW_STUDIO_COURSE_TEAM_PAGE.is_enabled(course_key)
|
||||
|
||||
@@ -5,33 +5,80 @@ Common utility functions useful throughout the contentstore
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.urls import reverse
|
||||
from django.utils import translation
|
||||
from django.utils.translation import gettext as _
|
||||
from lti_consumer.models import CourseAllowPIISharingInLTIFlag
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
from opaque_keys.edx.locator import LibraryLocator
|
||||
from openedx_events.content_authoring.data import DuplicatedXBlockData
|
||||
from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED
|
||||
from milestones import api as milestones_api
|
||||
from pytz import UTC
|
||||
from xblock.fields import Scope
|
||||
|
||||
from cms.djangoapps.contentstore.toggles import exam_setting_view_enabled
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
from common.djangoapps.edxmako.services import MakoService
|
||||
from common.djangoapps.student import auth
|
||||
from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole
|
||||
from common.djangoapps.student.roles import (
|
||||
CourseInstructorRole,
|
||||
CourseStaffRole,
|
||||
GlobalStaff,
|
||||
)
|
||||
from common.djangoapps.util.course import get_link_for_about_page
|
||||
from common.djangoapps.util.milestones_helpers import (
|
||||
is_prerequisite_courses_enabled,
|
||||
is_valid_course_key,
|
||||
remove_prerequisite_course,
|
||||
set_prerequisite_courses,
|
||||
get_namespace_choices,
|
||||
generate_milestone_namespace
|
||||
)
|
||||
from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService
|
||||
from openedx.core import toggles as core_toggles
|
||||
from openedx.core.djangoapps.course_apps.toggles import proctoring_settings_modal_view_enabled
|
||||
from openedx.core.djangoapps.credit.api import get_credit_requirements, is_credit_course
|
||||
from openedx.core.djangoapps.discussions.config.waffle import ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND
|
||||
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration
|
||||
from openedx.core.djangoapps.django_comment_common.models import assign_default_role
|
||||
from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
|
||||
from openedx.core.djangoapps.models.course_details import CourseDetails
|
||||
from openedx.core.lib.courses import course_image_url
|
||||
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
|
||||
from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_SCHEME
|
||||
from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML
|
||||
from cms.djangoapps.contentstore.toggles import (
|
||||
use_new_advanced_settings_page,
|
||||
use_new_course_outline_page,
|
||||
use_new_export_page,
|
||||
use_new_files_uploads_page,
|
||||
use_new_grading_page,
|
||||
use_new_course_team_page,
|
||||
use_new_home_page,
|
||||
use_new_import_page,
|
||||
use_new_schedule_details_page,
|
||||
use_new_unit_page,
|
||||
use_new_updates_page,
|
||||
use_new_video_uploads_page,
|
||||
)
|
||||
from cms.djangoapps.contentstore.toggles import use_new_text_editor, use_new_video_editor
|
||||
from xmodule.library_tools import LibraryToolsService
|
||||
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.partitions.partitions_service import get_all_partitions_for_course # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.services import SettingsService, ConfigurationService, TeamsConfigurationService
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -212,6 +259,161 @@ def get_editor_page_base_url(course_locator) -> str:
|
||||
return editor_url
|
||||
|
||||
|
||||
def get_studio_home_url():
|
||||
"""
|
||||
Gets course authoring microfrontend URL for Studio Home view.
|
||||
"""
|
||||
studio_home_url = None
|
||||
if use_new_home_page():
|
||||
mfe_base_url = settings.COURSE_AUTHORING_MICROFRONTEND_URL
|
||||
if mfe_base_url:
|
||||
studio_home_url = f'{mfe_base_url}/home'
|
||||
return studio_home_url
|
||||
|
||||
|
||||
def get_schedule_details_url(course_locator) -> str:
|
||||
"""
|
||||
Gets course authoring microfrontend URL for schedule and details pages view.
|
||||
"""
|
||||
schedule_details_url = None
|
||||
if use_new_schedule_details_page(course_locator):
|
||||
mfe_base_url = get_course_authoring_url(course_locator)
|
||||
course_mfe_url = f'{mfe_base_url}/course/{course_locator}/settings/details'
|
||||
if mfe_base_url:
|
||||
schedule_details_url = course_mfe_url
|
||||
return schedule_details_url
|
||||
|
||||
|
||||
def get_advanced_settings_url(course_locator) -> str:
|
||||
"""
|
||||
Gets course authoring microfrontend URL for advanced settings page view.
|
||||
"""
|
||||
advanced_settings_url = None
|
||||
if use_new_advanced_settings_page(course_locator):
|
||||
mfe_base_url = get_course_authoring_url(course_locator)
|
||||
course_mfe_url = f'{mfe_base_url}/course/{course_locator}/settings/advanced'
|
||||
if mfe_base_url:
|
||||
advanced_settings_url = course_mfe_url
|
||||
return advanced_settings_url
|
||||
|
||||
|
||||
def get_grading_url(course_locator) -> str:
|
||||
"""
|
||||
Gets course authoring microfrontend URL for grading page view.
|
||||
"""
|
||||
grading_url = None
|
||||
if use_new_grading_page(course_locator):
|
||||
mfe_base_url = get_course_authoring_url(course_locator)
|
||||
course_mfe_url = f'{mfe_base_url}/course/{course_locator}/settings/grading'
|
||||
if mfe_base_url:
|
||||
grading_url = course_mfe_url
|
||||
return grading_url
|
||||
|
||||
|
||||
def get_course_team_url(course_locator) -> str:
|
||||
"""
|
||||
Gets course authoring microfrontend URL for course team page view.
|
||||
"""
|
||||
course_team_url = None
|
||||
if use_new_course_team_page(course_locator):
|
||||
mfe_base_url = get_course_authoring_url(course_locator)
|
||||
course_mfe_url = f'{mfe_base_url}/course/{course_locator}/course_team'
|
||||
if mfe_base_url:
|
||||
course_team_url = course_mfe_url
|
||||
return course_team_url
|
||||
|
||||
|
||||
def get_updates_url(course_locator) -> str:
|
||||
"""
|
||||
Gets course authoring microfrontend URL for updates page view.
|
||||
"""
|
||||
updates_url = None
|
||||
if use_new_updates_page(course_locator):
|
||||
mfe_base_url = get_course_authoring_url(course_locator)
|
||||
course_mfe_url = f'{mfe_base_url}/course/{course_locator}/course_info'
|
||||
if mfe_base_url:
|
||||
updates_url = course_mfe_url
|
||||
return updates_url
|
||||
|
||||
|
||||
def get_import_url(course_locator) -> str:
|
||||
"""
|
||||
Gets course authoring microfrontend URL for import page view.
|
||||
"""
|
||||
import_url = None
|
||||
if use_new_import_page(course_locator):
|
||||
mfe_base_url = get_course_authoring_url(course_locator)
|
||||
course_mfe_url = f'{mfe_base_url}/course/{course_locator}/import'
|
||||
if mfe_base_url:
|
||||
import_url = course_mfe_url
|
||||
return import_url
|
||||
|
||||
|
||||
def get_export_url(course_locator) -> str:
|
||||
"""
|
||||
Gets course authoring microfrontend URL for export page view.
|
||||
"""
|
||||
export_url = None
|
||||
if use_new_export_page(course_locator):
|
||||
mfe_base_url = get_course_authoring_url(course_locator)
|
||||
course_mfe_url = f'{mfe_base_url}/course/{course_locator}/export'
|
||||
if mfe_base_url:
|
||||
export_url = course_mfe_url
|
||||
return export_url
|
||||
|
||||
|
||||
def get_files_uploads_url(course_locator) -> str:
|
||||
"""
|
||||
Gets course authoring microfrontend URL for files and uploads page view.
|
||||
"""
|
||||
files_uploads_url = None
|
||||
if use_new_files_uploads_page(course_locator):
|
||||
mfe_base_url = get_course_authoring_url(course_locator)
|
||||
course_mfe_url = f'{mfe_base_url}/course/{course_locator}/assets'
|
||||
if mfe_base_url:
|
||||
files_uploads_url = course_mfe_url
|
||||
return files_uploads_url
|
||||
|
||||
|
||||
def get_video_uploads_url(course_locator) -> str:
|
||||
"""
|
||||
Gets course authoring microfrontend URL for files and uploads page view.
|
||||
"""
|
||||
video_uploads_url = None
|
||||
if use_new_video_uploads_page(course_locator):
|
||||
mfe_base_url = get_course_authoring_url(course_locator)
|
||||
course_mfe_url = f'{mfe_base_url}/course/{course_locator}/videos/'
|
||||
if mfe_base_url:
|
||||
video_uploads_url = course_mfe_url
|
||||
return video_uploads_url
|
||||
|
||||
|
||||
def get_course_outline_url(course_locator) -> str:
|
||||
"""
|
||||
Gets course authoring microfrontend URL for course oultine page view.
|
||||
"""
|
||||
course_outline_url = None
|
||||
if use_new_course_outline_page(course_locator):
|
||||
mfe_base_url = get_course_authoring_url(course_locator)
|
||||
course_mfe_url = f'{mfe_base_url}/course/{course_locator}'
|
||||
if mfe_base_url:
|
||||
course_outline_url = course_mfe_url
|
||||
return course_outline_url
|
||||
|
||||
|
||||
def get_unit_url(course_locator) -> str:
|
||||
"""
|
||||
Gets course authoring microfrontend URL for unit page view.
|
||||
"""
|
||||
unit_url = None
|
||||
if use_new_unit_page(course_locator):
|
||||
mfe_base_url = get_course_authoring_url(course_locator)
|
||||
course_mfe_url = f'{mfe_base_url}/container/'
|
||||
if mfe_base_url:
|
||||
unit_url = course_mfe_url
|
||||
return unit_url
|
||||
|
||||
|
||||
def course_import_olx_validation_is_enabled():
|
||||
"""
|
||||
Check if course olx validation is enabled on course import.
|
||||
@@ -371,7 +573,7 @@ def get_split_group_display_name(xblock, course):
|
||||
|
||||
Arguments:
|
||||
xblock (XBlock): The courseware component.
|
||||
course (XBlock): The course descriptor.
|
||||
course (XBlock): The course block.
|
||||
|
||||
Returns:
|
||||
group name (String): Group name of the matching group xblock.
|
||||
@@ -398,14 +600,14 @@ def get_user_partition_info(xblock, schemes=None, course=None):
|
||||
schemes (iterable of str): If provided, filter partitions to include only
|
||||
schemes with the provided names.
|
||||
|
||||
course (XBlock): The course descriptor. If provided, uses this to look up the user partitions
|
||||
course (XBlock): The course block. If provided, uses this to look up the user partitions
|
||||
instead of loading the course. This is useful if we're calling this function multiple
|
||||
times for the same course want to minimize queries to the modulestore.
|
||||
|
||||
Returns: list
|
||||
|
||||
Example Usage:
|
||||
>>> get_user_partition_info(block, schemes=["cohort", "verification"])
|
||||
>>> get_user_partition_info(xblock, schemes=["cohort", "verification"])
|
||||
[
|
||||
{
|
||||
"id": 12345,
|
||||
@@ -508,7 +710,7 @@ def get_visibility_partition_info(xblock, course=None):
|
||||
Arguments:
|
||||
xblock (XBlock): The component being edited.
|
||||
|
||||
course (XBlock): The course descriptor. If provided, uses this to look up the user partitions
|
||||
course (XBlock): The course block. If provided, uses this to look up the user partitions
|
||||
instead of loading the course. This is useful if we're calling this function multiple
|
||||
times for the same course want to minimize queries to the modulestore.
|
||||
|
||||
@@ -568,8 +770,8 @@ def get_xblock_aside_instance(usage_key):
|
||||
:param usage_key: Usage key of aside xblock
|
||||
"""
|
||||
try:
|
||||
descriptor = modulestore().get_item(usage_key.usage_key)
|
||||
for aside in descriptor.runtime.get_asides(descriptor):
|
||||
xblock = modulestore().get_item(usage_key.usage_key)
|
||||
for aside in xblock.runtime.get_asides(xblock):
|
||||
if aside.scope_ids.block_type == usage_key.aside_type:
|
||||
return aside
|
||||
except ItemNotFoundError:
|
||||
@@ -753,3 +955,375 @@ def get_subsections_by_assignment_type(course_key):
|
||||
f'{section.display_name} - {subsection.display_name}'
|
||||
)
|
||||
return subsections_by_assignment_type
|
||||
|
||||
|
||||
def update_course_discussions_settings(course_key):
|
||||
"""
|
||||
Updates course provider_type when new course is created
|
||||
"""
|
||||
provider = DiscussionsConfiguration.get(context_key=course_key).provider_type
|
||||
store = modulestore()
|
||||
course = store.get_course(course_key)
|
||||
course.discussions_settings['provider_type'] = provider
|
||||
store.update_item(course, course.published_by)
|
||||
|
||||
|
||||
def duplicate_block(
|
||||
parent_usage_key,
|
||||
duplicate_source_usage_key,
|
||||
user,
|
||||
dest_usage_key=None,
|
||||
display_name=None,
|
||||
shallow=False,
|
||||
is_child=False
|
||||
):
|
||||
"""
|
||||
Duplicate an existing xblock as a child of the supplied parent_usage_key. You can
|
||||
optionally specify what usage key the new duplicate block will use via dest_usage_key.
|
||||
|
||||
If shallow is True, does not copy children. Otherwise, this function calls itself
|
||||
recursively, and will set the is_child flag to True when dealing with recursed child
|
||||
blocks.
|
||||
"""
|
||||
store = modulestore()
|
||||
with store.bulk_operations(duplicate_source_usage_key.course_key):
|
||||
source_item = store.get_item(duplicate_source_usage_key)
|
||||
if not dest_usage_key:
|
||||
# Change the blockID to be unique.
|
||||
dest_usage_key = source_item.location.replace(name=uuid4().hex)
|
||||
|
||||
category = dest_usage_key.block_type
|
||||
|
||||
duplicate_metadata, asides_to_create = gather_block_attributes(
|
||||
source_item, display_name=display_name, is_child=is_child,
|
||||
)
|
||||
|
||||
dest_block = store.create_item(
|
||||
user.id,
|
||||
dest_usage_key.course_key,
|
||||
dest_usage_key.block_type,
|
||||
block_id=dest_usage_key.block_id,
|
||||
definition_data=source_item.get_explicitly_set_fields_by_scope(Scope.content),
|
||||
metadata=duplicate_metadata,
|
||||
runtime=source_item.runtime,
|
||||
asides=asides_to_create
|
||||
)
|
||||
|
||||
children_handled = False
|
||||
|
||||
if hasattr(dest_block, 'studio_post_duplicate'):
|
||||
# Allow an XBlock to do anything fancy it may need to when duplicated from another block.
|
||||
# These blocks may handle their own children or parenting if needed. Let them return booleans to
|
||||
# let us know if we need to handle these or not.
|
||||
load_services_for_studio(dest_block.runtime, user)
|
||||
children_handled = dest_block.studio_post_duplicate(store, source_item)
|
||||
|
||||
# Children are not automatically copied over (and not all xblocks have a 'children' attribute).
|
||||
# Because DAGs are not fully supported, we need to actually duplicate each child as well.
|
||||
if source_item.has_children and not shallow and not children_handled:
|
||||
dest_block.children = dest_block.children or []
|
||||
for child in source_item.children:
|
||||
dupe = duplicate_block(dest_block.location, child, user=user, is_child=True)
|
||||
if dupe not in dest_block.children: # _duplicate_block may add the child for us.
|
||||
dest_block.children.append(dupe)
|
||||
store.update_item(dest_block, user.id)
|
||||
|
||||
# pylint: disable=protected-access
|
||||
if 'detached' not in source_item.runtime.load_block_type(category)._class_tags:
|
||||
parent = store.get_item(parent_usage_key)
|
||||
# If source was already a child of the parent, add duplicate immediately afterward.
|
||||
# Otherwise, add child to end.
|
||||
if source_item.location in parent.children:
|
||||
source_index = parent.children.index(source_item.location)
|
||||
parent.children.insert(source_index + 1, dest_block.location)
|
||||
else:
|
||||
parent.children.append(dest_block.location)
|
||||
store.update_item(parent, user.id)
|
||||
|
||||
# .. event_implemented_name: XBLOCK_DUPLICATED
|
||||
XBLOCK_DUPLICATED.send_event(
|
||||
time=datetime.now(timezone.utc),
|
||||
xblock_info=DuplicatedXBlockData(
|
||||
usage_key=dest_block.location,
|
||||
block_type=dest_block.location.block_type,
|
||||
source_usage_key=duplicate_source_usage_key,
|
||||
)
|
||||
)
|
||||
|
||||
return dest_block.location
|
||||
|
||||
|
||||
def update_from_source(*, source_block, destination_block, user_id):
|
||||
"""
|
||||
Update a block to have all the settings and attributes of another source.
|
||||
|
||||
Copies over all attributes and settings of a source block to a destination
|
||||
block. Blocks must be the same type. This function does not modify or duplicate
|
||||
children.
|
||||
|
||||
This function is useful when a block, originally copied from a source block, drifts
|
||||
and needs to be updated to match the original.
|
||||
|
||||
The modulestore function copy_from_template will copy a block's children recursively,
|
||||
replacing the target block's children. It does not, however, update any of the target
|
||||
block's settings. copy_from_template, then, is useful for cases like the Library
|
||||
Content Block, where the children are the same across all instances, but the settings
|
||||
may differ.
|
||||
|
||||
By contrast, for cases where we're copying a block that has drifted from its source,
|
||||
we need to update the target block's settings, but we don't want to replace its children,
|
||||
or, at least, not only replace its children. update_from_source is useful for these cases.
|
||||
|
||||
This function is meant to be imported by pluggable django apps looking to manage duplicated
|
||||
sections of a course. It is placed here for lack of a more appropriate location, since this
|
||||
code has not yet been brought up to the standards in OEP-45.
|
||||
"""
|
||||
duplicate_metadata, asides = gather_block_attributes(source_block, display_name=source_block.display_name)
|
||||
for key, value in duplicate_metadata.items():
|
||||
setattr(destination_block, key, value)
|
||||
for key, value in source_block.get_explicitly_set_fields_by_scope(Scope.content).items():
|
||||
setattr(destination_block, key, value)
|
||||
modulestore().update_item(
|
||||
destination_block,
|
||||
user_id,
|
||||
metadata=duplicate_metadata,
|
||||
asides=asides,
|
||||
)
|
||||
|
||||
|
||||
def gather_block_attributes(source_item, display_name=None, is_child=False):
|
||||
"""
|
||||
Gather all the attributes of the source block that need to be copied over to a new or updated block.
|
||||
"""
|
||||
# Update the display name to indicate this is a duplicate (unless display name provided).
|
||||
# Can't use own_metadata(), b/c it converts data for JSON serialization -
|
||||
# not suitable for setting metadata of the new block
|
||||
duplicate_metadata = {}
|
||||
for field in source_item.fields.values():
|
||||
if field.scope == Scope.settings and field.is_set_on(source_item):
|
||||
duplicate_metadata[field.name] = field.read_from(source_item)
|
||||
|
||||
if is_child:
|
||||
display_name = display_name or source_item.display_name or source_item.category
|
||||
|
||||
if display_name is not None:
|
||||
duplicate_metadata['display_name'] = display_name
|
||||
else:
|
||||
if source_item.display_name is None:
|
||||
duplicate_metadata['display_name'] = _("Duplicate of {0}").format(source_item.category)
|
||||
else:
|
||||
duplicate_metadata['display_name'] = _("Duplicate of '{0}'").format(source_item.display_name)
|
||||
|
||||
asides_to_create = []
|
||||
for aside in source_item.runtime.get_asides(source_item):
|
||||
for field in aside.fields.values():
|
||||
if field.scope in (Scope.settings, Scope.content,) and field.is_set_on(aside):
|
||||
asides_to_create.append(aside)
|
||||
break
|
||||
|
||||
for aside in asides_to_create:
|
||||
for field in aside.fields.values():
|
||||
if field.scope not in (Scope.settings, Scope.content,):
|
||||
field.delete_from(aside)
|
||||
return duplicate_metadata, asides_to_create
|
||||
|
||||
|
||||
def load_services_for_studio(runtime, user):
|
||||
"""
|
||||
Function to set some required services used for XBlock edits and studio_view.
|
||||
(i.e. whenever we're not loading _prepare_runtime_for_preview.) This is required to make information
|
||||
about the current user (especially permissions) available via services as needed.
|
||||
"""
|
||||
services = {
|
||||
"user": DjangoXBlockUserService(user),
|
||||
"studio_user_permissions": StudioPermissionsService(user),
|
||||
"mako": MakoService(),
|
||||
"settings": SettingsService(),
|
||||
"lti-configuration": ConfigurationService(CourseAllowPIISharingInLTIFlag),
|
||||
"teams_configuration": TeamsConfigurationService(),
|
||||
"library_tools": LibraryToolsService(modulestore(), user.id)
|
||||
}
|
||||
|
||||
runtime._services.update(services) # lint-amnesty, pylint: disable=protected-access
|
||||
|
||||
|
||||
def update_course_details(request, course_key, payload, course_block):
|
||||
"""
|
||||
Utils is used to update course details.
|
||||
It is used for both DRF and django views.
|
||||
"""
|
||||
|
||||
from .views.entrance_exam import create_entrance_exam, delete_entrance_exam, update_entrance_exam
|
||||
|
||||
# if pre-requisite course feature is enabled set pre-requisite course
|
||||
if is_prerequisite_courses_enabled():
|
||||
prerequisite_course_keys = payload.get('pre_requisite_courses', [])
|
||||
if prerequisite_course_keys:
|
||||
if not all(is_valid_course_key(course_key) for course_key in prerequisite_course_keys):
|
||||
raise ValidationError(_("Invalid prerequisite course key"))
|
||||
set_prerequisite_courses(course_key, prerequisite_course_keys)
|
||||
else:
|
||||
# None is chosen, so remove the course prerequisites
|
||||
course_milestones = milestones_api.get_course_milestones(
|
||||
course_key=course_key,
|
||||
relationship="requires",
|
||||
)
|
||||
for milestone in course_milestones:
|
||||
entrance_exam_namespace = generate_milestone_namespace(
|
||||
get_namespace_choices().get('ENTRANCE_EXAM'),
|
||||
course_key
|
||||
)
|
||||
if milestone["namespace"] != entrance_exam_namespace:
|
||||
remove_prerequisite_course(course_key, milestone)
|
||||
|
||||
# If the entrance exams feature has been enabled, we'll need to check for some
|
||||
# feature-specific settings and handle them accordingly
|
||||
# We have to be careful that we're only executing the following logic if we actually
|
||||
# need to create or delete an entrance exam from the specified course
|
||||
if core_toggles.ENTRANCE_EXAMS.is_enabled():
|
||||
course_entrance_exam_present = course_block.entrance_exam_enabled
|
||||
entrance_exam_enabled = payload.get('entrance_exam_enabled', '') == 'true'
|
||||
ee_min_score_pct = payload.get('entrance_exam_minimum_score_pct', None)
|
||||
# If the entrance exam box on the settings screen has been checked...
|
||||
if entrance_exam_enabled:
|
||||
# Load the default minimum score threshold from settings, then try to override it
|
||||
entrance_exam_minimum_score_pct = float(settings.ENTRANCE_EXAM_MIN_SCORE_PCT)
|
||||
if ee_min_score_pct:
|
||||
entrance_exam_minimum_score_pct = float(ee_min_score_pct)
|
||||
if entrance_exam_minimum_score_pct.is_integer():
|
||||
entrance_exam_minimum_score_pct = entrance_exam_minimum_score_pct / 100
|
||||
# If there's already an entrance exam defined, we'll update the existing one
|
||||
if course_entrance_exam_present:
|
||||
exam_data = {
|
||||
'entrance_exam_minimum_score_pct': entrance_exam_minimum_score_pct
|
||||
}
|
||||
update_entrance_exam(request, course_key, exam_data)
|
||||
# If there's no entrance exam defined, we'll create a new one
|
||||
else:
|
||||
create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct)
|
||||
|
||||
# If the entrance exam box on the settings screen has been unchecked,
|
||||
# and the course has an entrance exam attached...
|
||||
elif not entrance_exam_enabled and course_entrance_exam_present:
|
||||
delete_entrance_exam(request, course_key)
|
||||
|
||||
# Perform the normal update workflow for the CourseDetails model
|
||||
return CourseDetails.update_from_json(course_key, payload, request.user)
|
||||
|
||||
|
||||
def get_course_settings(request, course_key, course_block):
|
||||
"""
|
||||
Utils is used to get context of course settings.
|
||||
It is used for both DRF and django views.
|
||||
"""
|
||||
|
||||
from .views.course import get_courses_accessible_to_user, _process_courses_list
|
||||
|
||||
credit_eligibility_enabled = settings.FEATURES.get('ENABLE_CREDIT_ELIGIBILITY', False)
|
||||
upload_asset_url = reverse_course_url('assets_handler', course_key)
|
||||
|
||||
# see if the ORG of this course can be attributed to a defined configuration . In that case, the
|
||||
# course about page should be editable in Studio
|
||||
publisher_enabled = configuration_helpers.get_value_for_org(
|
||||
course_block.location.org,
|
||||
'ENABLE_PUBLISHER',
|
||||
settings.FEATURES.get('ENABLE_PUBLISHER', False)
|
||||
)
|
||||
marketing_enabled = configuration_helpers.get_value_for_org(
|
||||
course_block.location.org,
|
||||
'ENABLE_MKTG_SITE',
|
||||
settings.FEATURES.get('ENABLE_MKTG_SITE', False)
|
||||
)
|
||||
enable_extended_course_details = configuration_helpers.get_value_for_org(
|
||||
course_block.location.org,
|
||||
'ENABLE_EXTENDED_COURSE_DETAILS',
|
||||
settings.FEATURES.get('ENABLE_EXTENDED_COURSE_DETAILS', False)
|
||||
)
|
||||
|
||||
about_page_editable = not publisher_enabled
|
||||
enrollment_end_editable = GlobalStaff().has_user(request.user) or not publisher_enabled
|
||||
short_description_editable = configuration_helpers.get_value_for_org(
|
||||
course_block.location.org,
|
||||
'EDITABLE_SHORT_DESCRIPTION',
|
||||
settings.FEATURES.get('EDITABLE_SHORT_DESCRIPTION', True)
|
||||
)
|
||||
sidebar_html_enabled = ENABLE_COURSE_ABOUT_SIDEBAR_HTML.is_enabled()
|
||||
|
||||
verified_mode = CourseMode.verified_mode_for_course(course_key, include_expired=True)
|
||||
upgrade_deadline = (verified_mode and verified_mode.expiration_datetime and
|
||||
verified_mode.expiration_datetime.isoformat())
|
||||
settings_context = {
|
||||
'context_course': course_block,
|
||||
'course_locator': course_key,
|
||||
'lms_link_for_about_page': get_link_for_about_page(course_block),
|
||||
'course_image_url': course_image_url(course_block, 'course_image'),
|
||||
'banner_image_url': course_image_url(course_block, 'banner_image'),
|
||||
'video_thumbnail_image_url': course_image_url(course_block, 'video_thumbnail_image'),
|
||||
'details_url': reverse_course_url('settings_handler', course_key),
|
||||
'about_page_editable': about_page_editable,
|
||||
'marketing_enabled': marketing_enabled,
|
||||
'short_description_editable': short_description_editable,
|
||||
'sidebar_html_enabled': sidebar_html_enabled,
|
||||
'upload_asset_url': upload_asset_url,
|
||||
'course_handler_url': reverse_course_url('course_handler', course_key),
|
||||
'language_options': settings.ALL_LANGUAGES,
|
||||
'credit_eligibility_enabled': credit_eligibility_enabled,
|
||||
'is_credit_course': False,
|
||||
'show_min_grade_warning': False,
|
||||
'enrollment_end_editable': enrollment_end_editable,
|
||||
'is_prerequisite_courses_enabled': is_prerequisite_courses_enabled(),
|
||||
'is_entrance_exams_enabled': core_toggles.ENTRANCE_EXAMS.is_enabled(),
|
||||
'enable_extended_course_details': enable_extended_course_details,
|
||||
'upgrade_deadline': upgrade_deadline,
|
||||
'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course_block.id),
|
||||
}
|
||||
if is_prerequisite_courses_enabled():
|
||||
courses, in_process_course_actions = get_courses_accessible_to_user(request)
|
||||
# exclude current course from the list of available courses
|
||||
courses = [course for course in courses if course.id != course_key]
|
||||
if courses:
|
||||
courses, __ = _process_courses_list(courses, in_process_course_actions)
|
||||
settings_context.update({'possible_pre_requisite_courses': courses})
|
||||
|
||||
if credit_eligibility_enabled:
|
||||
if is_credit_course(course_key):
|
||||
# get and all credit eligibility requirements
|
||||
credit_requirements = get_credit_requirements(course_key)
|
||||
# pair together requirements with same 'namespace' values
|
||||
paired_requirements = {}
|
||||
for requirement in credit_requirements:
|
||||
namespace = requirement.pop("namespace")
|
||||
paired_requirements.setdefault(namespace, []).append(requirement)
|
||||
|
||||
# if 'minimum_grade_credit' of a course is not set or 0 then
|
||||
# show warning message to course author.
|
||||
show_min_grade_warning = False if course_block.minimum_grade_credit > 0 else True # lint-amnesty, pylint: disable=simplifiable-if-expression
|
||||
settings_context.update(
|
||||
{
|
||||
'is_credit_course': True,
|
||||
'credit_requirements': paired_requirements,
|
||||
'show_min_grade_warning': show_min_grade_warning,
|
||||
}
|
||||
)
|
||||
|
||||
return settings_context
|
||||
|
||||
|
||||
class StudioPermissionsService:
|
||||
"""
|
||||
Service that can provide information about a user's permissions.
|
||||
|
||||
Deprecated. To be replaced by a more general authorization service.
|
||||
|
||||
Only used by LibraryContentBlock (and library_tools.py).
|
||||
"""
|
||||
def __init__(self, user):
|
||||
self._user = user
|
||||
|
||||
def can_read(self, course_key):
|
||||
""" Does the user have read access to the given course/library? """
|
||||
return has_studio_read_access(self._user, course_key)
|
||||
|
||||
def can_write(self, course_key):
|
||||
""" Does the user have read access to the given course/library? """
|
||||
return has_studio_write_access(self._user, course_key)
|
||||
|
||||
@@ -4,19 +4,15 @@ import logging
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import Http404, HttpResponse, HttpResponseBadRequest
|
||||
from django.utils.timezone import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from edx_django_utils.plugins import pluggable_override
|
||||
from openedx_events.content_authoring.data import DuplicatedXBlockData
|
||||
from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED
|
||||
from edx_proctoring.api import (
|
||||
does_backend_support_onboarding,
|
||||
get_exam_by_content_id,
|
||||
@@ -24,7 +20,6 @@ from edx_proctoring.api import (
|
||||
)
|
||||
from edx_proctoring.exceptions import ProctoredExamNotFoundException
|
||||
from help_tokens.core import HelpUrlExpert
|
||||
from lti_consumer.models import CourseAllowPIISharingInLTIFlag
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys.edx.locator import LibraryUsageLocator
|
||||
from pytz import UTC
|
||||
@@ -35,26 +30,23 @@ from xblock.fields import Scope
|
||||
from cms.djangoapps.contentstore.config.waffle import SHOW_REVIEW_RULES_FLAG
|
||||
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
|
||||
from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW
|
||||
from common.djangoapps.edxmako.services import MakoService
|
||||
from common.djangoapps.edxmako.shortcuts import render_to_string
|
||||
from common.djangoapps.static_replace import replace_static_urls
|
||||
from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
|
||||
from common.djangoapps.util.date_utils import get_default_time_display
|
||||
from common.djangoapps.util.json_request import JsonResponse, expect_json
|
||||
from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService
|
||||
from openedx.core.djangoapps.bookmarks import api as bookmarks_api
|
||||
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration
|
||||
from openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE
|
||||
from openedx.core.lib.gating import api as gating_api
|
||||
from openedx.core.lib.xblock_utils import hash_resource, request_token, wrap_xblock, wrap_xblock_aside
|
||||
from openedx.core.toggles import ENTRANCE_EXAMS
|
||||
from xmodule.course_block import DEFAULT_START_DATE # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.library_tools import LibraryToolsService # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore import EdxJSONEncoder, ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.draft_and_published import DIRECT_ONLY_CATEGORIES # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.inheritance import own_metadata # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.services import ConfigurationService, SettingsService, TeamsConfigurationService # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.tabs import CourseTabList # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.x_module import AUTHOR_VIEW, PREVIEW_VIEWS, STUDENT_VIEW, STUDIO_VIEW # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
@@ -67,11 +59,12 @@ from ..utils import (
|
||||
get_visibility_partition_info,
|
||||
has_children_visible_to_specific_partition_groups,
|
||||
is_currently_visible_to_students,
|
||||
is_self_paced
|
||||
is_self_paced, duplicate_block, load_services_for_studio
|
||||
)
|
||||
from .helpers import (
|
||||
create_xblock,
|
||||
get_parent_xblock,
|
||||
import_staged_content_from_user_clipboard,
|
||||
is_unit,
|
||||
usage_key_with_run,
|
||||
xblock_primary_child_category,
|
||||
@@ -169,6 +162,8 @@ def xblock_handler(request, usage_key_string=None):
|
||||
:display_name: name for new xblock, optional
|
||||
:boilerplate: template name for populating fields, optional and only used
|
||||
if duplicate_source_locator is not present
|
||||
:staged_content: use "clipboard" to paste from the OLX user's clipboard. (Incompatible with all other
|
||||
fields except parent_locator)
|
||||
The locator (unicode representation of a UsageKey) for the created xblock (minus children) is returned.
|
||||
"""
|
||||
if usage_key_string:
|
||||
@@ -241,11 +236,11 @@ def xblock_handler(request, usage_key_string=None):
|
||||
status=400
|
||||
)
|
||||
|
||||
dest_usage_key = _duplicate_block(
|
||||
dest_usage_key = duplicate_block(
|
||||
parent_usage_key,
|
||||
duplicate_source_usage_key,
|
||||
request.user,
|
||||
request.json.get('display_name'),
|
||||
display_name=request.json.get('display_name'),
|
||||
)
|
||||
return JsonResponse({
|
||||
'locator': str(dest_usage_key),
|
||||
@@ -273,45 +268,6 @@ def xblock_handler(request, usage_key_string=None):
|
||||
)
|
||||
|
||||
|
||||
class StudioPermissionsService:
|
||||
"""
|
||||
Service that can provide information about a user's permissions.
|
||||
|
||||
Deprecated. To be replaced by a more general authorization service.
|
||||
|
||||
Only used by LibraryContentBlock (and library_tools.py).
|
||||
"""
|
||||
def __init__(self, user):
|
||||
self._user = user
|
||||
|
||||
def can_read(self, course_key):
|
||||
""" Does the user have read access to the given course/library? """
|
||||
return has_studio_read_access(self._user, course_key)
|
||||
|
||||
def can_write(self, course_key):
|
||||
""" Does the user have read access to the given course/library? """
|
||||
return has_studio_write_access(self._user, course_key)
|
||||
|
||||
|
||||
def load_services_for_studio(runtime, user):
|
||||
"""
|
||||
Function to set some required services used for XBlock edits and studio_view.
|
||||
(i.e. whenever we're not loading _prepare_runtime_for_preview.) This is required to make information
|
||||
about the current user (especially permissions) available via services as needed.
|
||||
"""
|
||||
services = {
|
||||
"user": DjangoXBlockUserService(user),
|
||||
"studio_user_permissions": StudioPermissionsService(user),
|
||||
"mako": MakoService(),
|
||||
"settings": SettingsService(),
|
||||
"lti-configuration": ConfigurationService(CourseAllowPIISharingInLTIFlag),
|
||||
"teams_configuration": TeamsConfigurationService(),
|
||||
"library_tools": LibraryToolsService(modulestore(), user.id)
|
||||
}
|
||||
|
||||
runtime._services.update(services) # lint-amnesty, pylint: disable=protected-access
|
||||
|
||||
|
||||
@require_http_methods("GET")
|
||||
@login_required
|
||||
@expect_json
|
||||
@@ -471,7 +427,7 @@ def xblock_outline_handler(request, usage_key_string):
|
||||
include_children_predicate=lambda xblock: not xblock.category == 'vertical'
|
||||
))
|
||||
else:
|
||||
return Http404
|
||||
raise Http404
|
||||
|
||||
|
||||
@require_http_methods("GET")
|
||||
@@ -496,7 +452,7 @@ def xblock_container_handler(request, usage_key_string):
|
||||
)
|
||||
return JsonResponse(response)
|
||||
else:
|
||||
return Http404
|
||||
raise Http404
|
||||
|
||||
|
||||
def _update_with_callback(xblock, user, old_metadata=None, old_content=None):
|
||||
@@ -701,6 +657,19 @@ def _create_block(request):
|
||||
if not has_studio_write_access(request.user, usage_key.course_key):
|
||||
raise PermissionDenied()
|
||||
|
||||
if request.json.get('staged_content') == "clipboard":
|
||||
# Paste from the user's clipboard (content_staging app clipboard, not browser clipboard) into 'usage_key':
|
||||
try:
|
||||
created_xblock = import_staged_content_from_user_clipboard(parent_key=usage_key, request=request)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception("Could not paste component into location {}".format(usage_key))
|
||||
return JsonResponse({"error": _('There was a problem pasting your component.')}, status=400)
|
||||
if created_xblock is None:
|
||||
return JsonResponse({"error": _('Your clipboard is empty or invalid.')}, status=400)
|
||||
return JsonResponse(
|
||||
{'locator': str(created_xblock.location), 'courseKey': str(created_xblock.location.course_key)}
|
||||
)
|
||||
|
||||
category = request.json['category']
|
||||
if isinstance(usage_key, LibraryUsageLocator):
|
||||
# Only these categories are supported at this time.
|
||||
@@ -863,103 +832,6 @@ def _move_item(source_usage_key, target_parent_usage_key, user, target_index=Non
|
||||
return JsonResponse(context)
|
||||
|
||||
|
||||
def _duplicate_block(parent_usage_key, duplicate_source_usage_key, user, display_name=None, is_child=False):
|
||||
"""
|
||||
Duplicate an existing xblock as a child of the supplied parent_usage_key.
|
||||
"""
|
||||
store = modulestore()
|
||||
with store.bulk_operations(duplicate_source_usage_key.course_key):
|
||||
source_item = store.get_item(duplicate_source_usage_key)
|
||||
# Change the blockID to be unique.
|
||||
dest_usage_key = source_item.location.replace(name=uuid4().hex)
|
||||
category = dest_usage_key.block_type
|
||||
|
||||
# Update the display name to indicate this is a duplicate (unless display name provided).
|
||||
# Can't use own_metadata(), b/c it converts data for JSON serialization -
|
||||
# not suitable for setting metadata of the new block
|
||||
duplicate_metadata = {}
|
||||
for field in source_item.fields.values():
|
||||
if field.scope == Scope.settings and field.is_set_on(source_item):
|
||||
duplicate_metadata[field.name] = field.read_from(source_item)
|
||||
|
||||
if is_child:
|
||||
display_name = display_name or source_item.display_name or source_item.category
|
||||
|
||||
if display_name is not None:
|
||||
duplicate_metadata['display_name'] = display_name
|
||||
else:
|
||||
if source_item.display_name is None:
|
||||
duplicate_metadata['display_name'] = _("Duplicate of {0}").format(source_item.category)
|
||||
else:
|
||||
duplicate_metadata['display_name'] = _("Duplicate of '{0}'").format(source_item.display_name)
|
||||
|
||||
asides_to_create = []
|
||||
for aside in source_item.runtime.get_asides(source_item):
|
||||
for field in aside.fields.values():
|
||||
if field.scope in (Scope.settings, Scope.content,) and field.is_set_on(aside):
|
||||
asides_to_create.append(aside)
|
||||
break
|
||||
|
||||
for aside in asides_to_create:
|
||||
for field in aside.fields.values():
|
||||
if field.scope not in (Scope.settings, Scope.content,):
|
||||
field.delete_from(aside)
|
||||
|
||||
dest_block = store.create_item(
|
||||
user.id,
|
||||
dest_usage_key.course_key,
|
||||
dest_usage_key.block_type,
|
||||
block_id=dest_usage_key.block_id,
|
||||
definition_data=source_item.get_explicitly_set_fields_by_scope(Scope.content),
|
||||
metadata=duplicate_metadata,
|
||||
runtime=source_item.runtime,
|
||||
asides=asides_to_create
|
||||
)
|
||||
|
||||
children_handled = False
|
||||
|
||||
if hasattr(dest_block, 'studio_post_duplicate'):
|
||||
# Allow an XBlock to do anything fancy it may need to when duplicated from another block.
|
||||
# These blocks may handle their own children or parenting if needed. Let them return booleans to
|
||||
# let us know if we need to handle these or not.
|
||||
load_services_for_studio(dest_block.runtime, user)
|
||||
children_handled = dest_block.studio_post_duplicate(store, source_item)
|
||||
|
||||
# Children are not automatically copied over (and not all xblocks have a 'children' attribute).
|
||||
# Because DAGs are not fully supported, we need to actually duplicate each child as well.
|
||||
if source_item.has_children and not children_handled:
|
||||
dest_block.children = dest_block.children or []
|
||||
for child in source_item.children:
|
||||
dupe = _duplicate_block(dest_block.location, child, user=user, is_child=True)
|
||||
if dupe not in dest_block.children: # _duplicate_block may add the child for us.
|
||||
dest_block.children.append(dupe)
|
||||
store.update_item(dest_block, user.id)
|
||||
|
||||
# pylint: disable=protected-access
|
||||
if 'detached' not in source_item.runtime.load_block_type(category)._class_tags:
|
||||
parent = store.get_item(parent_usage_key)
|
||||
# If source was already a child of the parent, add duplicate immediately afterward.
|
||||
# Otherwise, add child to end.
|
||||
if source_item.location in parent.children:
|
||||
source_index = parent.children.index(source_item.location)
|
||||
parent.children.insert(source_index + 1, dest_block.location)
|
||||
else:
|
||||
parent.children.append(dest_block.location)
|
||||
store.update_item(parent, user.id)
|
||||
|
||||
# .. event_implemented_name: XBLOCK_DUPLICATED
|
||||
XBLOCK_DUPLICATED.send_event(
|
||||
time=datetime.now(timezone.utc),
|
||||
xblock_info=DuplicatedXBlockData(
|
||||
usage_key=dest_block.location,
|
||||
block_type=dest_block.location.block_type,
|
||||
source_usage_key=duplicate_source_usage_key,
|
||||
)
|
||||
)
|
||||
|
||||
return dest_block.location
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def delete_item(request, usage_key):
|
||||
@@ -1224,6 +1096,13 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
|
||||
'has_children': xblock.has_children
|
||||
}
|
||||
|
||||
if course is not None and PUBLIC_VIDEO_SHARE.is_enabled(xblock.location.course_key):
|
||||
xblock_info.update({
|
||||
'video_sharing_enabled': True,
|
||||
'video_sharing_options': course.video_sharing_options,
|
||||
'video_sharing_doc_url': HelpUrlExpert.the_one().url_for_token('social_sharing')
|
||||
})
|
||||
|
||||
if xblock.category == 'course':
|
||||
discussions_config = DiscussionsConfiguration.get(course.id)
|
||||
show_unit_level_discussions_toggle = (
|
||||
|
||||
@@ -128,7 +128,7 @@ class CertificateValidationError(CertificateException):
|
||||
class CertificateManager:
|
||||
"""
|
||||
The CertificateManager is responsible for storage, retrieval, and manipulation of Certificates
|
||||
Certificates are not stored in the Django ORM, they are a field/setting on the course descriptor
|
||||
Certificates are not stored in the Django ORM, they are a field/setting on the course block
|
||||
"""
|
||||
@staticmethod
|
||||
def parse(json_string):
|
||||
|
||||
@@ -27,12 +27,18 @@ from common.djangoapps.xblock_django.models import XBlockStudioConfigurationFlag
|
||||
from cms.djangoapps.contentstore.toggles import use_new_problem_editor
|
||||
from openedx.core.lib.xblock_utils import get_aside_from_xblock, is_xblock_aside
|
||||
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration
|
||||
try:
|
||||
# Technically this is a django app plugin, so we should not error if it's not installed:
|
||||
import openedx.core.djangoapps.content_staging.api as content_staging_api
|
||||
except ImportError:
|
||||
content_staging_api = None
|
||||
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
from ..utils import get_lms_link_for_item, get_sibling_urls, reverse_course_url
|
||||
from ..utils import get_lms_link_for_item, get_sibling_urls, reverse_course_url, \
|
||||
load_services_for_studio
|
||||
from .helpers import get_parent_xblock, is_unit, xblock_type_display_name
|
||||
from .block import add_container_page_publishing_info, create_xblock_info, load_services_for_studio
|
||||
from .block import add_container_page_publishing_info, create_xblock_info
|
||||
|
||||
__all__ = [
|
||||
'container_handler',
|
||||
@@ -185,6 +191,12 @@ def container_handler(request, usage_key_string):
|
||||
break
|
||||
index += 1
|
||||
|
||||
# Get the status of the user's clipboard so they can paste components if they have something to paste
|
||||
if content_staging_api:
|
||||
user_clipboard = content_staging_api.get_user_clipboard_json(request.user.id, request)
|
||||
else:
|
||||
user_clipboard = {"content": None}
|
||||
|
||||
return render_to_response('container.html', {
|
||||
'language_code': request.LANGUAGE_CODE,
|
||||
'context_course': course, # Needed only for display of menus at top of page.
|
||||
@@ -205,7 +217,9 @@ def container_handler(request, usage_key_string):
|
||||
'xblock_info': xblock_info,
|
||||
'draft_preview_link': preview_lms_link,
|
||||
'published_preview_link': lms_link,
|
||||
'templates': CONTAINER_TEMPLATES
|
||||
'templates': CONTAINER_TEMPLATES,
|
||||
# Status of the user's clipboard, exactly as would be returned from the "GET clipboard" REST API.
|
||||
'user_clipboard': user_clipboard,
|
||||
})
|
||||
else:
|
||||
return HttpResponseBadRequest("Only supports HTML requests")
|
||||
@@ -292,8 +306,8 @@ def get_component_templates(courselike, library=False): # lint-amnesty, pylint:
|
||||
# by the components in the order listed in COMPONENT_TYPES.
|
||||
component_types = COMPONENT_TYPES[:]
|
||||
|
||||
# Libraries do not support discussions and openassessment and other libraries
|
||||
component_not_supported_by_library = ['discussion', 'library', 'openassessment']
|
||||
# Libraries do not support discussions, drag-and-drop, and openassessment and other libraries
|
||||
component_not_supported_by_library = ['discussion', 'library', 'openassessment', 'drag-and-drop-v2']
|
||||
if library:
|
||||
component_types = [component for component in component_types
|
||||
if component not in set(component_not_supported_by_library)]
|
||||
@@ -317,11 +331,14 @@ def get_component_templates(courselike, library=False): # lint-amnesty, pylint:
|
||||
# TODO: Once mixins are defined per-application, rather than per-runtime,
|
||||
# this should use a cms mixed-in class. (cpennington)
|
||||
template_id = None
|
||||
display_name = xblock_type_display_name(category, _('Blank')) # this is the Blank Advanced problem
|
||||
display_name = xblock_type_display_name(category, _('Blank'))
|
||||
# The ORA "blank" assessment should be Peer Assessment Only
|
||||
if category == 'openassessment':
|
||||
display_name = _("Peer Assessment Only")
|
||||
template_id = "peer-assessment"
|
||||
elif category == 'problem':
|
||||
# Override generic "Problem" name to describe this blank template:
|
||||
display_name = _("Blank Advanced Problem")
|
||||
templates_for_category.append(
|
||||
create_template_dict(display_name, category, support_level_without_template, template_id, 'advanced')
|
||||
)
|
||||
@@ -552,18 +569,18 @@ def component_handler(request, usage_key_string, handler, suffix=''):
|
||||
|
||||
try:
|
||||
if is_xblock_aside(usage_key):
|
||||
# Get the descriptor for the block being wrapped by the aside (not the aside itself)
|
||||
descriptor = modulestore().get_item(usage_key.usage_key)
|
||||
handler_descriptor = get_aside_from_xblock(descriptor, usage_key.aside_type)
|
||||
asides = [handler_descriptor]
|
||||
# Get the block being wrapped by the aside (not the aside itself)
|
||||
block = modulestore().get_item(usage_key.usage_key)
|
||||
handler_block = get_aside_from_xblock(block, usage_key.aside_type)
|
||||
asides = [handler_block]
|
||||
else:
|
||||
descriptor = modulestore().get_item(usage_key)
|
||||
handler_descriptor = descriptor
|
||||
block = modulestore().get_item(usage_key)
|
||||
handler_block = block
|
||||
asides = []
|
||||
load_services_for_studio(handler_descriptor.runtime, request.user)
|
||||
resp = handler_descriptor.handle(handler, req, suffix)
|
||||
load_services_for_studio(handler_block.runtime, request.user)
|
||||
resp = handler_block.handle(handler, req, suffix)
|
||||
except NoSuchHandlerError:
|
||||
log.info("XBlock %s attempted to access missing handler %r", handler_descriptor, handler, exc_info=True)
|
||||
log.info("XBlock %s attempted to access missing handler %r", handler_block, handler, exc_info=True)
|
||||
raise Http404 # lint-amnesty, pylint: disable=raise-missing-from
|
||||
|
||||
# unintentional update to handle any side effects of handle call
|
||||
@@ -572,7 +589,7 @@ def component_handler(request, usage_key_string, handler, suffix=''):
|
||||
# TNL 101-62 studio write permission is also checked for editing content.
|
||||
|
||||
if has_course_author_access(request.user, usage_key.course_key):
|
||||
modulestore().update_item(descriptor, request.user.id, asides=asides)
|
||||
modulestore().update_item(block, request.user.id, asides=asides)
|
||||
else:
|
||||
#fail quietly if user is not course author.
|
||||
log.warning(
|
||||
|
||||
@@ -17,7 +17,7 @@ from ccx_keys.locator import CCXLocator
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.exceptions import PermissionDenied, ValidationError as DjangoValidationError
|
||||
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
@@ -26,7 +26,6 @@ from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.views.decorators.http import require_GET, require_http_methods
|
||||
from edx_django_utils.monitoring import function_trace
|
||||
from edx_toggles.toggles import WaffleSwitch
|
||||
from milestones import api as milestones_api
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys.edx.locator import BlockUsageLocator
|
||||
@@ -41,40 +40,32 @@ from cms.djangoapps.models.settings.course_metadata import CourseMetadata
|
||||
from cms.djangoapps.models.settings.encoder import CourseSettingsEncoder
|
||||
from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError
|
||||
from common.djangoapps.course_action_state.models import CourseRerunState, CourseRerunUIStateManager
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
from common.djangoapps.edxmako.shortcuts import render_to_response
|
||||
from common.djangoapps.student.auth import has_course_author_access, has_studio_read_access, has_studio_write_access
|
||||
from common.djangoapps.student.auth import (
|
||||
has_course_author_access,
|
||||
has_studio_read_access,
|
||||
has_studio_write_access,
|
||||
has_studio_advanced_settings_access
|
||||
)
|
||||
from common.djangoapps.student.roles import (
|
||||
CourseInstructorRole,
|
||||
CourseStaffRole,
|
||||
GlobalStaff,
|
||||
UserBasedRole
|
||||
)
|
||||
from common.djangoapps.util.course import get_link_for_about_page
|
||||
from common.djangoapps.util.date_utils import get_default_time_display
|
||||
from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json
|
||||
from common.djangoapps.util.milestones_helpers import (
|
||||
is_prerequisite_courses_enabled,
|
||||
is_valid_course_key,
|
||||
remove_prerequisite_course,
|
||||
set_prerequisite_courses,
|
||||
get_namespace_choices,
|
||||
generate_milestone_namespace
|
||||
)
|
||||
from common.djangoapps.util.string_utils import _has_non_ascii_characters
|
||||
from common.djangoapps.xblock_django.api import deprecated_xblocks
|
||||
from openedx.core import toggles as core_toggles
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.djangoapps.credit.api import get_credit_requirements, is_credit_course
|
||||
from openedx.core.djangoapps.credit.api import is_credit_course
|
||||
from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements
|
||||
from openedx.core.djangoapps.models.course_details import CourseDetails
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangolib.js_utils import dump_js_escaped_json
|
||||
from openedx.core.lib.course_tabs import CourseTabPluginManager
|
||||
from openedx.core.lib.courses import course_image_url
|
||||
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
|
||||
from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_SCHEME
|
||||
from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML
|
||||
from organizations.models import Organization
|
||||
from xmodule.contentstore.content import StaticContent # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.course_block import CourseBlock, DEFAULT_START_DATE, CourseFields # lint-amnesty, pylint: disable=wrong-import-order
|
||||
@@ -98,6 +89,7 @@ from ..tasks import rerun_course as rerun_course_task
|
||||
from ..toggles import split_library_view_on_dashboard
|
||||
from ..utils import (
|
||||
add_instructor,
|
||||
get_course_settings,
|
||||
get_lms_link_for_item,
|
||||
get_proctored_exam_settings_url,
|
||||
get_subsections_by_assignment_type,
|
||||
@@ -106,11 +98,12 @@ from ..utils import (
|
||||
reverse_course_url,
|
||||
reverse_library_url,
|
||||
reverse_url,
|
||||
reverse_usage_url
|
||||
reverse_usage_url,
|
||||
update_course_discussions_settings,
|
||||
update_course_details,
|
||||
)
|
||||
from .component import ADVANCED_COMPONENT_TYPES
|
||||
from .helpers import is_content_creator
|
||||
from .entrance_exam import create_entrance_exam, delete_entrance_exam, update_entrance_exam
|
||||
from .block import create_xblock_info
|
||||
from .library import (
|
||||
LIBRARIES_ENABLED,
|
||||
@@ -148,17 +141,6 @@ class AccessListFallback(Exception):
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
|
||||
|
||||
def has_advanced_settings_access(user):
|
||||
"""
|
||||
If DISABLE_ADVANCED_SETTINGS feature is enabled, only global staff can access "Advanced Settings".
|
||||
"""
|
||||
return (
|
||||
not settings.FEATURES.get('DISABLE_ADVANCED_SETTINGS', False)
|
||||
or user.is_staff
|
||||
or user.is_superuser
|
||||
)
|
||||
|
||||
|
||||
def get_course_and_check_access(course_key, user, depth=0):
|
||||
"""
|
||||
Function used to calculate and return the locator and course block
|
||||
@@ -764,7 +746,6 @@ def course_index(request, course_key):
|
||||
'frontend_app_publisher_url': frontend_app_publisher_url,
|
||||
'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course_block.id),
|
||||
'advance_settings_url': reverse_course_url('advanced_settings_handler', course_block.id),
|
||||
'advance_settings_access': has_advanced_settings_access(request.user),
|
||||
'proctoring_errors': proctoring_errors,
|
||||
})
|
||||
|
||||
@@ -992,6 +973,7 @@ def create_new_course(user, org, number, run, fields):
|
||||
store_for_new_course = modulestore().default_modulestore.get_modulestore_type()
|
||||
new_course = create_new_course_in_store(store_for_new_course, user, org, number, run, fields)
|
||||
add_organization_course(org_data, new_course.id)
|
||||
update_course_discussions_settings(new_course.id)
|
||||
return new_course
|
||||
|
||||
|
||||
@@ -1163,96 +1145,11 @@ def settings_handler(request, course_key_string): # lint-amnesty, pylint: disab
|
||||
json: update the Course and About xblocks through the CourseDetails model
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_key_string)
|
||||
credit_eligibility_enabled = settings.FEATURES.get('ENABLE_CREDIT_ELIGIBILITY', False)
|
||||
|
||||
with modulestore().bulk_operations(course_key):
|
||||
course_block = get_course_and_check_access(course_key, request.user)
|
||||
if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET':
|
||||
upload_asset_url = reverse_course_url('assets_handler', course_key)
|
||||
|
||||
# see if the ORG of this course can be attributed to a defined configuration . In that case, the
|
||||
# course about page should be editable in Studio
|
||||
publisher_enabled = configuration_helpers.get_value_for_org(
|
||||
course_block.location.org,
|
||||
'ENABLE_PUBLISHER',
|
||||
settings.FEATURES.get('ENABLE_PUBLISHER', False)
|
||||
)
|
||||
marketing_enabled = configuration_helpers.get_value_for_org(
|
||||
course_block.location.org,
|
||||
'ENABLE_MKTG_SITE',
|
||||
settings.FEATURES.get('ENABLE_MKTG_SITE', False)
|
||||
)
|
||||
enable_extended_course_details = configuration_helpers.get_value_for_org(
|
||||
course_block.location.org,
|
||||
'ENABLE_EXTENDED_COURSE_DETAILS',
|
||||
settings.FEATURES.get('ENABLE_EXTENDED_COURSE_DETAILS', False)
|
||||
)
|
||||
|
||||
about_page_editable = not publisher_enabled
|
||||
enrollment_end_editable = GlobalStaff().has_user(request.user) or not publisher_enabled
|
||||
short_description_editable = configuration_helpers.get_value_for_org(
|
||||
course_block.location.org,
|
||||
'EDITABLE_SHORT_DESCRIPTION',
|
||||
settings.FEATURES.get('EDITABLE_SHORT_DESCRIPTION', True)
|
||||
)
|
||||
sidebar_html_enabled = ENABLE_COURSE_ABOUT_SIDEBAR_HTML.is_enabled()
|
||||
|
||||
verified_mode = CourseMode.verified_mode_for_course(course_key, include_expired=True)
|
||||
upgrade_deadline = (verified_mode and verified_mode.expiration_datetime and
|
||||
verified_mode.expiration_datetime.isoformat())
|
||||
settings_context = {
|
||||
'context_course': course_block,
|
||||
'course_locator': course_key,
|
||||
'lms_link_for_about_page': get_link_for_about_page(course_block),
|
||||
'course_image_url': course_image_url(course_block, 'course_image'),
|
||||
'banner_image_url': course_image_url(course_block, 'banner_image'),
|
||||
'video_thumbnail_image_url': course_image_url(course_block, 'video_thumbnail_image'),
|
||||
'details_url': reverse_course_url('settings_handler', course_key),
|
||||
'about_page_editable': about_page_editable,
|
||||
'marketing_enabled': marketing_enabled,
|
||||
'short_description_editable': short_description_editable,
|
||||
'sidebar_html_enabled': sidebar_html_enabled,
|
||||
'upload_asset_url': upload_asset_url,
|
||||
'course_handler_url': reverse_course_url('course_handler', course_key),
|
||||
'language_options': settings.ALL_LANGUAGES,
|
||||
'credit_eligibility_enabled': credit_eligibility_enabled,
|
||||
'is_credit_course': False,
|
||||
'show_min_grade_warning': False,
|
||||
'enrollment_end_editable': enrollment_end_editable,
|
||||
'is_prerequisite_courses_enabled': is_prerequisite_courses_enabled(),
|
||||
'is_entrance_exams_enabled': core_toggles.ENTRANCE_EXAMS.is_enabled(),
|
||||
'enable_extended_course_details': enable_extended_course_details,
|
||||
'upgrade_deadline': upgrade_deadline,
|
||||
'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course_block.id),
|
||||
}
|
||||
if is_prerequisite_courses_enabled():
|
||||
courses, in_process_course_actions = get_courses_accessible_to_user(request)
|
||||
# exclude current course from the list of available courses
|
||||
courses = [course for course in courses if course.id != course_key]
|
||||
if courses:
|
||||
courses, __ = _process_courses_list(courses, in_process_course_actions)
|
||||
settings_context.update({'possible_pre_requisite_courses': courses})
|
||||
|
||||
if credit_eligibility_enabled:
|
||||
if is_credit_course(course_key):
|
||||
# get and all credit eligibility requirements
|
||||
credit_requirements = get_credit_requirements(course_key)
|
||||
# pair together requirements with same 'namespace' values
|
||||
paired_requirements = {}
|
||||
for requirement in credit_requirements:
|
||||
namespace = requirement.pop("namespace")
|
||||
paired_requirements.setdefault(namespace, []).append(requirement)
|
||||
|
||||
# if 'minimum_grade_credit' of a course is not set or 0 then
|
||||
# show warning message to course author.
|
||||
show_min_grade_warning = False if course_block.minimum_grade_credit > 0 else True # lint-amnesty, pylint: disable=simplifiable-if-expression
|
||||
settings_context.update(
|
||||
{
|
||||
'is_credit_course': True,
|
||||
'credit_requirements': paired_requirements,
|
||||
'show_min_grade_warning': show_min_grade_warning,
|
||||
}
|
||||
)
|
||||
|
||||
settings_context = get_course_settings(request, course_key, course_block)
|
||||
return render_to_response('settings.html', settings_context)
|
||||
elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): # pylint: disable=too-many-nested-blocks
|
||||
if request.method == 'GET':
|
||||
@@ -1264,63 +1161,12 @@ def settings_handler(request, course_key_string): # lint-amnesty, pylint: disab
|
||||
)
|
||||
# For every other possible method type submitted by the caller...
|
||||
else:
|
||||
# if pre-requisite course feature is enabled set pre-requisite course
|
||||
if is_prerequisite_courses_enabled():
|
||||
prerequisite_course_keys = request.json.get('pre_requisite_courses', [])
|
||||
if prerequisite_course_keys:
|
||||
if not all(is_valid_course_key(course_key) for course_key in prerequisite_course_keys):
|
||||
return JsonResponseBadRequest({"error": _("Invalid prerequisite course key")})
|
||||
set_prerequisite_courses(course_key, prerequisite_course_keys)
|
||||
else:
|
||||
# None is chosen, so remove the course prerequisites
|
||||
course_milestones = milestones_api.get_course_milestones(
|
||||
course_key=course_key,
|
||||
relationship="requires",
|
||||
)
|
||||
for milestone in course_milestones:
|
||||
entrance_exam_namespace = generate_milestone_namespace(
|
||||
get_namespace_choices().get('ENTRANCE_EXAM'),
|
||||
course_key
|
||||
)
|
||||
if milestone["namespace"] != entrance_exam_namespace:
|
||||
remove_prerequisite_course(course_key, milestone)
|
||||
try:
|
||||
update_data = update_course_details(request, course_key, request.json, course_block)
|
||||
except DjangoValidationError as err:
|
||||
return JsonResponseBadRequest({"error": err.message})
|
||||
|
||||
# If the entrance exams feature has been enabled, we'll need to check for some
|
||||
# feature-specific settings and handle them accordingly
|
||||
# We have to be careful that we're only executing the following logic if we actually
|
||||
# need to create or delete an entrance exam from the specified course
|
||||
if core_toggles.ENTRANCE_EXAMS.is_enabled():
|
||||
course_entrance_exam_present = course_block.entrance_exam_enabled
|
||||
entrance_exam_enabled = request.json.get('entrance_exam_enabled', '') == 'true'
|
||||
ee_min_score_pct = request.json.get('entrance_exam_minimum_score_pct', None)
|
||||
# If the entrance exam box on the settings screen has been checked...
|
||||
if entrance_exam_enabled:
|
||||
# Load the default minimum score threshold from settings, then try to override it
|
||||
entrance_exam_minimum_score_pct = float(settings.ENTRANCE_EXAM_MIN_SCORE_PCT)
|
||||
if ee_min_score_pct:
|
||||
entrance_exam_minimum_score_pct = float(ee_min_score_pct)
|
||||
if entrance_exam_minimum_score_pct.is_integer():
|
||||
entrance_exam_minimum_score_pct = entrance_exam_minimum_score_pct / 100
|
||||
# If there's already an entrance exam defined, we'll update the existing one
|
||||
if course_entrance_exam_present:
|
||||
exam_data = {
|
||||
'entrance_exam_minimum_score_pct': entrance_exam_minimum_score_pct
|
||||
}
|
||||
update_entrance_exam(request, course_key, exam_data)
|
||||
# If there's no entrance exam defined, we'll create a new one
|
||||
else:
|
||||
create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct)
|
||||
|
||||
# If the entrance exam box on the settings screen has been unchecked,
|
||||
# and the course has an entrance exam attached...
|
||||
elif not entrance_exam_enabled and course_entrance_exam_present:
|
||||
delete_entrance_exam(request, course_key)
|
||||
|
||||
# Perform the normal update workflow for the CourseDetails model
|
||||
return JsonResponse(
|
||||
CourseDetails.update_from_json(course_key, request.json, request.user),
|
||||
encoder=CourseSettingsEncoder
|
||||
)
|
||||
return JsonResponse(update_data, encoder=CourseSettingsEncoder)
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -1435,7 +1281,7 @@ def advanced_settings_handler(request, course_key_string):
|
||||
json: update the Course's settings. The payload is a json rep of the
|
||||
metadata dicts.
|
||||
"""
|
||||
if not has_advanced_settings_access(request.user):
|
||||
if not has_studio_advanced_settings_access(request.user):
|
||||
raise PermissionDenied()
|
||||
|
||||
course_key = CourseKey.from_string(course_key_string)
|
||||
|
||||
@@ -176,9 +176,9 @@ def _get_entrance_exam(request, course_key):
|
||||
except InvalidKeyError:
|
||||
return HttpResponse(status=404)
|
||||
try:
|
||||
exam_descriptor = modulestore().get_item(exam_key)
|
||||
exam_block = modulestore().get_item(exam_key)
|
||||
return HttpResponse( # lint-amnesty, pylint: disable=http-response-with-content-type-json
|
||||
dump_js_escaped_json({'locator': str(exam_descriptor.location)}),
|
||||
dump_js_escaped_json({'locator': str(exam_block.location)}),
|
||||
status=200, content_type='application/json')
|
||||
except ItemNotFoundError:
|
||||
return HttpResponse(status=404)
|
||||
|
||||
@@ -3,20 +3,31 @@ Helper methods for Studio views.
|
||||
"""
|
||||
|
||||
import urllib
|
||||
from lxml import etree
|
||||
from uuid import uuid4
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.utils.translation import gettext as _
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from opaque_keys.edx.locator import DefinitionLocator, LocalId
|
||||
from xblock.core import XBlock
|
||||
from xblock.fields import ScopeIds
|
||||
from xblock.runtime import IdGenerator
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.tabs import StaticTab
|
||||
|
||||
from cms.djangoapps.contentstore.views.preview import _load_preview_block
|
||||
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
|
||||
from common.djangoapps.student import auth
|
||||
from common.djangoapps.student.roles import CourseCreatorRole, OrgContentCreatorRole
|
||||
from openedx.core.toggles import ENTRANCE_EXAMS
|
||||
|
||||
try:
|
||||
# Technically this is a django app plugin, so we should not error if it's not installed:
|
||||
import openedx.core.djangoapps.content_staging.api as content_staging_api
|
||||
except ImportError:
|
||||
content_staging_api = None
|
||||
|
||||
from ..utils import reverse_course_url, reverse_library_url, reverse_usage_url
|
||||
|
||||
__all__ = ['event']
|
||||
@@ -90,12 +101,27 @@ def xblock_has_own_studio_page(xblock, parent_xblock=None):
|
||||
return xblock.has_children
|
||||
|
||||
|
||||
def xblock_studio_url(xblock, parent_xblock=None):
|
||||
def xblock_studio_url(xblock, parent_xblock=None, find_parent=False):
|
||||
"""
|
||||
Returns the Studio editing URL for the specified xblock.
|
||||
|
||||
You can pass the parent xblock as an optimization, to avoid needing to load
|
||||
it twice, as sometimes the parent has to be checked.
|
||||
|
||||
If you pass in a leaf block that doesn't have its own Studio page, this will
|
||||
normally return None, but if you use find_parent=True, this will find the
|
||||
nearest ancestor (usually the parent unit) that does have a Studio page and
|
||||
return that URL.
|
||||
"""
|
||||
if not xblock_has_own_studio_page(xblock, parent_xblock):
|
||||
return None
|
||||
if find_parent:
|
||||
while xblock and not xblock_has_own_studio_page(xblock, parent_xblock):
|
||||
xblock = parent_xblock or get_parent_xblock(xblock)
|
||||
parent_xblock = None
|
||||
if not xblock:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
category = xblock.category
|
||||
if category == 'course':
|
||||
return reverse_course_url('course_handler', xblock.location.course_key)
|
||||
@@ -116,7 +142,7 @@ def xblock_type_display_name(xblock, default_display_name=None):
|
||||
Returns the display name for the specified type of xblock. Note that an instance can be passed in
|
||||
for context dependent names, e.g. a vertical beneath a sequential is a Unit.
|
||||
|
||||
:param xblock: An xblock instance or the type of xblock.
|
||||
:param xblock: An xblock instance or the type of xblock (as a string).
|
||||
:param default_display_name: The default value to return if no display name can be found.
|
||||
:return:
|
||||
"""
|
||||
@@ -133,6 +159,13 @@ def xblock_type_display_name(xblock, default_display_name=None):
|
||||
return _('Subsection')
|
||||
elif category == 'vertical':
|
||||
return _('Unit')
|
||||
elif category == 'problem':
|
||||
# The problem XBlock's display_name.default is not helpful ("Blank Advanced Problem") but changing it could have
|
||||
# too many ripple effects in other places, so we have a special case for capa problems here.
|
||||
# Note: With a ProblemBlock instance, we could actually check block.problem_types to give a more specific
|
||||
# description like "Multiple Choice Problem", but that won't work if our 'block' argument is just the block_type
|
||||
# string ("problem").
|
||||
return _('Problem')
|
||||
component_class = XBlock.load_class(category)
|
||||
if hasattr(component_class, 'display_name') and component_class.display_name.default:
|
||||
return _(component_class.display_name.default) # lint-amnesty, pylint: disable=translation-of-non-string
|
||||
@@ -271,6 +304,76 @@ def create_xblock(parent_locator, user, category, display_name, boilerplate=None
|
||||
return created_block
|
||||
|
||||
|
||||
class ImportIdGenerator(IdGenerator):
|
||||
"""
|
||||
Modulestore's IdGenerator doesn't work for importing single blocks as OLX,
|
||||
so we implement our own
|
||||
"""
|
||||
def __init__(self, context_key):
|
||||
super().__init__()
|
||||
self.context_key = context_key
|
||||
|
||||
def create_aside(self, definition_id, usage_id, aside_type):
|
||||
""" Generate a new aside key """
|
||||
raise NotImplementedError()
|
||||
|
||||
def create_usage(self, def_id) -> UsageKey:
|
||||
""" Generate a new UsageKey for an XBlock """
|
||||
# Note: Split modulestore will detect this temporary ID and create a new block ID when the XBlock is saved.
|
||||
return self.context_key.make_usage_key(def_id.block_type, LocalId())
|
||||
|
||||
def create_definition(self, block_type, slug=None) -> DefinitionLocator:
|
||||
""" Generate a new definition_id for an XBlock """
|
||||
# Note: Split modulestore will detect this temporary ID and create a new definition ID when the XBlock is saved.
|
||||
return DefinitionLocator(block_type, LocalId(block_type))
|
||||
|
||||
|
||||
def import_staged_content_from_user_clipboard(parent_key: UsageKey, request):
|
||||
"""
|
||||
Import a block (and any children it has) from "staged" OLX.
|
||||
Does not deal with permissions or REST stuff - do that before calling this.
|
||||
|
||||
Returns the newly created block on success or None if the clipboard is
|
||||
empty.
|
||||
"""
|
||||
if not content_staging_api:
|
||||
raise RuntimeError("The required content_staging app is not installed")
|
||||
user_clipboard = content_staging_api.get_user_clipboard(request.user.id)
|
||||
if not user_clipboard:
|
||||
# Clipboard is empty or expired/error/loading
|
||||
return None
|
||||
block_type = user_clipboard.content.block_type
|
||||
olx_str = content_staging_api.get_staged_content_olx(user_clipboard.content.id)
|
||||
node = etree.fromstring(olx_str)
|
||||
store = modulestore()
|
||||
with store.bulk_operations(parent_key.course_key):
|
||||
parent_descriptor = store.get_item(parent_key)
|
||||
# Some blocks like drag-and-drop only work here with the full XBlock runtime loaded:
|
||||
parent_xblock = _load_preview_block(request, parent_descriptor)
|
||||
runtime = parent_xblock.runtime
|
||||
# Generate the new ID:
|
||||
id_generator = ImportIdGenerator(parent_key.context_key)
|
||||
def_id = id_generator.create_definition(block_type, user_clipboard.source_usage_key.block_id)
|
||||
usage_id = id_generator.create_usage(def_id)
|
||||
keys = ScopeIds(None, block_type, def_id, usage_id)
|
||||
# parse_xml is a really messy API. We pass both 'keys' and 'id_generator' and, depending on the XBlock, either
|
||||
# one may be used to determine the new XBlock's usage key, and the other will be ignored. e.g. video ignores
|
||||
# 'keys' and uses 'id_generator', but the default XBlock parse_xml ignores 'id_generator' and uses 'keys'.
|
||||
# For children of this block, obviously only id_generator is used.
|
||||
xblock_class = runtime.load_block_type(block_type)
|
||||
temp_xblock = xblock_class.parse_xml(node, runtime, keys, id_generator)
|
||||
if xblock_class.has_children and temp_xblock.children:
|
||||
raise NotImplementedError("We don't yet support pasting XBlocks with children")
|
||||
temp_xblock.parent = parent_key
|
||||
# Store a reference to where this block was copied from, in the 'copied_from_block' field (AuthoringMixin)
|
||||
temp_xblock.copied_from_block = str(user_clipboard.source_usage_key)
|
||||
# Save the XBlock into modulestore. We need to save the block and its parent for this to work:
|
||||
new_xblock = store.update_item(temp_xblock, request.user.id, allow_not_found=True)
|
||||
parent_xblock.children.append(new_xblock.location)
|
||||
store.update_item(parent_xblock, request.user.id)
|
||||
return new_xblock
|
||||
|
||||
|
||||
def is_item_in_course_tree(item):
|
||||
"""
|
||||
Check that the item is in the course tree.
|
||||
|
||||
@@ -11,6 +11,7 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.decorators.clickjacking import xframe_options_exempt
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from rest_framework.request import Request
|
||||
from web_fragments.fragment import Fragment
|
||||
from xblock.django.request import django_to_webob_request, webob_to_django_response
|
||||
from xblock.exceptions import NoSuchHandlerError
|
||||
@@ -24,7 +25,7 @@ from xmodule.services import SettingsService, TeamsConfigurationService
|
||||
from xmodule.studio_editable import has_author_view
|
||||
from xmodule.util.sandboxing import SandboxService
|
||||
from xmodule.util.xmodule_django import add_webpack_to_fragment
|
||||
from xmodule.x_module import AUTHOR_VIEW, PREVIEW_VIEWS, STUDENT_VIEW
|
||||
from xmodule.x_module import AUTHOR_VIEW, PREVIEW_VIEWS, STUDENT_VIEW, XModuleMixin
|
||||
from cms.djangoapps.xblock_config.models import StudioConfig
|
||||
from cms.djangoapps.contentstore.toggles import individualize_anonymous_user_id, ENABLE_COPY_PASTE_FEATURE
|
||||
from cms.lib.xblock.field_data import CmsFieldData
|
||||
@@ -65,8 +66,8 @@ def preview_handler(request, usage_key_string, handler, suffix=''):
|
||||
"""
|
||||
usage_key = UsageKey.from_string(usage_key_string)
|
||||
|
||||
descriptor = modulestore().get_item(usage_key)
|
||||
instance = _load_preview_block(request, descriptor)
|
||||
block = modulestore().get_item(usage_key)
|
||||
instance = _load_preview_block(request, block)
|
||||
|
||||
# Let the module handle the AJAX
|
||||
req = django_to_webob_request(request)
|
||||
@@ -154,6 +155,7 @@ def _prepare_runtime_for_preview(request, block, field_data):
|
||||
required for rendering block previews.
|
||||
|
||||
request: The active django request
|
||||
block: An XBlock
|
||||
field_data: Wrapped field data for previews
|
||||
"""
|
||||
|
||||
@@ -192,16 +194,11 @@ def _prepare_runtime_for_preview(request, block, field_data):
|
||||
# stick the license wrapper in front
|
||||
wrappers.insert(0, partial(wrap_with_license, mako_service=mako_service))
|
||||
|
||||
preview_anonymous_user_id = 'student'
|
||||
anonymous_user_id = deprecated_anonymous_user_id = 'student'
|
||||
if individualize_anonymous_user_id(course_id):
|
||||
# There are blocks (capa, html, and video) where we do not want to scope
|
||||
# the anonymous_user_id to specific courses. These are captured in the
|
||||
# block attribute 'requires_per_student_anonymous_id'. Please note,
|
||||
# the course_id field in AnynomousUserID model is blank if value is None.
|
||||
if getattr(block, 'requires_per_student_anonymous_id', False):
|
||||
preview_anonymous_user_id = anonymous_id_for_user(request.user, None)
|
||||
else:
|
||||
preview_anonymous_user_id = anonymous_id_for_user(request.user, course_id)
|
||||
anonymous_user_id = anonymous_id_for_user(request.user, course_id)
|
||||
# See the docstring of `DjangoXBlockUserService`.
|
||||
deprecated_anonymous_user_id = anonymous_id_for_user(request.user, None)
|
||||
|
||||
services = {
|
||||
"field-data": field_data,
|
||||
@@ -211,7 +208,8 @@ def _prepare_runtime_for_preview(request, block, field_data):
|
||||
"user": DjangoXBlockUserService(
|
||||
request.user,
|
||||
user_role=get_user_role(request.user, course_id),
|
||||
anonymous_user_id=preview_anonymous_user_id,
|
||||
anonymous_user_id=anonymous_user_id,
|
||||
deprecated_anonymous_user_id=deprecated_anonymous_user_id,
|
||||
),
|
||||
"partitions": StudioPartitionService(course_id=course_id),
|
||||
"teams_configuration": TeamsConfigurationService(),
|
||||
@@ -256,29 +254,29 @@ class StudioPartitionService(PartitionService):
|
||||
return None
|
||||
|
||||
|
||||
def _load_preview_block(request, descriptor):
|
||||
def _load_preview_block(request: Request, block: XModuleMixin):
|
||||
"""
|
||||
Return a preview XBlock instantiated from the supplied descriptor. Will use mutable fields
|
||||
Return a preview XBlock instantiated from the supplied block. Will use mutable fields
|
||||
if XBlock supports an author_view. Otherwise, will use immutable fields and student_view.
|
||||
|
||||
request: The active django request
|
||||
descriptor: An XModuleDescriptor
|
||||
block: An XModuleMixin
|
||||
"""
|
||||
student_data = KvsFieldData(SessionKeyValueStore(request))
|
||||
if has_author_view(descriptor):
|
||||
if has_author_view(block):
|
||||
wrapper = partial(CmsFieldData, student_data=student_data)
|
||||
else:
|
||||
wrapper = partial(LmsFieldData, student_data=student_data)
|
||||
|
||||
# wrap the _field_data upfront to pass to _prepare_runtime_for_preview
|
||||
wrapped_field_data = wrapper(descriptor._field_data) # pylint: disable=protected-access
|
||||
_prepare_runtime_for_preview(request, descriptor, wrapped_field_data)
|
||||
wrapped_field_data = wrapper(block._field_data) # pylint: disable=protected-access
|
||||
_prepare_runtime_for_preview(request, block, wrapped_field_data)
|
||||
|
||||
descriptor.bind_for_student(
|
||||
block.bind_for_student(
|
||||
request.user.id,
|
||||
[wrapper]
|
||||
)
|
||||
return descriptor
|
||||
return block
|
||||
|
||||
|
||||
def _is_xblock_reorderable(xblock, context):
|
||||
@@ -307,8 +305,10 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
|
||||
selected_groups_label = _('Access restricted to: {list_of_groups}').format(list_of_groups=selected_groups_label) # lint-amnesty, pylint: disable=line-too-long
|
||||
course = modulestore().get_course(xblock.location.course_key)
|
||||
can_edit = context.get('can_edit', True)
|
||||
# Is this a course or a library?
|
||||
is_course = xblock.scope_ids.usage_id.context_key.is_course
|
||||
# Copy-paste is a new feature; while we are beta-testing it, only beta users with the Waffle flag enabled see it
|
||||
enable_copy_paste = can_edit and ENABLE_COPY_PASTE_FEATURE.is_enabled()
|
||||
enable_copy_paste = can_edit and is_course and ENABLE_COPY_PASTE_FEATURE.is_enabled()
|
||||
template_context = {
|
||||
'xblock_context': context,
|
||||
'xblock': xblock,
|
||||
@@ -318,10 +318,10 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
|
||||
'is_reorderable': is_reorderable,
|
||||
'can_edit': can_edit,
|
||||
'enable_copy_paste': enable_copy_paste,
|
||||
'can_edit_visibility': context.get('can_edit_visibility', xblock.scope_ids.usage_id.context_key.is_course),
|
||||
'can_edit_visibility': context.get('can_edit_visibility', is_course),
|
||||
'selected_groups_label': selected_groups_label,
|
||||
'can_add': context.get('can_add', True),
|
||||
'can_move': context.get('can_move', xblock.scope_ids.usage_id.context_key.is_course),
|
||||
'can_move': context.get('can_move', is_course),
|
||||
'language': getattr(course, 'language', None)
|
||||
}
|
||||
|
||||
@@ -332,12 +332,12 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
|
||||
return frag
|
||||
|
||||
|
||||
def get_preview_fragment(request, descriptor, context):
|
||||
def get_preview_fragment(request, block, context):
|
||||
"""
|
||||
Returns the HTML returned by the XModule's student_view or author_view (if available),
|
||||
specified by the descriptor and idx.
|
||||
specified by the block and idx.
|
||||
"""
|
||||
block = _load_preview_block(request, descriptor)
|
||||
block = _load_preview_block(request, block)
|
||||
|
||||
preview_view = AUTHOR_VIEW if has_author_view(block) else STUDENT_VIEW
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from django.http import Http404
|
||||
from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
from django.urls import reverse
|
||||
from openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE
|
||||
from openedx_events.content_authoring.data import DuplicatedXBlockData
|
||||
from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED
|
||||
from openedx_events.tests.utils import OpenEdxEventsTestMixin
|
||||
@@ -47,7 +48,7 @@ from xmodule.partitions.tests.test_partitions import MockPartitionService
|
||||
from xmodule.x_module import STUDENT_VIEW, STUDIO_VIEW
|
||||
|
||||
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
|
||||
from cms.djangoapps.contentstore.utils import reverse_course_url, reverse_usage_url
|
||||
from cms.djangoapps.contentstore.utils import reverse_course_url, reverse_usage_url, duplicate_block, update_from_source
|
||||
from cms.djangoapps.contentstore.views import block as item_module
|
||||
from common.djangoapps.student.tests.factories import StaffFactory, UserFactory
|
||||
from common.djangoapps.xblock_django.models import (
|
||||
@@ -786,6 +787,30 @@ class TestDuplicateItem(ItemTest, DuplicateHelper, OpenEdxEventsTestMixin):
|
||||
# Now send a custom display name for the duplicate.
|
||||
verify_name(self.seq_usage_key, self.chapter_usage_key, "customized name", display_name="customized name")
|
||||
|
||||
def test_shallow_duplicate(self):
|
||||
"""
|
||||
Test that duplicate_block(..., shallow=True) can duplicate a block but ignores its children.
|
||||
"""
|
||||
source_course = CourseFactory()
|
||||
user = UserFactory.create()
|
||||
source_chapter = BlockFactory(parent=source_course, category='chapter', display_name='Source Chapter')
|
||||
BlockFactory(parent=source_chapter, category='html', display_name='Child')
|
||||
# Refresh.
|
||||
source_chapter = self.store.get_item(source_chapter.location)
|
||||
self.assertEqual(len(source_chapter.get_children()), 1)
|
||||
destination_course = CourseFactory()
|
||||
destination_location = duplicate_block(
|
||||
parent_usage_key=destination_course.location,
|
||||
duplicate_source_usage_key=source_chapter.location,
|
||||
user=user,
|
||||
display_name=source_chapter.display_name,
|
||||
shallow=True,
|
||||
)
|
||||
# Refresh here, too, just to be sure.
|
||||
destination_chapter = self.store.get_item(destination_location)
|
||||
self.assertEqual(len(destination_chapter.get_children()), 0)
|
||||
self.assertEqual(destination_chapter.display_name, 'Source Chapter')
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestMoveItem(ItemTest):
|
||||
@@ -2159,10 +2184,10 @@ class TestComponentHandler(TestCase):
|
||||
self.modulestore = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
# component_handler calls modulestore.get_item to get the descriptor of the requested xBlock.
|
||||
# component_handler calls modulestore.get_item to get the requested xBlock.
|
||||
# Here, we mock the return value of modulestore.get_item so it can be used to mock the handler
|
||||
# of the xBlock descriptor.
|
||||
self.descriptor = self.modulestore.return_value.get_item.return_value
|
||||
# of the xBlock.
|
||||
self.block = self.modulestore.return_value.get_item.return_value
|
||||
|
||||
self.usage_key = BlockUsageLocator(
|
||||
CourseLocator('dummy_org', 'dummy_course', 'dummy_run'), 'dummy_category', 'dummy_name'
|
||||
@@ -2173,7 +2198,7 @@ class TestComponentHandler(TestCase):
|
||||
self.request.user = self.user
|
||||
|
||||
def test_invalid_handler(self):
|
||||
self.descriptor.handle.side_effect = NoSuchHandlerError
|
||||
self.block.handle.side_effect = NoSuchHandlerError
|
||||
|
||||
with self.assertRaises(Http404):
|
||||
component_handler(self.request, self.usage_key_string, 'invalid_handler')
|
||||
@@ -2185,7 +2210,7 @@ class TestComponentHandler(TestCase):
|
||||
self.assertEqual(request.method, method)
|
||||
return Response()
|
||||
|
||||
self.descriptor.handle = check_handler
|
||||
self.block.handle = check_handler
|
||||
|
||||
# Have to use the right method to create the request to get the HTTP method that we want
|
||||
req_factory_method = getattr(self.request_factory, method.lower())
|
||||
@@ -2198,7 +2223,7 @@ class TestComponentHandler(TestCase):
|
||||
def create_response(handler, request, suffix): # lint-amnesty, pylint: disable=unused-argument
|
||||
return Response(status_code=status_code)
|
||||
|
||||
self.descriptor.handle = create_response
|
||||
self.block.handle = create_response
|
||||
|
||||
self.assertEqual(component_handler(self.request, self.usage_key_string, 'dummy_handler').status_code,
|
||||
status_code)
|
||||
@@ -2219,7 +2244,7 @@ class TestComponentHandler(TestCase):
|
||||
self.request.user = UserFactory()
|
||||
mock_handler = 'dummy_handler'
|
||||
|
||||
self.descriptor.handle = create_response
|
||||
self.block.handle = create_response
|
||||
|
||||
with patch(
|
||||
'cms.djangoapps.contentstore.views.component.is_xblock_aside',
|
||||
@@ -2253,7 +2278,7 @@ class TestComponentHandler(TestCase):
|
||||
else self.usage_key_string
|
||||
)
|
||||
|
||||
self.descriptor.handle = create_response
|
||||
self.block.handle = create_response
|
||||
|
||||
with patch(
|
||||
'cms.djangoapps.contentstore.views.component.is_xblock_aside',
|
||||
@@ -2770,6 +2795,28 @@ class TestXBlockInfo(ItemTest):
|
||||
course_xblock_info = create_xblock_info(self.course)
|
||||
self.assertTrue(course_xblock_info['highlights_enabled_for_messaging'])
|
||||
|
||||
def test_xblock_public_video_sharing_enabled(self):
|
||||
"""
|
||||
Public video sharing is included in the xblock info when enable.
|
||||
"""
|
||||
self.course.video_sharing_options = 'all-on'
|
||||
with patch.object(PUBLIC_VIDEO_SHARE, 'is_enabled', return_value=True):
|
||||
self.store.update_item(self.course, None)
|
||||
course_xblock_info = create_xblock_info(self.course)
|
||||
self.assertTrue(course_xblock_info['video_sharing_enabled'])
|
||||
self.assertEqual(course_xblock_info['video_sharing_options'], 'all-on')
|
||||
|
||||
def test_xblock_public_video_sharing_disabled(self):
|
||||
"""
|
||||
Public video sharing not is included in the xblock info when disabled.
|
||||
"""
|
||||
self.course.video_sharing_options = 'arbitrary'
|
||||
with patch.object(PUBLIC_VIDEO_SHARE, 'is_enabled', return_value=False):
|
||||
self.store.update_item(self.course, None)
|
||||
course_xblock_info = create_xblock_info(self.course)
|
||||
self.assertNotIn('video_sharing_enabled', course_xblock_info)
|
||||
self.assertNotIn('video_sharing_options', course_xblock_info)
|
||||
|
||||
def validate_course_xblock_info(self, xblock_info, has_child_info=True, course_outline=False):
|
||||
"""
|
||||
Validate that the xblock info is correct for the test course.
|
||||
@@ -3472,3 +3519,111 @@ class TestXBlockPublishingInfo(ItemTest):
|
||||
# Check that in self paced course content has live state now
|
||||
xblock_info = self._get_xblock_info(chapter.location)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.live)
|
||||
|
||||
|
||||
@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types',
|
||||
lambda self, block: ['test_aside'])
|
||||
class TestUpdateFromSource(ModuleStoreTestCase):
|
||||
"""
|
||||
Test update_from_source.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Set up the runtime for tests.
|
||||
"""
|
||||
super().setUp()
|
||||
key_store = DictKeyValueStore()
|
||||
field_data = KvsFieldData(key_store)
|
||||
self.runtime = TestRuntime(services={'field-data': field_data})
|
||||
|
||||
def create_source_block(self, course):
|
||||
"""
|
||||
Create a chapter with all the fixings.
|
||||
"""
|
||||
source_block = BlockFactory(
|
||||
parent=course,
|
||||
category='course_info',
|
||||
display_name='Source Block',
|
||||
metadata={'due': datetime(2010, 11, 22, 4, 0, tzinfo=UTC)},
|
||||
)
|
||||
|
||||
def_id = self.runtime.id_generator.create_definition('html')
|
||||
usage_id = self.runtime.id_generator.create_usage(def_id)
|
||||
|
||||
aside = AsideTest(scope_ids=ScopeIds('user', 'html', def_id, usage_id), runtime=self.runtime)
|
||||
aside.field11 = 'html_new_value1'
|
||||
|
||||
# The data attribute is handled in a special manner and should be updated.
|
||||
source_block.data = '<div>test</div>'
|
||||
# This field is set on the content scope (definition_data), which should be updated.
|
||||
source_block.items = ['test', 'beep']
|
||||
|
||||
self.store.update_item(source_block, self.user.id, asides=[aside])
|
||||
|
||||
# quick sanity checks
|
||||
source_block = self.store.get_item(source_block.location)
|
||||
self.assertEqual(source_block.due, datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
|
||||
self.assertEqual(source_block.display_name, 'Source Block')
|
||||
self.assertEqual(source_block.runtime.get_asides(source_block)[0].field11, 'html_new_value1')
|
||||
self.assertEqual(source_block.data, '<div>test</div>')
|
||||
self.assertEqual(source_block.items, ['test', 'beep'])
|
||||
|
||||
return source_block
|
||||
|
||||
def check_updated(self, source_block, destination_key):
|
||||
"""
|
||||
Check that the destination block has been updated to match our source block.
|
||||
"""
|
||||
revised = self.store.get_item(destination_key)
|
||||
self.assertEqual(source_block.display_name, revised.display_name)
|
||||
self.assertEqual(source_block.due, revised.due)
|
||||
self.assertEqual(revised.data, source_block.data)
|
||||
self.assertEqual(revised.items, source_block.items)
|
||||
|
||||
self.assertEqual(
|
||||
revised.runtime.get_asides(revised)[0].field11,
|
||||
source_block.runtime.get_asides(source_block)[0].field11,
|
||||
)
|
||||
|
||||
@XBlockAside.register_temp_plugin(AsideTest, 'test_aside')
|
||||
def test_update_from_source(self):
|
||||
"""
|
||||
Test that update_from_source updates the destination block.
|
||||
"""
|
||||
course = CourseFactory()
|
||||
user = UserFactory.create()
|
||||
|
||||
source_block = self.create_source_block(course)
|
||||
|
||||
destination_block = BlockFactory(parent=course, category='course_info', display_name='Destination Problem')
|
||||
update_from_source(source_block=source_block, destination_block=destination_block, user_id=user.id)
|
||||
self.check_updated(source_block, destination_block.location)
|
||||
|
||||
@XBlockAside.register_temp_plugin(AsideTest, 'test_aside')
|
||||
def test_update_clobbers(self):
|
||||
"""
|
||||
Verify that our update replaces all settings on the block.
|
||||
"""
|
||||
course = CourseFactory()
|
||||
user = UserFactory.create()
|
||||
|
||||
source_block = self.create_source_block(course)
|
||||
|
||||
destination_block = BlockFactory(
|
||||
parent=course,
|
||||
category='course_info',
|
||||
display_name='Destination Chapter',
|
||||
metadata={'due': datetime(2025, 10, 21, 6, 5, tzinfo=UTC)},
|
||||
)
|
||||
|
||||
def_id = self.runtime.id_generator.create_definition('html')
|
||||
usage_id = self.runtime.id_generator.create_usage(def_id)
|
||||
aside = AsideTest(scope_ids=ScopeIds('user', 'html', def_id, usage_id), runtime=self.runtime)
|
||||
aside.field11 = 'Other stuff'
|
||||
destination_block.data = '<div>other stuff</div>'
|
||||
destination_block.items = ['other stuff', 'boop']
|
||||
self.store.update_item(destination_block, user.id, asides=[aside])
|
||||
|
||||
update_from_source(source_block=source_block, destination_block=destination_block, user_id=user.id)
|
||||
self.check_updated(source_block, destination_block.location)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Test the import_staged_content_from_user_clipboard() method, which is used to
|
||||
allow users to paste XBlocks that were copied using the staged_content/clipboard
|
||||
APIs.
|
||||
"""
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from rest_framework.test import APIClient
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import ToyCourseFactory
|
||||
|
||||
CLIPBOARD_ENDPOINT = "/api/content-staging/v1/clipboard/"
|
||||
XBLOCK_ENDPOINT = "/xblock/"
|
||||
|
||||
|
||||
class ClipboardPasteTestCase(ModuleStoreTestCase):
|
||||
"""
|
||||
Test Clipboard Paste functionality
|
||||
"""
|
||||
|
||||
def _setup_course(self):
|
||||
""" Set up the "Toy Course" and an APIClient for testing clipboard functionality. """
|
||||
# Setup:
|
||||
course_key = ToyCourseFactory.create().id # See xmodule/modulestore/tests/sample_courses.py
|
||||
client = APIClient()
|
||||
client.login(username=self.user.username, password=self.user_password)
|
||||
return (course_key, client)
|
||||
|
||||
def test_copy_and_paste_video(self):
|
||||
"""
|
||||
Test copying a video from the course, and pasting it into the same unit
|
||||
"""
|
||||
course_key, client = self._setup_course()
|
||||
|
||||
# Check how many blocks are in the vertical currently
|
||||
parent_key = course_key.make_usage_key("vertical", "vertical_test") # This is the vertical that holds the video
|
||||
orig_vertical = modulestore().get_item(parent_key)
|
||||
assert len(orig_vertical.children) == 4
|
||||
|
||||
# Copy the video
|
||||
video_key = course_key.make_usage_key("video", "sample_video")
|
||||
copy_response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(video_key)}, format="json")
|
||||
assert copy_response.status_code == 200
|
||||
|
||||
# Paste the video
|
||||
paste_response = client.post(XBLOCK_ENDPOINT, {
|
||||
"parent_locator": str(parent_key),
|
||||
"staged_content": "clipboard",
|
||||
}, format="json")
|
||||
assert paste_response.status_code == 200
|
||||
new_block_key = UsageKey.from_string(paste_response.json()["locator"])
|
||||
|
||||
# Now there should be an extra block in the vertical:
|
||||
updated_vertical = modulestore().get_item(parent_key)
|
||||
assert len(updated_vertical.children) == 5
|
||||
assert updated_vertical.children[-1] == new_block_key
|
||||
# And it should match the original:
|
||||
orig_video = modulestore().get_item(video_key)
|
||||
new_video = modulestore().get_item(new_block_key)
|
||||
assert new_video.youtube_id_1_0 == orig_video.youtube_id_1_0
|
||||
# The new block should store a reference to where it was copied from
|
||||
assert new_video.copied_from_block == str(video_key)
|
||||
@@ -52,6 +52,8 @@ class HelpersTestCase(CourseTestCase):
|
||||
video = BlockFactory.create(parent_location=child_vertical.location, category="video",
|
||||
display_name="My Video")
|
||||
self.assertIsNone(xblock_studio_url(video))
|
||||
# Verify video URL with find_parent=True
|
||||
self.assertEqual(xblock_studio_url(video, find_parent=True), f'/container/{child_vertical.location}')
|
||||
|
||||
# Verify library URL
|
||||
library = LibraryFactory.create()
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""
|
||||
Tests for contentstore.views.preview.py
|
||||
"""
|
||||
|
||||
|
||||
import re
|
||||
from unittest import mock
|
||||
|
||||
import ddt
|
||||
from common.djangoapps.xblock_django.constants import ATTR_KEY_ANONYMOUS_USER_ID, ATTR_KEY_DEPRECATED_ANONYMOUS_USER_ID
|
||||
from django.test.client import Client, RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
from edx_toggles.toggles.testutils import override_waffle_flag
|
||||
@@ -213,13 +212,13 @@ class StudioXBlockServiceBindingTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests that the 'user' and 'i18n' services are provided by the Studio runtime.
|
||||
"""
|
||||
descriptor = BlockFactory(category="pure", parent=self.course)
|
||||
block = BlockFactory(category="pure", parent=self.course)
|
||||
_prepare_runtime_for_preview(
|
||||
self.request,
|
||||
descriptor,
|
||||
block,
|
||||
self.field_data,
|
||||
)
|
||||
service = descriptor.runtime.service(descriptor, expected_service)
|
||||
service = block.runtime.service(block, expected_service)
|
||||
self.assertIsNotNone(service)
|
||||
|
||||
|
||||
@@ -242,32 +241,31 @@ class CmsModuleSystemShimTest(ModuleStoreTestCase):
|
||||
self.request = RequestFactory().get('/dummy-url')
|
||||
self.request.user = self.user
|
||||
self.request.session = {}
|
||||
self.descriptor = BlockFactory(category="video", parent=course)
|
||||
self.field_data = mock.Mock()
|
||||
self.contentstore = contentstore()
|
||||
self.descriptor = BlockFactory(category="problem", parent=course)
|
||||
self.block = BlockFactory(category="problem", parent=course)
|
||||
_prepare_runtime_for_preview(
|
||||
self.request,
|
||||
block=self.descriptor,
|
||||
block=self.block,
|
||||
field_data=mock.Mock(),
|
||||
)
|
||||
self.course = self.store.get_item(course.location)
|
||||
|
||||
def test_get_user_role(self):
|
||||
assert self.descriptor.runtime.get_user_role() == 'staff'
|
||||
assert self.block.runtime.get_user_role() == 'staff'
|
||||
|
||||
@XBlock.register_temp_plugin(PureXBlock, identifier='pure')
|
||||
def test_render_template(self):
|
||||
descriptor = BlockFactory(category="pure", parent=self.course)
|
||||
html = get_preview_fragment(self.request, descriptor, {'element_id': 142}).content
|
||||
block = BlockFactory(category="pure", parent=self.course)
|
||||
html = get_preview_fragment(self.request, block, {'element_id': 142}).content
|
||||
assert '<div id="142" ns="main">Testing the MakoService</div>' in html
|
||||
|
||||
@override_settings(COURSES_WITH_UNSAFE_CODE=[r'course-v1:edX\+LmsModuleShimTest\+2021_Fall'])
|
||||
def test_can_execute_unsafe_code(self):
|
||||
assert self.descriptor.runtime.can_execute_unsafe_code()
|
||||
assert self.block.runtime.can_execute_unsafe_code()
|
||||
|
||||
def test_cannot_execute_unsafe_code(self):
|
||||
assert not self.descriptor.runtime.can_execute_unsafe_code()
|
||||
assert not self.block.runtime.can_execute_unsafe_code()
|
||||
|
||||
@override_settings(PYTHON_LIB_FILENAME=PYTHON_LIB_FILENAME)
|
||||
def test_get_python_lib_zip(self):
|
||||
@@ -277,7 +275,7 @@ class CmsModuleSystemShimTest(ModuleStoreTestCase):
|
||||
source_file=self.PYTHON_LIB_SOURCE_FILE,
|
||||
target_filename=self.PYTHON_LIB_FILENAME,
|
||||
)
|
||||
assert self.descriptor.runtime.get_python_lib_zip() == zipfile
|
||||
assert self.block.runtime.get_python_lib_zip() == zipfile
|
||||
|
||||
def test_no_get_python_lib_zip(self):
|
||||
zipfile = upload_file_to_course(
|
||||
@@ -286,40 +284,47 @@ class CmsModuleSystemShimTest(ModuleStoreTestCase):
|
||||
source_file=self.PYTHON_LIB_SOURCE_FILE,
|
||||
target_filename=self.PYTHON_LIB_FILENAME,
|
||||
)
|
||||
assert self.descriptor.runtime.get_python_lib_zip() is None
|
||||
assert self.block.runtime.get_python_lib_zip() is None
|
||||
|
||||
def test_cache(self):
|
||||
assert hasattr(self.descriptor.runtime.cache, 'get')
|
||||
assert hasattr(self.descriptor.runtime.cache, 'set')
|
||||
assert hasattr(self.block.runtime.cache, 'get')
|
||||
assert hasattr(self.block.runtime.cache, 'set')
|
||||
|
||||
def test_replace_urls(self):
|
||||
html = '<a href="/static/id">'
|
||||
assert self.descriptor.runtime.replace_urls(html) == \
|
||||
assert self.block.runtime.replace_urls(html) == \
|
||||
static_replace.replace_static_urls(html, course_id=self.course.id)
|
||||
|
||||
def test_anonymous_user_id_preview(self):
|
||||
assert self.descriptor.runtime.anonymous_student_id == 'student'
|
||||
assert self.block.runtime.anonymous_student_id == 'student'
|
||||
|
||||
@override_waffle_flag(INDIVIDUALIZE_ANONYMOUS_USER_ID, active=True)
|
||||
def test_anonymous_user_id_individual_per_student(self):
|
||||
"""Test anonymous_user_id on a block which uses per-student anonymous IDs"""
|
||||
# Create the runtime with the flag turned on.
|
||||
descriptor = BlockFactory(category="problem", parent=self.course)
|
||||
block = BlockFactory(category="problem", parent=self.course)
|
||||
_prepare_runtime_for_preview(
|
||||
self.request,
|
||||
block=descriptor,
|
||||
block=block,
|
||||
field_data=mock.Mock(),
|
||||
)
|
||||
assert descriptor.runtime.anonymous_student_id == '26262401c528d7c4a6bbeabe0455ec46'
|
||||
deprecated_anonymous_user_id = (
|
||||
block.runtime.service(block, 'user').get_current_user().opt_attrs.get(ATTR_KEY_DEPRECATED_ANONYMOUS_USER_ID)
|
||||
)
|
||||
assert deprecated_anonymous_user_id == '26262401c528d7c4a6bbeabe0455ec46'
|
||||
|
||||
@override_waffle_flag(INDIVIDUALIZE_ANONYMOUS_USER_ID, active=True)
|
||||
def test_anonymous_user_id_individual_per_course(self):
|
||||
"""Test anonymous_user_id on a block which uses per-course anonymous IDs"""
|
||||
# Create the runtime with the flag turned on.
|
||||
descriptor = BlockFactory(category="lti", parent=self.course)
|
||||
block = BlockFactory(category="lti", parent=self.course)
|
||||
_prepare_runtime_for_preview(
|
||||
self.request,
|
||||
block=descriptor,
|
||||
block=block,
|
||||
field_data=mock.Mock(),
|
||||
)
|
||||
assert descriptor.runtime.anonymous_student_id == 'ad503f629b55c531fed2e45aa17a3368'
|
||||
|
||||
anonymous_user_id = (
|
||||
block.runtime.service(block, 'user').get_current_user().opt_attrs.get(ATTR_KEY_ANONYMOUS_USER_ID)
|
||||
)
|
||||
assert anonymous_user_id == 'ad503f629b55c531fed2e45aa17a3368'
|
||||
|
||||
@@ -256,6 +256,7 @@ class TestUploadTranscripts(BaseTranscripts):
|
||||
expected_edx_video_id = edx_video_id if edx_video_id else json_response['edx_video_id']
|
||||
video = modulestore().get_item(self.video_usage_key)
|
||||
self.assertEqual(video.edx_video_id, expected_edx_video_id)
|
||||
self.assertDictEqual(video.transcripts, {'en': f'{expected_edx_video_id}-en.srt'})
|
||||
|
||||
# Verify transcript content
|
||||
actual_transcript = get_video_transcript_content(video.edx_video_id, language_code='en')
|
||||
@@ -319,6 +320,8 @@ class TestUploadTranscripts(BaseTranscripts):
|
||||
expected_status_code=400,
|
||||
expected_message='There is a problem with this transcript file. Try to upload a different file.'
|
||||
)
|
||||
video = modulestore().get_item(self.video_usage_key)
|
||||
self.assertDictEqual(video.transcripts, {})
|
||||
|
||||
def test_transcript_upload_unknown_category(self):
|
||||
"""
|
||||
@@ -364,7 +367,7 @@ class TestUploadTranscripts(BaseTranscripts):
|
||||
def test_transcript_upload_with_non_existant_edx_video_id(self):
|
||||
"""
|
||||
Test that transcript upload works as expected if `edx_video_id` set on
|
||||
video descriptor is different from `edx_video_id` received in POST request.
|
||||
video block is different from `edx_video_id` received in POST request.
|
||||
"""
|
||||
non_existant_edx_video_id = '1111-2222-3333-4444'
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import ddt
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
from edx_toggles.toggles.testutils import override_waffle_flag, override_waffle_switch
|
||||
from edxval.api import (
|
||||
create_or_update_transcript_preferences,
|
||||
@@ -364,6 +365,7 @@ class VideosHandlerTestCase(
|
||||
'course_video_image_url',
|
||||
'transcripts',
|
||||
'transcription_status',
|
||||
'transcript_urls',
|
||||
'error_description'
|
||||
}
|
||||
)
|
||||
@@ -380,7 +382,7 @@ class VideosHandlerTestCase(
|
||||
[
|
||||
'edx_video_id', 'client_video_id', 'created', 'duration',
|
||||
'status', 'course_video_image_url', 'transcripts', 'transcription_status',
|
||||
'error_description'
|
||||
'transcript_urls', 'error_description'
|
||||
],
|
||||
[
|
||||
{
|
||||
@@ -397,7 +399,7 @@ class VideosHandlerTestCase(
|
||||
[
|
||||
'edx_video_id', 'client_video_id', 'created', 'duration',
|
||||
'status', 'course_video_image_url', 'transcripts', 'transcription_status',
|
||||
'error_description'
|
||||
'transcript_urls', 'error_description'
|
||||
],
|
||||
[
|
||||
{
|
||||
@@ -1574,7 +1576,6 @@ class VideoUrlsCsvTestCase(
|
||||
|
||||
@ddt.ddt
|
||||
class GetVideoFeaturesTestCase(
|
||||
VideoStudioAccessTestsMixin,
|
||||
CourseTestCase
|
||||
):
|
||||
"""Test cases for the get_video_features endpoint """
|
||||
@@ -1582,10 +1583,9 @@ class GetVideoFeaturesTestCase(
|
||||
super().setUp()
|
||||
self.url = self.get_url_for_course_key()
|
||||
|
||||
def get_url_for_course_key(self, course_id=None):
|
||||
def get_url_for_course_key(self):
|
||||
""" Helper to generate a url for a course key """
|
||||
course_id = course_id or str(self.course.id)
|
||||
return reverse_course_url("video_features", course_id)
|
||||
return reverse("video_features")
|
||||
|
||||
def test_basic(self):
|
||||
""" Test for expected return keys """
|
||||
|
||||
@@ -71,7 +71,7 @@ def link_video_to_component(video_component, user):
|
||||
Links a VAL video to the video component.
|
||||
|
||||
Arguments:
|
||||
video_component: video descriptor item.
|
||||
video_component: video block.
|
||||
user: A requesting user.
|
||||
|
||||
Returns:
|
||||
@@ -134,7 +134,7 @@ def validate_video_block(request, locator):
|
||||
locator: video locator.
|
||||
|
||||
Returns:
|
||||
A tuple containing error(or None) and video descriptor(i.e. if validation succeeds).
|
||||
A tuple containing error(or None) and video block(i.e. if validation succeeds).
|
||||
|
||||
Raises:
|
||||
PermissionDenied: if requesting user does not have access to author the video component.
|
||||
@@ -231,6 +231,8 @@ def upload_transcripts(request):
|
||||
file_data=ContentFile(sjson_subs),
|
||||
)
|
||||
|
||||
video.transcripts['en'] = f"{edx_video_id}-en.srt"
|
||||
video.save_with_metadata(request.user)
|
||||
if transcript_created is None:
|
||||
response = JsonResponse({'status': 'Invalid Video ID'}, status=400)
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from edxval.api import (
|
||||
create_video,
|
||||
get_3rd_party_transcription_plans,
|
||||
get_available_transcript_languages,
|
||||
get_video_transcript_url,
|
||||
get_transcript_credentials_state_for_org,
|
||||
get_transcript_preferences,
|
||||
get_videos_for_course,
|
||||
@@ -45,7 +46,6 @@ from rest_framework.response import Response
|
||||
|
||||
from common.djangoapps.edxmako.shortcuts import render_to_response
|
||||
from common.djangoapps.util.json_request import JsonResponse, expect_json
|
||||
from common.djangoapps.util.views import ensure_valid_course_key
|
||||
from openedx.core.djangoapps.video_config.models import VideoTranscriptEnabledFlag
|
||||
from openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE
|
||||
from openedx.core.djangoapps.video_pipeline.config.waffle import (
|
||||
@@ -278,19 +278,14 @@ def video_images_upload_enabled(request):
|
||||
return JsonResponse({'allowThumbnailUpload': True})
|
||||
|
||||
|
||||
@ensure_valid_course_key
|
||||
@login_required
|
||||
@require_GET
|
||||
def get_video_features(request, course_key_string):
|
||||
def get_video_features(request):
|
||||
""" Return a dict with info about which video features are enabled """
|
||||
course_key = CourseKey.from_string(course_key_string)
|
||||
course = get_course_and_check_access(course_key, request.user)
|
||||
if not course:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
features = {
|
||||
'allowThumbnailUpload': VIDEO_IMAGE_UPLOAD_ENABLED.is_enabled(),
|
||||
'videoSharingEnabled': PUBLIC_VIDEO_SHARE.is_enabled(course_key),
|
||||
'videoSharingEnabled': PUBLIC_VIDEO_SHARE.is_enabled(),
|
||||
}
|
||||
return JsonResponse(features)
|
||||
|
||||
@@ -580,6 +575,12 @@ def _get_videos(course, pagination_conf=None):
|
||||
video['transcription_status'] = (
|
||||
StatusDisplayStrings.get(video['status']) if is_video_encodes_ready else ''
|
||||
)
|
||||
video['transcript_urls'] = {}
|
||||
for language_code in video['transcripts']:
|
||||
video['transcript_urls'][language_code] = get_video_transcript_url(
|
||||
video_id=video['edx_video_id'],
|
||||
language_code=language_code,
|
||||
)
|
||||
# Convert the video status.
|
||||
video['status'] = convert_video_status(video, is_video_encodes_ready)
|
||||
|
||||
@@ -601,7 +602,7 @@ def _get_index_videos(course, pagination_conf=None):
|
||||
attrs = [
|
||||
'edx_video_id', 'client_video_id', 'created', 'duration',
|
||||
'status', 'courses', 'transcripts', 'transcription_status',
|
||||
'error_description'
|
||||
'transcript_urls', 'error_description'
|
||||
]
|
||||
|
||||
def _get_values(video):
|
||||
|
||||
@@ -25,21 +25,21 @@ class CourseGradingModel:
|
||||
"""
|
||||
# Within this class, allow access to protected members of client classes.
|
||||
# This comes up when accessing kvs data and caches during kvs saves and modulestore writes.
|
||||
def __init__(self, course_descriptor):
|
||||
def __init__(self, course):
|
||||
self.graders = [
|
||||
CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)
|
||||
CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course.raw_grader)
|
||||
] # weights transformed to ints [0..100]
|
||||
self.grade_cutoffs = course_descriptor.grade_cutoffs
|
||||
self.grace_period = CourseGradingModel.convert_set_grace_period(course_descriptor)
|
||||
self.minimum_grade_credit = course_descriptor.minimum_grade_credit
|
||||
self.grade_cutoffs = course.grade_cutoffs
|
||||
self.grace_period = CourseGradingModel.convert_set_grace_period(course)
|
||||
self.minimum_grade_credit = course.minimum_grade_credit
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, course_key):
|
||||
"""
|
||||
Fetch the course grading policy for the given course from persistence and return a CourseGradingModel.
|
||||
"""
|
||||
descriptor = modulestore().get_course(course_key)
|
||||
model = cls(descriptor)
|
||||
course = modulestore().get_course(course_key)
|
||||
model = cls(course)
|
||||
return model
|
||||
|
||||
@staticmethod
|
||||
@@ -48,10 +48,10 @@ class CourseGradingModel:
|
||||
Fetch the course's nth grader
|
||||
Returns an empty dict if there's no such grader.
|
||||
"""
|
||||
descriptor = modulestore().get_course(course_key)
|
||||
course = modulestore().get_course(course_key)
|
||||
index = int(index)
|
||||
if len(descriptor.raw_grader) > index:
|
||||
return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index])
|
||||
if len(course.raw_grader) > index:
|
||||
return CourseGradingModel.jsonize_grader(index, course.raw_grader[index])
|
||||
|
||||
# return empty model
|
||||
else:
|
||||
@@ -69,27 +69,27 @@ class CourseGradingModel:
|
||||
Decode the json into CourseGradingModel and save any changes. Returns the modified model.
|
||||
Probably not the usual path for updates as it's too coarse grained.
|
||||
"""
|
||||
descriptor = modulestore().get_course(course_key)
|
||||
previous_grading_policy_hash = str(hash_grading_policy(descriptor.grading_policy))
|
||||
course = modulestore().get_course(course_key)
|
||||
previous_grading_policy_hash = str(hash_grading_policy(course.grading_policy))
|
||||
|
||||
graders_parsed = [CourseGradingModel.parse_grader(jsonele) for jsonele in jsondict['graders']]
|
||||
fire_signal = CourseGradingModel.must_fire_grading_event_and_signal(
|
||||
course_key,
|
||||
graders_parsed,
|
||||
descriptor,
|
||||
course,
|
||||
jsondict
|
||||
)
|
||||
descriptor.raw_grader = graders_parsed
|
||||
descriptor.grade_cutoffs = jsondict['grade_cutoffs']
|
||||
course.raw_grader = graders_parsed
|
||||
course.grade_cutoffs = jsondict['grade_cutoffs']
|
||||
|
||||
modulestore().update_item(descriptor, user.id)
|
||||
modulestore().update_item(course, user.id)
|
||||
|
||||
CourseGradingModel.update_grace_period_from_json(course_key, jsondict['grace_period'], user)
|
||||
|
||||
CourseGradingModel.update_minimum_grade_credit_from_json(course_key, jsondict['minimum_grade_credit'], user)
|
||||
|
||||
descriptor = modulestore().get_course(course_key)
|
||||
new_grading_policy_hash = str(hash_grading_policy(descriptor.grading_policy))
|
||||
course = modulestore().get_course(course_key)
|
||||
new_grading_policy_hash = str(hash_grading_policy(course.grading_policy))
|
||||
log.info(
|
||||
"Updated course grading policy for course %s from %s to %s. fire_signal = %s",
|
||||
str(course_key),
|
||||
@@ -153,28 +153,28 @@ class CourseGradingModel:
|
||||
Create or update the grader of the given type (string key) for the given course. Returns the modified
|
||||
grader which is a full model on the client but not on the server (just a dict)
|
||||
"""
|
||||
descriptor = modulestore().get_course(course_key)
|
||||
previous_grading_policy_hash = str(hash_grading_policy(descriptor.grading_policy))
|
||||
course = modulestore().get_course(course_key)
|
||||
previous_grading_policy_hash = str(hash_grading_policy(course.grading_policy))
|
||||
|
||||
# parse removes the id; so, grab it before parse
|
||||
index = int(grader.get('id', len(descriptor.raw_grader)))
|
||||
index = int(grader.get('id', len(course.raw_grader)))
|
||||
grader = CourseGradingModel.parse_grader(grader)
|
||||
|
||||
fire_signal = True
|
||||
if index < len(descriptor.raw_grader):
|
||||
if index < len(course.raw_grader):
|
||||
fire_signal = CourseGradingModel.must_fire_grading_event_and_signal_single_grader(
|
||||
course_key,
|
||||
grader,
|
||||
descriptor.raw_grader[index]
|
||||
course.raw_grader[index]
|
||||
)
|
||||
descriptor.raw_grader[index] = grader
|
||||
course.raw_grader[index] = grader
|
||||
else:
|
||||
descriptor.raw_grader.append(grader)
|
||||
course.raw_grader.append(grader)
|
||||
|
||||
modulestore().update_item(descriptor, user.id)
|
||||
modulestore().update_item(course, user.id)
|
||||
|
||||
descriptor = modulestore().get_course(course_key)
|
||||
new_grading_policy_hash = str(hash_grading_policy(descriptor.grading_policy))
|
||||
course = modulestore().get_course(course_key)
|
||||
new_grading_policy_hash = str(hash_grading_policy(course.grading_policy))
|
||||
log.info(
|
||||
"Updated grader for course %s. Grading policy has changed from %s to %s. fire_signal = %s",
|
||||
str(course_key),
|
||||
@@ -185,7 +185,7 @@ class CourseGradingModel:
|
||||
if fire_signal:
|
||||
_grading_event_and_signal(course_key, user.id)
|
||||
|
||||
return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index])
|
||||
return CourseGradingModel.jsonize_grader(index, course.raw_grader[index])
|
||||
|
||||
@staticmethod
|
||||
def update_cutoffs_from_json(course_key, cutoffs, user):
|
||||
@@ -193,10 +193,10 @@ class CourseGradingModel:
|
||||
Create or update the grade cutoffs for the given course. Returns sent in cutoffs (ie., no extra
|
||||
db fetch).
|
||||
"""
|
||||
descriptor = modulestore().get_course(course_key)
|
||||
descriptor.grade_cutoffs = cutoffs
|
||||
course = modulestore().get_course(course_key)
|
||||
course.grade_cutoffs = cutoffs
|
||||
|
||||
modulestore().update_item(descriptor, user.id)
|
||||
modulestore().update_item(course, user.id)
|
||||
_grading_event_and_signal(course_key, user.id)
|
||||
return cutoffs
|
||||
|
||||
@@ -207,7 +207,7 @@ class CourseGradingModel:
|
||||
grace_period entry in an enclosing dict. It is also safe to call this method with a value of
|
||||
None for graceperiodjson.
|
||||
"""
|
||||
descriptor = modulestore().get_course(course_key)
|
||||
course = modulestore().get_course(course_key)
|
||||
|
||||
# Before a graceperiod has ever been created, it will be None (once it has been
|
||||
# created, it cannot be set back to None).
|
||||
@@ -216,9 +216,9 @@ class CourseGradingModel:
|
||||
graceperiodjson = graceperiodjson['grace_period']
|
||||
|
||||
grace_timedelta = timedelta(**graceperiodjson)
|
||||
descriptor.graceperiod = grace_timedelta
|
||||
course.graceperiod = grace_timedelta
|
||||
|
||||
modulestore().update_item(descriptor, user.id)
|
||||
modulestore().update_item(course, user.id)
|
||||
|
||||
@staticmethod
|
||||
def update_minimum_grade_credit_from_json(course_key, minimum_grade_credit, user):
|
||||
@@ -230,29 +230,29 @@ class CourseGradingModel:
|
||||
user(User): The user object
|
||||
|
||||
"""
|
||||
descriptor = modulestore().get_course(course_key)
|
||||
course = modulestore().get_course(course_key)
|
||||
|
||||
# 'minimum_grade_credit' cannot be set to None
|
||||
if minimum_grade_credit is not None:
|
||||
minimum_grade_credit = minimum_grade_credit # lint-amnesty, pylint: disable=self-assigning-variable
|
||||
|
||||
descriptor.minimum_grade_credit = minimum_grade_credit
|
||||
modulestore().update_item(descriptor, user.id)
|
||||
course.minimum_grade_credit = minimum_grade_credit
|
||||
modulestore().update_item(course, user.id)
|
||||
|
||||
@staticmethod
|
||||
def delete_grader(course_key, index, user):
|
||||
"""
|
||||
Delete the grader of the given type from the given course.
|
||||
"""
|
||||
descriptor = modulestore().get_course(course_key)
|
||||
course = modulestore().get_course(course_key)
|
||||
|
||||
index = int(index)
|
||||
if index < len(descriptor.raw_grader):
|
||||
del descriptor.raw_grader[index]
|
||||
if index < len(course.raw_grader):
|
||||
del course.raw_grader[index]
|
||||
# force propagation to definition
|
||||
descriptor.raw_grader = descriptor.raw_grader
|
||||
course.raw_grader = course.raw_grader
|
||||
|
||||
modulestore().update_item(descriptor, user.id)
|
||||
modulestore().update_item(course, user.id)
|
||||
_grading_event_and_signal(course_key, user.id)
|
||||
|
||||
@staticmethod
|
||||
@@ -260,37 +260,37 @@ class CourseGradingModel:
|
||||
"""
|
||||
Delete the course's grace period.
|
||||
"""
|
||||
descriptor = modulestore().get_course(course_key)
|
||||
course = modulestore().get_course(course_key)
|
||||
|
||||
del descriptor.graceperiod
|
||||
del course.graceperiod
|
||||
|
||||
modulestore().update_item(descriptor, user.id)
|
||||
modulestore().update_item(course, user.id)
|
||||
|
||||
@staticmethod
|
||||
def get_section_grader_type(location):
|
||||
descriptor = modulestore().get_item(location)
|
||||
block = modulestore().get_item(location)
|
||||
return {
|
||||
"graderType": descriptor.format if descriptor.format is not None else 'notgraded',
|
||||
"graderType": block.format if block.format is not None else 'notgraded',
|
||||
"location": str(location),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def update_section_grader_type(descriptor, grader_type, user): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
def update_section_grader_type(block, grader_type, user): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
if grader_type is not None and grader_type != 'notgraded':
|
||||
descriptor.format = grader_type
|
||||
descriptor.graded = True
|
||||
block.format = grader_type
|
||||
block.graded = True
|
||||
else:
|
||||
del descriptor.format
|
||||
del descriptor.graded
|
||||
del block.format
|
||||
del block.graded
|
||||
|
||||
modulestore().update_item(descriptor, user.id)
|
||||
_grading_event_and_signal(descriptor.location.course_key, user.id)
|
||||
modulestore().update_item(block, user.id)
|
||||
_grading_event_and_signal(block.location.course_key, user.id)
|
||||
return {'graderType': grader_type}
|
||||
|
||||
@staticmethod
|
||||
def convert_set_grace_period(descriptor): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
def convert_set_grace_period(course): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
# 5 hours 59 minutes 59 seconds => converted to iso format
|
||||
rawgrace = descriptor.graceperiod
|
||||
rawgrace = course.graceperiod
|
||||
if rawgrace:
|
||||
hours_from_days = rawgrace.days * 24
|
||||
seconds = rawgrace.seconds
|
||||
|
||||
@@ -155,14 +155,14 @@ class CourseMetadata:
|
||||
return exclude_list
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, descriptor, filter_fields=None):
|
||||
def fetch(cls, block, filter_fields=None):
|
||||
"""
|
||||
Fetch the key:value editable course details for the given course from
|
||||
persistence and return a CourseMetadata model.
|
||||
"""
|
||||
result = {}
|
||||
metadata = cls.fetch_all(descriptor, filter_fields=filter_fields)
|
||||
exclude_list_of_fields = cls.get_exclude_list_of_fields(descriptor.id)
|
||||
metadata = cls.fetch_all(block, filter_fields=filter_fields)
|
||||
exclude_list_of_fields = cls.get_exclude_list_of_fields(block.id)
|
||||
|
||||
for key, value in metadata.items():
|
||||
if key in exclude_list_of_fields:
|
||||
@@ -171,12 +171,12 @@ class CourseMetadata:
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def fetch_all(cls, descriptor, filter_fields=None):
|
||||
def fetch_all(cls, block, filter_fields=None):
|
||||
"""
|
||||
Fetches all key:value pairs from persistence and returns a CourseMetadata model.
|
||||
"""
|
||||
result = {}
|
||||
for field in descriptor.fields.values():
|
||||
for field in block.fields.values():
|
||||
if field.scope != Scope.settings:
|
||||
continue
|
||||
|
||||
@@ -189,7 +189,7 @@ class CourseMetadata:
|
||||
field_help = field_help.format(**help_args)
|
||||
|
||||
result[field.name] = {
|
||||
'value': field.read_json(descriptor),
|
||||
'value': field.read_json(block),
|
||||
'display_name': _(field.display_name), # lint-amnesty, pylint: disable=translation-of-non-string
|
||||
'help': field_help,
|
||||
'deprecated': field.runtime_options.get('deprecated', False),
|
||||
@@ -198,13 +198,13 @@ class CourseMetadata:
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def update_from_json(cls, descriptor, jsondict, user, filter_tabs=True):
|
||||
def update_from_json(cls, block, jsondict, user, filter_tabs=True):
|
||||
"""
|
||||
Decode the json into CourseMetadata and save any changed attrs to the db.
|
||||
|
||||
Ensures none of the fields are in the exclude list.
|
||||
"""
|
||||
exclude_list_of_fields = cls.get_exclude_list_of_fields(descriptor.id)
|
||||
exclude_list_of_fields = cls.get_exclude_list_of_fields(block.id)
|
||||
# Don't filter on the tab attribute if filter_tabs is False.
|
||||
if not filter_tabs:
|
||||
exclude_list_of_fields.remove("tabs")
|
||||
@@ -218,16 +218,16 @@ class CourseMetadata:
|
||||
continue
|
||||
try:
|
||||
val = model['value']
|
||||
if hasattr(descriptor, key) and getattr(descriptor, key) != val:
|
||||
key_values[key] = descriptor.fields[key].from_json(val)
|
||||
if hasattr(block, key) and getattr(block, key) != val:
|
||||
key_values[key] = block.fields[key].from_json(val)
|
||||
except (TypeError, ValueError) as err:
|
||||
raise ValueError(_("Incorrect format for field '{name}'. {detailed_message}").format( # lint-amnesty, pylint: disable=raise-missing-from
|
||||
name=model['display_name'], detailed_message=str(err)))
|
||||
|
||||
return cls.update_from_dict(key_values, descriptor, user)
|
||||
return cls.update_from_dict(key_values, block, user)
|
||||
|
||||
@classmethod
|
||||
def validate_and_update_from_json(cls, descriptor, jsondict, user, filter_tabs=True):
|
||||
def validate_and_update_from_json(cls, block, jsondict, user, filter_tabs=True):
|
||||
"""
|
||||
Validate the values in the json dict (validated by xblock fields from_json method)
|
||||
|
||||
@@ -240,7 +240,7 @@ class CourseMetadata:
|
||||
errors: list of error objects
|
||||
result: the updated course metadata or None if error
|
||||
"""
|
||||
exclude_list_of_fields = cls.get_exclude_list_of_fields(descriptor.id)
|
||||
exclude_list_of_fields = cls.get_exclude_list_of_fields(block.id)
|
||||
|
||||
if not filter_tabs:
|
||||
exclude_list_of_fields.remove("tabs")
|
||||
@@ -254,8 +254,8 @@ class CourseMetadata:
|
||||
for key, model in filtered_dict.items():
|
||||
try:
|
||||
val = model['value']
|
||||
if hasattr(descriptor, key) and getattr(descriptor, key) != val:
|
||||
key_values[key] = descriptor.fields[key].from_json(val)
|
||||
if hasattr(block, key) and getattr(block, key) != val:
|
||||
key_values[key] = block.fields[key].from_json(val)
|
||||
except (TypeError, ValueError, ValidationError) as err:
|
||||
did_validate = False
|
||||
errors.append({'key': key, 'message': str(err), 'model': model})
|
||||
@@ -264,7 +264,7 @@ class CourseMetadata:
|
||||
# Because we cannot pass course context to the exception, we need to check if the LTI provider
|
||||
# should actually be available to the course
|
||||
err_message = str(err)
|
||||
if not exams_ida_enabled(descriptor.id):
|
||||
if not exams_ida_enabled(block.id):
|
||||
available_providers = get_available_providers()
|
||||
available_providers.remove('lti_external')
|
||||
err_message = str(InvalidProctoringProvider(val, available_providers))
|
||||
@@ -277,29 +277,29 @@ class CourseMetadata:
|
||||
errors = errors + team_setting_errors
|
||||
did_validate = False
|
||||
|
||||
proctoring_errors = cls.validate_proctoring_settings(descriptor, filtered_dict, user)
|
||||
proctoring_errors = cls.validate_proctoring_settings(block, filtered_dict, user)
|
||||
if proctoring_errors:
|
||||
errors = errors + proctoring_errors
|
||||
did_validate = False
|
||||
|
||||
# If did validate, go ahead and update the metadata
|
||||
if did_validate:
|
||||
updated_data = cls.update_from_dict(key_values, descriptor, user, save=False)
|
||||
updated_data = cls.update_from_dict(key_values, block, user, save=False)
|
||||
|
||||
return did_validate, errors, updated_data
|
||||
|
||||
@classmethod
|
||||
def update_from_dict(cls, key_values, descriptor, user, save=True):
|
||||
def update_from_dict(cls, key_values, block, user, save=True):
|
||||
"""
|
||||
Update metadata descriptor from key_values. Saves to modulestore if save is true.
|
||||
Update metadata from key_values. Saves to modulestore if save is true.
|
||||
"""
|
||||
for key, value in key_values.items():
|
||||
setattr(descriptor, key, value)
|
||||
setattr(block, key, value)
|
||||
|
||||
if save and key_values:
|
||||
modulestore().update_item(descriptor, user.id)
|
||||
modulestore().update_item(block, user.id)
|
||||
|
||||
return cls.fetch(descriptor)
|
||||
return cls.fetch(block)
|
||||
|
||||
@classmethod
|
||||
def validate_team_settings(cls, settings_dict):
|
||||
@@ -397,7 +397,7 @@ class CourseMetadata:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def validate_proctoring_settings(cls, descriptor, settings_dict, user):
|
||||
def validate_proctoring_settings(cls, block, settings_dict, user):
|
||||
"""
|
||||
Verify proctoring settings
|
||||
|
||||
@@ -412,9 +412,9 @@ class CourseMetadata:
|
||||
if (
|
||||
not user.is_staff and
|
||||
cls._has_requested_proctoring_provider_changed(
|
||||
descriptor.proctoring_provider, proctoring_provider_model.get('value')
|
||||
block.proctoring_provider, proctoring_provider_model.get('value')
|
||||
) and
|
||||
datetime.now(pytz.UTC) > descriptor.start
|
||||
datetime.now(pytz.UTC) > block.start
|
||||
):
|
||||
message = (
|
||||
'The proctoring provider cannot be modified after a course has started.'
|
||||
@@ -426,7 +426,7 @@ class CourseMetadata:
|
||||
# should only be allowed if the exams IDA is enabled for a course
|
||||
available_providers = get_available_providers()
|
||||
updated_provider = settings_dict.get('proctoring_provider', {}).get('value')
|
||||
if updated_provider == 'lti_external' and not exams_ida_enabled(descriptor.id):
|
||||
if updated_provider == 'lti_external' and not exams_ida_enabled(block.id):
|
||||
available_providers.remove('lti_external')
|
||||
error = InvalidProctoringProvider('lti_external', available_providers)
|
||||
errors.append({'key': 'proctoring_provider', 'message': str(error), 'model': proctoring_provider_model})
|
||||
@@ -435,7 +435,7 @@ class CourseMetadata:
|
||||
if enable_proctoring_model:
|
||||
enable_proctoring = enable_proctoring_model.get('value')
|
||||
else:
|
||||
enable_proctoring = descriptor.enable_proctored_exams
|
||||
enable_proctoring = block.enable_proctored_exams
|
||||
|
||||
if enable_proctoring:
|
||||
# Require a valid escalation email if Proctortrack is chosen as the proctoring provider
|
||||
@@ -443,12 +443,12 @@ class CourseMetadata:
|
||||
if escalation_email_model:
|
||||
escalation_email = escalation_email_model.get('value')
|
||||
else:
|
||||
escalation_email = descriptor.proctoring_escalation_email
|
||||
escalation_email = block.proctoring_escalation_email
|
||||
|
||||
if proctoring_provider_model:
|
||||
proctoring_provider = proctoring_provider_model.get('value')
|
||||
else:
|
||||
proctoring_provider = descriptor.proctoring_provider
|
||||
proctoring_provider = block.proctoring_provider
|
||||
|
||||
missing_escalation_email_msg = 'Provider \'{provider}\' requires an exam escalation contact.'
|
||||
if proctoring_provider_model and proctoring_provider == 'proctortrack':
|
||||
@@ -477,7 +477,7 @@ class CourseMetadata:
|
||||
if zendesk_ticket_model:
|
||||
create_zendesk_tickets = zendesk_ticket_model.get('value')
|
||||
else:
|
||||
create_zendesk_tickets = descriptor.create_zendesk_tickets
|
||||
create_zendesk_tickets = block.create_zendesk_tickets
|
||||
|
||||
if (
|
||||
(proctoring_provider == 'proctortrack' and create_zendesk_tickets)
|
||||
@@ -489,7 +489,7 @@ class CourseMetadata:
|
||||
'should be updated for this course.'.format(
|
||||
ticket_value=create_zendesk_tickets,
|
||||
provider=proctoring_provider,
|
||||
course_id=descriptor.id
|
||||
course_id=block.id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ define(
|
||||
window._ = _;
|
||||
|
||||
$script(
|
||||
'https://cdn.jsdelivr.net/npm/mathjax@2.7.5/MathJax.js' +
|
||||
'?config=TeX-MML-AM_SVG&delayStartupUntil=configured',
|
||||
'https://cdn.jsdelivr.net/npm/mathjax@2.7.5/MathJax.js'
|
||||
+ '?config=TeX-MML-AM_SVG&delayStartupUntil=configured',
|
||||
'mathjax',
|
||||
function() {
|
||||
window.MathJax.Hub.Config({
|
||||
@@ -55,7 +55,7 @@ define(
|
||||
* The module should be used until we'll use RequireJS for XModules.
|
||||
* @param {Array} modules A list of urls.
|
||||
* @return {jQuery Promise}
|
||||
**/
|
||||
* */
|
||||
function requireQueue(modules) {
|
||||
var deferred = $.Deferred();
|
||||
function loadScript(queue) {
|
||||
|
||||
@@ -19,9 +19,9 @@ class XBlockConfig(AppConfig):
|
||||
def ready(self):
|
||||
from openedx.core.lib.xblock_utils import xblock_local_resource_url
|
||||
|
||||
# In order to allow descriptors to use a handler url, we need to
|
||||
# In order to allow blocks to use a handler url, we need to
|
||||
# monkey-patch the x_module library.
|
||||
# TODO: Remove this code when Runtimes are no longer created by modulestores
|
||||
# https://openedx.atlassian.net/wiki/display/PLAT/Convert+from+Storage-centric+runtimes+to+Application-centric+runtimes
|
||||
xmodule.x_module.descriptor_global_handler_url = cms.lib.xblock.runtime.handler_url
|
||||
xmodule.x_module.descriptor_global_local_resource_url = xblock_local_resource_url
|
||||
xmodule.x_module.block_global_handler_url = cms.lib.xblock.runtime.handler_url
|
||||
xmodule.x_module.block_global_local_resource_url = xblock_local_resource_url
|
||||
|
||||
@@ -1439,7 +1439,8 @@ REQUIRE_DEBUG = False
|
||||
WEBPACK_LOADER = {
|
||||
'DEFAULT': {
|
||||
'BUNDLE_DIR_NAME': 'bundles/',
|
||||
'STATS_FILE': os.path.join(STATIC_ROOT, 'webpack-stats.json')
|
||||
'STATS_FILE': os.path.join(STATIC_ROOT, 'webpack-stats.json'),
|
||||
'LOADER_CLASS': 'xmodule.util.xmodule_django.XModuleWebpackLoader',
|
||||
},
|
||||
'WORKERS': {
|
||||
'BUNDLE_DIR_NAME': 'bundles/',
|
||||
@@ -2240,6 +2241,12 @@ PARTNER_SUPPORT_EMAIL = ''
|
||||
# Affiliate cookie tracking
|
||||
AFFILIATE_COOKIE_NAME = 'dev_affiliate_id'
|
||||
|
||||
# API access management
|
||||
API_ACCESS_MANAGER_EMAIL = 'api-access@example.com'
|
||||
API_ACCESS_FROM_EMAIL = 'api-requests@example.com'
|
||||
API_DOCUMENTATION_URL = 'https://course-catalog-api-guide.readthedocs.io/en/latest/'
|
||||
AUTH_DOCUMENTATION_URL = 'https://course-catalog-api-guide.readthedocs.io/en/latest/authentication/index.html'
|
||||
|
||||
############## Settings for Studio Context Sensitive Help ##############
|
||||
|
||||
HELP_TOKENS_INI_FILE = REPO_ROOT / "cms" / "envs" / "help_tokens.ini"
|
||||
|
||||
@@ -296,10 +296,10 @@ CREDENTIALS_PUBLIC_SERVICE_URL = 'http://localhost:18150'
|
||||
# in the LMS and CMS.
|
||||
# .. toggle_tickets: 'https://github.com/openedx/edx-platform/pull/31813'
|
||||
FEATURES['ENABLE_SEND_XBLOCK_EVENTS_OVER_BUS'] = True
|
||||
EVENT_BUS_PRODUCER = 'edx_event_bus_kafka.create_producer'
|
||||
EVENT_BUS_KAFKA_SCHEMA_REGISTRY_URL = 'http://edx.devstack.schema-registry:8081'
|
||||
EVENT_BUS_KAFKA_BOOTSTRAP_SERVERS = 'edx.devstack.kafka:29092'
|
||||
EVENT_BUS_PRODUCER = 'edx_event_bus_redis.create_producer'
|
||||
EVENT_BUS_REDIS_CONNECTION_URL = 'redis://:password@edx.devstack.redis:6379/'
|
||||
EVENT_BUS_TOPIC_PREFIX = 'dev'
|
||||
EVENT_BUS_CONSUMER = 'edx_event_bus_redis.RedisEventConsumer'
|
||||
|
||||
################# New settings must go ABOVE this line #################
|
||||
########################################################################
|
||||
|
||||
@@ -34,6 +34,7 @@ video = course_author:video/index.html
|
||||
certificates = course_author:set_up_course/studio_add_course_information/studio_creating_certificates.html
|
||||
content_highlights = course_author:developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages
|
||||
image_accessibility = course_author:accessibility/best_practices_course_content_dev.html#use-best-practices-for-describing-images
|
||||
social_sharing = course_author:developing_course/social_sharing.html
|
||||
|
||||
# below are the language directory names for the different locales
|
||||
[locales]
|
||||
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
from django.conf import settings
|
||||
from web_fragments.fragment import Fragment
|
||||
from xblock.core import XBlock, XBlockMixin
|
||||
from xblock.fields import String, Scope
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,3 +44,10 @@ class AuthoringMixin(XBlockMixin):
|
||||
fragment.add_javascript_url(self._get_studio_resource_url('/js/xblock/authoring.js'))
|
||||
fragment.initialize_js('VisibilityEditorInit')
|
||||
return fragment
|
||||
|
||||
copied_from_block = String(
|
||||
# Note: used by the content_staging app. This field is not needed in the LMS.
|
||||
help="ID of the block that this one was copied from, if any. Used when copying and pasting blocks in Studio.",
|
||||
scope=Scope.settings,
|
||||
enforce_type=True,
|
||||
)
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
});
|
||||
};
|
||||
|
||||
var jsOptimize = process.env.REQUIRE_BUILD_PROFILE_OPTIMIZE !== undefined ?
|
||||
process.env.REQUIRE_BUILD_PROFILE_OPTIMIZE : 'uglify2';
|
||||
var jsOptimize = process.env.REQUIRE_BUILD_PROFILE_OPTIMIZE !== undefined
|
||||
? process.env.REQUIRE_BUILD_PROFILE_OPTIMIZE : 'uglify2';
|
||||
|
||||
return {
|
||||
/**
|
||||
|
||||
@@ -35,7 +35,7 @@ define([
|
||||
});
|
||||
$(document).ajaxError(function(event, jqXHR, ajaxSettings) {
|
||||
var msg, contentType,
|
||||
message = gettext('This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.'); // eslint-disable-line max-len
|
||||
message = gettext('This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.'); // eslint-disable-line max-len
|
||||
if (ajaxSettings.notifyOnError === false) {
|
||||
return;
|
||||
}
|
||||
@@ -54,7 +54,7 @@ define([
|
||||
});
|
||||
return msg.show();
|
||||
});
|
||||
sendJSON = function(url, data, callback, type) { // eslint-disable-line no-param-reassign
|
||||
sendJSON = function(url, data, callback, type) { // eslint-disable-line no-param-reassign
|
||||
if ($.isFunction(data)) {
|
||||
callback = data;
|
||||
data = undefined;
|
||||
@@ -66,13 +66,13 @@ define([
|
||||
dataType: 'json',
|
||||
data: JSON.stringify(data),
|
||||
success: callback,
|
||||
global: data ? data.global : true // Trigger global AJAX error handler or not
|
||||
global: data ? data.global : true // Trigger global AJAX error handler or not
|
||||
});
|
||||
};
|
||||
$.postJSON = function(url, data, callback) { // eslint-disable-line no-param-reassign
|
||||
$.postJSON = function(url, data, callback) { // eslint-disable-line no-param-reassign
|
||||
return sendJSON(url, data, callback, 'POST');
|
||||
};
|
||||
$.patchJSON = function(url, data, callback) { // eslint-disable-line no-param-reassign
|
||||
$.patchJSON = function(url, data, callback) { // eslint-disable-line no-param-reassign
|
||||
return sendJSON(url, data, callback, 'PATCH');
|
||||
};
|
||||
return domReady(function() {
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
'jquery_extend_patch': 'js/src/jquery_extend_patch',
|
||||
|
||||
// externally hosted files
|
||||
mathjax: 'https://cdn.jsdelivr.net/npm/mathjax@2.7.5/MathJax.js?config=TeX-MML-AM_SVG&delayStartupUntil=configured', // eslint-disable-line max-len
|
||||
mathjax: 'https://cdn.jsdelivr.net/npm/mathjax@2.7.5/MathJax.js?config=TeX-MML-AM_SVG&delayStartupUntil=configured', // eslint-disable-line max-len
|
||||
'youtube': [
|
||||
// youtube URL does not end in '.js'. We add '?noext' to the path so
|
||||
// that require.js adds the '.js' to the query component of the URL,
|
||||
|
||||
@@ -39,9 +39,9 @@
|
||||
'jquery.cookie': 'xmodule_js/common_static/js/vendor/jquery.cookie',
|
||||
'jquery.qtip': 'xmodule_js/common_static/js/vendor/jquery.qtip.min',
|
||||
'jquery.fileupload': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload',
|
||||
'jquery.fileupload-process': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-process', // eslint-disable-line max-len
|
||||
'jquery.fileupload-validate': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-validate', // eslint-disable-line max-len
|
||||
'jquery.iframe-transport': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport', // eslint-disable-line max-len
|
||||
'jquery.fileupload-process': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-process', // eslint-disable-line max-len
|
||||
'jquery.fileupload-validate': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-validate', // eslint-disable-line max-len
|
||||
'jquery.iframe-transport': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport', // eslint-disable-line max-len
|
||||
'jquery.inputnumber': 'xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill',
|
||||
'jquery.immediateDescendents': 'xmodule_js/common_static/js/src/jquery.immediateDescendents',
|
||||
'jquery.simulate': 'xmodule_js/common_static/js/vendor/jquery.simulate',
|
||||
@@ -69,7 +69,7 @@
|
||||
'domReady': 'xmodule_js/common_static/js/vendor/domReady',
|
||||
'URI': 'xmodule_js/common_static/js/vendor/URI.min',
|
||||
'mock-ajax': 'xmodule_js/common_static/js/vendor/mock-ajax',
|
||||
mathjax: 'https://cdn.jsdelivr.net/npm/mathjax@2.7.5/MathJax.js?config=TeX-MML-AM_SVG&delayStartupUntil=configured', // eslint-disable-line max-len
|
||||
mathjax: 'https://cdn.jsdelivr.net/npm/mathjax@2.7.5/MathJax.js?config=TeX-MML-AM_SVG&delayStartupUntil=configured', // eslint-disable-line max-len
|
||||
'youtube': '//www.youtube.com/player_api?noext',
|
||||
'js/src/ajax_prefix': 'xmodule_js/common_static/js/src/ajax_prefix',
|
||||
'js/spec/test_utils': 'js/spec/test_utils'
|
||||
@@ -297,6 +297,6 @@
|
||||
];
|
||||
|
||||
requireSerial(specHelpers.concat(testFiles), function() {
|
||||
return window.__karma__.start(); // eslint-disable-line no-underscore-dangle
|
||||
return window.__karma__.start(); // eslint-disable-line no-underscore-dangle
|
||||
});
|
||||
}).call(this, requirejs, requireSerial);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
(function(sandbox) {
|
||||
'use strict';
|
||||
|
||||
require(['jquery', 'backbone', 'cms/js/main', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'jquery.cookie'],
|
||||
function($, Backbone, main, AjaxHelpers) {
|
||||
describe('CMS', function() {
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
'jquery.cookie': 'xmodule_js/common_static/js/vendor/jquery.cookie',
|
||||
'jquery.qtip': 'xmodule_js/common_static/js/vendor/jquery.qtip.min',
|
||||
'jquery.fileupload': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload',
|
||||
'jquery.fileupload-process': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-process', // eslint-disable-line max-len
|
||||
'jquery.fileupload-validate': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-validate', // eslint-disable-line max-len
|
||||
'jquery.iframe-transport': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport', // eslint-disable-line max-len
|
||||
'jquery.fileupload-process': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-process', // eslint-disable-line max-len
|
||||
'jquery.fileupload-validate': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-validate', // eslint-disable-line max-len
|
||||
'jquery.iframe-transport': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport', // eslint-disable-line max-len
|
||||
'jquery.inputnumber': 'xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill',
|
||||
'jquery.immediateDescendents': 'xmodule_js/common_static/js/src/jquery.immediateDescendents',
|
||||
'jquery.ajaxQueue': 'xmodule_js/common_static/js/vendor/jquery.ajaxQueue',
|
||||
@@ -48,7 +48,7 @@
|
||||
'draggabilly': 'xmodule_js/common_static/js/vendor/draggabilly',
|
||||
'domReady': 'xmodule_js/common_static/js/vendor/domReady',
|
||||
'URI': 'xmodule_js/common_static/js/vendor/URI.min',
|
||||
mathjax: 'https://cdn.jsdelivr.net/npm/mathjax@2.7.5/MathJax.js?config=TeX-MML-AM_SVG&delayStartupUntil=configured', // eslint-disable-line max-len
|
||||
mathjax: 'https://cdn.jsdelivr.net/npm/mathjax@2.7.5/MathJax.js?config=TeX-MML-AM_SVG&delayStartupUntil=configured', // eslint-disable-line max-len
|
||||
'youtube': '//www.youtube.com/player_api?noext',
|
||||
'js/src/ajax_prefix': 'xmodule_js/common_static/js/src/ajax_prefix'
|
||||
},
|
||||
@@ -210,6 +210,6 @@
|
||||
];
|
||||
|
||||
requireSerial(specHelpers.concat(testFiles), function() {
|
||||
return window.__karma__.start(); // eslint-disable-line no-underscore-dangle
|
||||
return window.__karma__.start(); // eslint-disable-line no-underscore-dangle
|
||||
});
|
||||
}).call(this, requirejs, requireSerial);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
jasmine.getFixtures().fixturesPath = '/base/templates';
|
||||
|
||||
import 'common/js/spec_helpers/jasmine-extensions';
|
||||
import 'common/js/spec_helpers/jasmine-stealth';
|
||||
import 'common/js/spec_helpers/jasmine-waituntil';
|
||||
@@ -12,11 +10,6 @@ import _ from 'underscore';
|
||||
import str from 'underscore.string';
|
||||
import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils';
|
||||
import StringUtils from 'edx-ui-toolkit/js/utils/string-utils';
|
||||
window._ = _;
|
||||
window._.str = str;
|
||||
window.edx = window.edx || {};
|
||||
window.edx.HtmlUtils = HtmlUtils;
|
||||
window.edx.StringUtils = StringUtils;
|
||||
|
||||
// These are the tests that will be run
|
||||
import './xblock/cms.runtime.v1_spec.js';
|
||||
@@ -31,4 +24,12 @@ import '../../../js/spec/views/pages/course_outline_spec.js';
|
||||
import '../../../js/spec/views/xblock_editor_spec.js';
|
||||
import '../../../js/spec/views/xblock_string_field_editor_spec.js';
|
||||
|
||||
window.__karma__.start(); // eslint-disable-line no-underscore-dangle
|
||||
jasmine.getFixtures().fixturesPath = '/base/templates';
|
||||
|
||||
window._ = _;
|
||||
window._.str = str;
|
||||
window.edx = window.edx || {};
|
||||
window.edx.HtmlUtils = HtmlUtils;
|
||||
window.edx.StringUtils = StringUtils;
|
||||
|
||||
window.__karma__.start(); // eslint-disable-line no-underscore-dangle
|
||||
|
||||
@@ -27,6 +27,7 @@ function(
|
||||
DropdownMenuView
|
||||
) {
|
||||
'use strict';
|
||||
|
||||
var $body;
|
||||
|
||||
function smoothScrollLink(e) {
|
||||
|
||||
@@ -7,6 +7,7 @@ define([
|
||||
],
|
||||
function(Backbone, gettext, Certificate) {
|
||||
'use strict';
|
||||
|
||||
var CertificateCollection = Backbone.Collection.extend({
|
||||
model: Certificate,
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ define([
|
||||
],
|
||||
function(Backbone, Signatory) {
|
||||
'use strict';
|
||||
|
||||
var SignatoryCollection = Backbone.Collection.extend({
|
||||
model: Signatory
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ define([
|
||||
],
|
||||
function($, CertificatesCollection, Certificate, CertificatesPage, CertificatePreview) {
|
||||
'use strict';
|
||||
|
||||
return function(certificatesJson, certificateUrl, courseOutlineUrl, courseModes, certificateWebViewUrl,
|
||||
isActive, certificateActivationHandlerUrl) {
|
||||
// Initialize the model collection, passing any necessary options to the constructor
|
||||
|
||||
@@ -13,6 +13,7 @@ define([
|
||||
function(_, Backbone, BackboneRelational, BackboneAssociations, gettext, CoffeeSrcMain,
|
||||
SignatoryModel, SignatoryCollection) {
|
||||
'use strict';
|
||||
|
||||
var Certificate = Backbone.RelationalModel.extend({
|
||||
idAttribute: 'id',
|
||||
defaults: {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Custom matcher library for Jasmine test assertions
|
||||
// http://tobyho.com/2012/01/30/write-a-jasmine-matcher/
|
||||
|
||||
define(['jquery'], function($) { // eslint-disable-line no-unused-vars
|
||||
define(['jquery'], function($) { // eslint-disable-line no-unused-vars
|
||||
|
||||
'use strict';
|
||||
|
||||
return function() {
|
||||
jasmine.addMatchers({
|
||||
toBeCorrectValuesInModel: function() {
|
||||
|
||||
@@ -118,7 +118,6 @@ function(_, Course, CertificatesCollection, CertificateModel, CertificateDetails
|
||||
delete window.CMS.User;
|
||||
});
|
||||
|
||||
|
||||
describe('The Certificate Details view', function() {
|
||||
it('should parse a JSON string collection into a Backbone model collection', function() {
|
||||
var course_title = 'Test certificate course title override 2';
|
||||
@@ -140,7 +139,7 @@ function(_, Course, CertificatesCollection, CertificateModel, CertificateDetails
|
||||
});
|
||||
|
||||
it('should have empty certificate collection if there is an error parsing certifcate JSON', function() {
|
||||
var CERTIFICATE_INVALID_JSON = '[{"course_title": Test certificate course title override, "signatories":"[]"}]'; // eslint-disable-line max-len
|
||||
var CERTIFICATE_INVALID_JSON = '[{"course_title": Test certificate course title override, "signatories":"[]"}]'; // eslint-disable-line max-len
|
||||
var collection_length = this.collection.length;
|
||||
this.collection.parse(CERTIFICATE_INVALID_JSON);
|
||||
// collection length should remain the same since we have error parsing JSON
|
||||
|
||||
@@ -64,16 +64,16 @@ function(_, $, Course, CertificatePreview, TemplateHelpers, ViewHelpers, AjaxHel
|
||||
|
||||
it('course mode selection updating the link successfully', function() {
|
||||
selectDropDownByText(this.view.$(SELECTORS.course_modes), 'test1');
|
||||
expect(this.view.$(SELECTORS.preview_certificate).attr('href')).
|
||||
toEqual('/users/1/courses/orgX/009/2016?preview=test1');
|
||||
expect(this.view.$(SELECTORS.preview_certificate).attr('href'))
|
||||
.toEqual('/users/1/courses/orgX/009/2016?preview=test1');
|
||||
|
||||
selectDropDownByText(this.view.$(SELECTORS.course_modes), 'test2');
|
||||
expect(this.view.$(SELECTORS.preview_certificate).attr('href')).
|
||||
toEqual('/users/1/courses/orgX/009/2016?preview=test2');
|
||||
expect(this.view.$(SELECTORS.preview_certificate).attr('href'))
|
||||
.toEqual('/users/1/courses/orgX/009/2016?preview=test2');
|
||||
|
||||
selectDropDownByText(this.view.$(SELECTORS.course_modes), 'test3');
|
||||
expect(this.view.$(SELECTORS.preview_certificate).attr('href')).
|
||||
toEqual('/users/1/courses/orgX/009/2016?preview=test3');
|
||||
expect(this.view.$(SELECTORS.preview_certificate).attr('href'))
|
||||
.toEqual('/users/1/courses/orgX/009/2016?preview=test3');
|
||||
});
|
||||
|
||||
it('toggle certificate activation event works fine', function() {
|
||||
|
||||
@@ -15,6 +15,7 @@ define([
|
||||
function($, _, str, gettext, BaseView, SignatoryModel, SignatoryDetailsView, ViewUtils, smoothScroll,
|
||||
certificateDetailsTemplate) {
|
||||
'use strict';
|
||||
|
||||
var CertificateDetailsView = BaseView.extend({
|
||||
tagName: 'div',
|
||||
events: {
|
||||
|
||||
@@ -84,7 +84,7 @@ function($, _, Backbone, gettext,
|
||||
|
||||
addSignatory: function() {
|
||||
// Append a new signatory to the certificate model's signatories collection
|
||||
var signatory = new SignatoryModel({certificate: this.getSaveableModel()}); // eslint-disable-line max-len, no-unused-vars
|
||||
var signatory = new SignatoryModel({certificate: this.getSaveableModel()}); // eslint-disable-line max-len, no-unused-vars
|
||||
this.render();
|
||||
},
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ define([
|
||||
],
|
||||
function(gettext, ListItemView, CertificateDetailsView, CertificateEditorView) {
|
||||
'use strict';
|
||||
|
||||
var CertificateItemView = ListItemView.extend({
|
||||
events: {
|
||||
'click .delete': 'deleteItem'
|
||||
|
||||
@@ -13,6 +13,7 @@ define([
|
||||
],
|
||||
function(_, gettext, BaseView, ViewUtils, NotificationView, certificateWebPreviewTemplate, HtmlUtils) {
|
||||
'use strict';
|
||||
|
||||
var CertificateWebPreview = BaseView.extend({
|
||||
el: $('.preview-certificate'),
|
||||
events: {
|
||||
|
||||
@@ -7,6 +7,7 @@ define([
|
||||
],
|
||||
function(gettext, ListView, CertificateItemView) {
|
||||
'use strict';
|
||||
|
||||
var CertificatesListView = ListView.extend({
|
||||
tagName: 'div',
|
||||
className: 'certificates-list',
|
||||
|
||||
@@ -9,6 +9,7 @@ define([
|
||||
],
|
||||
function($, _, gettext, BasePage, CertificatesListView) {
|
||||
'use strict';
|
||||
|
||||
var CertificatesPage = BasePage.extend({
|
||||
|
||||
initialize: function(options) {
|
||||
|
||||
@@ -17,6 +17,7 @@ define([
|
||||
function($, _, str, Backbone, gettext, TemplateUtils, ViewUtils, BaseView, SignatoryEditorView,
|
||||
signatoryDetailsTemplate, signatoryActionsTemplate, HtmlUtils) {
|
||||
'use strict';
|
||||
|
||||
var SignatoryDetailsView = BaseView.extend({
|
||||
tagName: 'div',
|
||||
events: {
|
||||
|
||||
@@ -18,6 +18,7 @@ function($, _, Backbone, gettext,
|
||||
TemplateUtils, ViewUtils, PromptView, NotificationView, FileUploadModel, FileUploadDialog,
|
||||
signatoryEditorTemplate, HtmlUtils) {
|
||||
'use strict';
|
||||
|
||||
var SignatoryEditorView = Backbone.View.extend({
|
||||
tagName: 'div',
|
||||
events: {
|
||||
@@ -61,7 +62,7 @@ function($, _, Backbone, gettext,
|
||||
var count = 0;
|
||||
this.model.collection.each(function(modelSignatory) {
|
||||
if (!modelSignatory.isNew()) {
|
||||
count ++;
|
||||
count++;
|
||||
}
|
||||
});
|
||||
return count;
|
||||
|
||||
@@ -3,7 +3,7 @@ define(['backbone', 'js/models/chapter'], function(Backbone, ChapterModel) {
|
||||
model: ChapterModel,
|
||||
comparator: 'order',
|
||||
nextOrder: function() {
|
||||
if (!this.length) return 1;
|
||||
if (!this.length) { return 1; }
|
||||
return this.last().get('order') + 1;
|
||||
},
|
||||
isEmpty: function() {
|
||||
|
||||
@@ -3,6 +3,7 @@ define([
|
||||
],
|
||||
function(_, str, Backbone, gettext, GroupModel) {
|
||||
'use strict';
|
||||
|
||||
var GroupCollection = Backbone.Collection.extend({
|
||||
model: GroupModel,
|
||||
comparator: 'order',
|
||||
@@ -20,7 +21,7 @@ function(_, str, Backbone, gettext, GroupModel) {
|
||||
/**
|
||||
* Indicates if the collection is empty when all the models are empty
|
||||
* or the collection does not include any models.
|
||||
**/
|
||||
* */
|
||||
isEmpty: function() {
|
||||
return this.length === 0 || this.every(function(m) {
|
||||
return m.isEmpty();
|
||||
@@ -40,7 +41,7 @@ function(_, str, Backbone, gettext, GroupModel) {
|
||||
|
||||
do {
|
||||
name = str.sprintf(gettext('Group %s'), this.getGroupId(index));
|
||||
index ++;
|
||||
index++;
|
||||
} while (_.contains(usedNames, name));
|
||||
|
||||
return name;
|
||||
|
||||
@@ -3,6 +3,7 @@ define([
|
||||
],
|
||||
function(Backbone, GroupConfigurationModel) {
|
||||
'use strict';
|
||||
|
||||
var GroupConfigurationCollection = Backbone.Collection.extend({
|
||||
model: GroupConfigurationModel
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ define([
|
||||
'jquery', 'js/collections/asset', 'js/views/assets', 'jquery.fileupload'
|
||||
], function($, AssetCollection, AssetsView) {
|
||||
'use strict';
|
||||
|
||||
return function(config) {
|
||||
var assets = new AssetCollection(),
|
||||
assetsView;
|
||||
|
||||
@@ -21,6 +21,6 @@ export default function ContainerFactory(componentTemplates, XBlockInfoJson, act
|
||||
var view = new ContainerPage(_.extend(main_options, options));
|
||||
view.render();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export {ContainerFactory}
|
||||
export {ContainerFactory};
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import * as ContextCourse from 'js/models/course';
|
||||
|
||||
export {ContextCourse}
|
||||
export {ContextCourse};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
define(['jquery', 'jquery.form', 'js/views/course_rerun'], function($) {
|
||||
'use strict';
|
||||
|
||||
return function() {};
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user