Merge branch 'master' into fix-FB-share

This commit is contained in:
Edward Zarecor
2023-06-09 10:33:48 -04:00
committed by GitHub
1240 changed files with 29969 additions and 43091 deletions

View File

@@ -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
View File

@@ -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
View 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"

View File

@@ -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.

View File

@@ -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') }}

View File

@@ -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') }}

View File

@@ -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') }}

View File

@@ -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') }}

View File

@@ -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') }}

View File

@@ -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') }}

View File

@@ -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') }}

View File

@@ -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') }}

View File

@@ -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') }}

View File

@@ -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/",

View File

@@ -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') }}

View File

@@ -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

View 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

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View File

@@ -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'],
))

View 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)

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"
),
]

View File

@@ -0,0 +1,6 @@
"""
Views for v1 contentstore API.
"""
from .course_details import CourseDetailsView
from .settings import CourseSettingsView
from .proctoring import ProctoredExamSettingsView, ProctoringErrorsView

View 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)

View File

@@ -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)

View 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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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([

View File

@@ -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()

View File

@@ -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):

View File

@@ -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)

View File

@@ -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)

View File

@@ -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):

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 = (

View File

@@ -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):

View File

@@ -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(

View File

@@ -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)

View File

@@ -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)

View File

@@ -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.

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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'

View File

@@ -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'

View File

@@ -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 """

View File

@@ -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)

View File

@@ -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):

View File

@@ -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

View File

@@ -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
)
)

View File

@@ -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) {

View File

@@ -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

View File

@@ -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"

View File

@@ -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 #################
########################################################################

View File

@@ -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]

View File

@@ -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,
)

View File

@@ -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 {
/**

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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);

View File

@@ -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() {

View File

@@ -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);

View File

@@ -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

View File

@@ -27,6 +27,7 @@ function(
DropdownMenuView
) {
'use strict';
var $body;
function smoothScrollLink(e) {

View File

@@ -7,6 +7,7 @@ define([
],
function(Backbone, gettext, Certificate) {
'use strict';
var CertificateCollection = Backbone.Collection.extend({
model: Certificate,

View File

@@ -6,6 +6,7 @@ define([
],
function(Backbone, Signatory) {
'use strict';
var SignatoryCollection = Backbone.Collection.extend({
model: Signatory
});

View File

@@ -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

View File

@@ -13,6 +13,7 @@ define([
function(_, Backbone, BackboneRelational, BackboneAssociations, gettext, CoffeeSrcMain,
SignatoryModel, SignatoryCollection) {
'use strict';
var Certificate = Backbone.RelationalModel.extend({
idAttribute: 'id',
defaults: {

View File

@@ -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() {

View File

@@ -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

View File

@@ -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() {

View File

@@ -15,6 +15,7 @@ define([
function($, _, str, gettext, BaseView, SignatoryModel, SignatoryDetailsView, ViewUtils, smoothScroll,
certificateDetailsTemplate) {
'use strict';
var CertificateDetailsView = BaseView.extend({
tagName: 'div',
events: {

View File

@@ -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();
},

View File

@@ -9,6 +9,7 @@ define([
],
function(gettext, ListItemView, CertificateDetailsView, CertificateEditorView) {
'use strict';
var CertificateItemView = ListItemView.extend({
events: {
'click .delete': 'deleteItem'

View File

@@ -13,6 +13,7 @@ define([
],
function(_, gettext, BaseView, ViewUtils, NotificationView, certificateWebPreviewTemplate, HtmlUtils) {
'use strict';
var CertificateWebPreview = BaseView.extend({
el: $('.preview-certificate'),
events: {

View File

@@ -7,6 +7,7 @@ define([
],
function(gettext, ListView, CertificateItemView) {
'use strict';
var CertificatesListView = ListView.extend({
tagName: 'div',
className: 'certificates-list',

View File

@@ -9,6 +9,7 @@ define([
],
function($, _, gettext, BasePage, CertificatesListView) {
'use strict';
var CertificatesPage = BasePage.extend({
initialize: function(options) {

View File

@@ -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: {

View File

@@ -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;

View File

@@ -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() {

View File

@@ -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;

View File

@@ -3,6 +3,7 @@ define([
],
function(Backbone, GroupConfigurationModel) {
'use strict';
var GroupConfigurationCollection = Backbone.Collection.extend({
model: GroupConfigurationModel
});

View File

@@ -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;

View File

@@ -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};

View File

@@ -1,3 +1,3 @@
import * as ContextCourse from 'js/models/course';
export {ContextCourse}
export {ContextCourse};

View File

@@ -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