From 4922877b1f420a7a2e597df055d2173a15d1496c Mon Sep 17 00:00:00 2001
From: Josue Balandrano Coronel
Date: Wed, 3 Jul 2019 17:26:36 -0500
Subject: [PATCH 01/20] feat: update enrollment serializer and add problem
submission history endpoint
(cherry picked from commit ba4ae79bfe5db94dfabe91baf589c7434626dba2)
---
lms/djangoapps/courseware/access_utils.py | 20 +-
lms/djangoapps/support/tests/test_views.py | 2 +-
lms/envs/test.py | 14 +
.../content/block_structure/store.py | 14 +-
.../djangoapps/enrollments/serializers.py | 43 +-
...ourse-enrollments-api-list-valid-data.json | 512 +++++++++++++++++-
.../enrollments/tests/test_views.py | 24 +-
openedx/core/djangoapps/enrollments/urls.py | 2 +
openedx/core/djangoapps/enrollments/views.py | 208 ++++++-
9 files changed, 774 insertions(+), 65 deletions(-)
diff --git a/lms/djangoapps/courseware/access_utils.py b/lms/djangoapps/courseware/access_utils.py
index 860f2810452e..e855a69885ec 100644
--- a/lms/djangoapps/courseware/access_utils.py
+++ b/lms/djangoapps/courseware/access_utils.py
@@ -9,23 +9,21 @@
from django.conf import settings
from pytz import UTC
+from xmodule.course_module import COURSE_VISIBILITY_PUBLIC
+from xmodule.util.xmodule_django import get_current_request_hostname
+
+from common.djangoapps.student.models import CourseEnrollment
+from common.djangoapps.student.roles import CourseBetaTesterRole
from lms.djangoapps.courseware.access_response import (
AccessResponse,
- StartDateError,
- EnrollmentRequiredAccessError,
AuthenticationRequiredAccessError,
+ EnrollmentRequiredAccessError,
+ StartDateError
)
from lms.djangoapps.courseware.masquerade import get_course_masquerade, is_masquerading_as_student
from openedx.core.djangoapps.util.user_messages import PageLevelMessages # lint-amnesty, pylint: disable=unused-import
from openedx.core.djangolib.markup import HTML # lint-amnesty, pylint: disable=unused-import
-from openedx.features.course_experience import (
- COURSE_PRE_START_ACCESS_FLAG,
- COURSE_ENABLE_UNENROLLED_ACCESS_FLAG,
-)
-from common.djangoapps.student.models import CourseEnrollment
-from common.djangoapps.student.roles import CourseBetaTesterRole
-from xmodule.util.xmodule_django import get_current_request_hostname
-from xmodule.course_module import COURSE_VISIBILITY_PUBLIC
+from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, COURSE_PRE_START_ACCESS_FLAG
DEBUG_ACCESS = False
log = getLogger(__name__)
@@ -75,7 +73,7 @@ def check_start_date(user, days_early_for_beta, start, course_key, display_error
Returns:
AccessResponse: Either ACCESS_GRANTED or StartDateError.
"""
- start_dates_disabled = settings.FEATURES['DISABLE_START_DATES']
+ start_dates_disabled = settings.FEATURES.get('DISABLE_START_DATES', False)
masquerading_as_student = is_masquerading_as_student(user, course_key)
if start_dates_disabled and not masquerading_as_student:
diff --git a/lms/djangoapps/support/tests/test_views.py b/lms/djangoapps/support/tests/test_views.py
index 7b5acda2717d..48dce9ed1e6d 100644
--- a/lms/djangoapps/support/tests/test_views.py
+++ b/lms/djangoapps/support/tests/test_views.py
@@ -473,7 +473,7 @@ def test_change_enrollment_mode_fullfills_entitlement(self, search_string_type,
'course_id': str(self.course.id),
'old_mode': CourseMode.AUDIT,
'new_mode': CourseMode.VERIFIED,
- 'reason': 'Financial Assistance'
+ 'reason': u'Financial Assistance'
}, content_type='application/json')
entitlement.refresh_from_db()
assert response.status_code == 200
diff --git a/lms/envs/test.py b/lms/envs/test.py
index 69b290fc53e1..69c30a966493 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -603,3 +603,17 @@
#################### Network configuration ####################
# Tests are not behind any proxies
CLOSEST_CLIENT_IP_FROM_HEADERS = []
+
+COURSE_ENROLLMENT_MODES['test'] = {
+ "id": 8,
+ "slug": u"test",
+ "display_name": u"Test",
+ "min_price": 0
+}
+
+COURSE_ENROLLMENT_MODES['test_mode'] = {
+ "id": 9,
+ "slug": u"test_mode",
+ "display_name": u"Test Mode",
+ "min_price": 0
+}
diff --git a/openedx/core/djangoapps/content/block_structure/store.py b/openedx/core/djangoapps/content/block_structure/store.py
index 2342079bdc6e..13688fc85e43 100644
--- a/openedx/core/djangoapps/content/block_structure/store.py
+++ b/openedx/core/djangoapps/content/block_structure/store.py
@@ -228,12 +228,14 @@ def _encode_root_cache_key(bs_model):
Returns the cache key to use for the given
BlockStructureModel or StubModel.
"""
- if config.STORAGE_BACKING_FOR_CACHE.is_enabled():
- return str(bs_model)
- return "v{version}.root.key.{root_usage_key}".format(
- version=str(BlockStructureBlockData.VERSION),
- root_usage_key=str(bs_model.data_usage_key),
- )
+ if _is_storage_backing_enabled():
+ return six.text_type(bs_model)
+
+ else:
+ return u"v{version}.root.key.{root_usage_key}".format(
+ version=six.text_type(BlockStructureBlockData.VERSION),
+ root_usage_key=six.text_type(bs_model.data_usage_key),
+ )
@staticmethod
def _version_data_of_block(root_block):
diff --git a/openedx/core/djangoapps/enrollments/serializers.py b/openedx/core/djangoapps/enrollments/serializers.py
index 9fde7c04033a..a59cf98b988a 100644
--- a/openedx/core/djangoapps/enrollments/serializers.py
+++ b/openedx/core/djangoapps/enrollments/serializers.py
@@ -5,10 +5,13 @@
import logging
+from django.core.exceptions import PermissionDenied
from rest_framework import serializers
+from xmodule.modulestore.django import modulestore
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.student.models import CourseEnrollment
+from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
log = logging.getLogger(__name__)
@@ -83,15 +86,49 @@ class CourseEnrollmentSerializer(serializers.ModelSerializer):
"""
course_details = CourseSerializer(source="course_overview")
- user = serializers.SerializerMethodField('get_username')
+ user = serializers.SerializerMethodField("get_username")
+ finished = serializers.SerializerMethodField()
+ grading = serializers.SerializerMethodField()
def get_username(self, model):
"""Retrieves the username from the associated model."""
return model.username
- class Meta:
+ def get_finished(self, model):
+ """Retrieve finished course."""
+ course = modulestore().get_course(model.course_id)
+ if course:
+ try:
+ coursegrade = CourseGradeFactory().read(model.user, course).passed
+ except PermissionDenied:
+ return False
+ return coursegrade
+ return False
+
+ def get_grading(self, model):
+ """Retrieve course grade."""
+ course = modulestore().get_course(model.course_id)
+ course_grade = None
+ summary = []
+ current_grade = 0
+ if course:
+ try:
+ course_grade = CourseGradeFactory().read(model.user, course)
+ current_grade = int(course_grade.percent * 100)
+ for section in course_grade.summary.get(u'section_breakdown'):
+ if section.get(u'prominent'):
+ summary.append(section)
+ except PermissionDenied:
+ pass
+ return [
+ {u'current_grade': current_grade,
+ u'certificate_eligible': course_grade.passed if course_grade else False,
+ u'summary': summary}
+ ]
+
+ class Meta(object):
model = CourseEnrollment
- fields = ('created', 'mode', 'is_active', 'course_details', 'user')
+ fields = ('created', 'mode', 'is_active', 'course_details', 'user', 'finished', 'grading')
lookup_field = 'username'
diff --git a/openedx/core/djangoapps/enrollments/tests/fixtures/course-enrollments-api-list-valid-data.json b/openedx/core/djangoapps/enrollments/tests/fixtures/course-enrollments-api-list-valid-data.json
index e9fd2f55eca7..60324c0f1555 100644
--- a/openedx/core/djangoapps/enrollments/tests/fixtures/course-enrollments-api-list-valid-data.json
+++ b/openedx/core/djangoapps/enrollments/tests/fixtures/course-enrollments-api-list-valid-data.json
@@ -9,14 +9,74 @@
"is_active": true,
"mode": "honor",
"user": "student1",
- "created": "2018-01-01T00:00:01Z"
+ "created": "2018-01-01T00:00:01Z",
+ "finished": false,
+ "grading": [{
+ "certificate_eligible": false,
+ "current_grade": 0,
+ "summary": [{
+ "category": "Homework",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Homework Average = 0%",
+ "label": "HW Avg"
+ },{
+ "category": "Lab",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Lab Average = 0%",
+ "label": "Lab Avg"
+ },{
+ "category": "Midterm Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Midterm Exam = 0%",
+ "label": "Midterm"
+ },{
+ "category": "Final Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Final Exam = 0%",
+ "label": "Final"
+ }]
+ }]
},
{
"course_id": "e/d/X",
"is_active": true,
"mode": "honor",
"user": "student2",
- "created": "2018-01-01T00:00:01Z"
+ "created": "2018-01-01T00:00:01Z",
+ "finished": false,
+ "grading": [{
+ "certificate_eligible": false,
+ "current_grade": 0,
+ "summary": [{
+ "category": "Homework",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Homework Average = 0%",
+ "label": "HW Avg"
+ },{
+ "category": "Lab",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Lab Average = 0%",
+ "label": "Lab Avg"
+ },{
+ "category": "Midterm Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Midterm Exam = 0%",
+ "label": "Midterm"
+ },{
+ "category": "Final Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Final Exam = 0%",
+ "label": "Final"
+ }]
+ }]
}
]
],
@@ -30,21 +90,111 @@
"is_active": true,
"mode": "verified",
"user": "staff",
- "created": "2018-01-01T00:00:01Z"
+ "created": "2018-01-01T00:00:01Z",
+ "finished": false,
+ "grading": [{
+ "certificate_eligible": false,
+ "current_grade": 0,
+ "summary": [{
+ "category": "Homework",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Homework Average = 0%",
+ "label": "HW Avg"
+ },{
+ "category": "Lab",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Lab Average = 0%",
+ "label": "Lab Avg"
+ },{
+ "category": "Midterm Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Midterm Exam = 0%",
+ "label": "Midterm"
+ },{
+ "category": "Final Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Final Exam = 0%",
+ "label": "Final"
+ }]
+ }]
},
{
"course_id": "x/y/Z",
"is_active": true,
"mode": "honor",
"user": "student2",
- "created": "2018-01-01T00:00:01Z"
+ "created": "2018-01-01T00:00:01Z",
+ "finished": false,
+ "grading": [{
+ "certificate_eligible": false,
+ "current_grade": 0,
+ "summary": [{
+ "category": "Homework",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Homework Average = 0%",
+ "label": "HW Avg"
+ },{
+ "category": "Lab",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Lab Average = 0%",
+ "label": "Lab Avg"
+ },{
+ "category": "Midterm Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Midterm Exam = 0%",
+ "label": "Midterm"
+ },{
+ "category": "Final Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Final Exam = 0%",
+ "label": "Final"
+ }]
+ }]
},
{
"course_id": "x/y/Z",
"is_active": true,
"mode": "verified",
"user": "student3",
- "created": "2018-01-01T00:00:01Z"
+ "created": "2018-01-01T00:00:01Z",
+ "finished": false,
+ "grading": [{
+ "certificate_eligible": false,
+ "current_grade": 0,
+ "summary": [{
+ "category": "Homework",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Homework Average = 0%",
+ "label": "HW Avg"
+ },{
+ "category": "Lab",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Lab Average = 0%",
+ "label": "Lab Avg"
+ },{
+ "category": "Midterm Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Midterm Exam = 0%",
+ "label": "Midterm"
+ },{
+ "category": "Final Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Final Exam = 0%",
+ "label": "Final"
+ }]
+ }]
}
]
],
@@ -59,14 +209,74 @@
"is_active": true,
"mode": "honor",
"user": "student2",
- "created": "2018-01-01T00:00:01Z"
+ "created": "2018-01-01T00:00:01Z",
+ "finished": false,
+ "grading": [{
+ "certificate_eligible": false,
+ "current_grade": 0,
+ "summary": [{
+ "category": "Homework",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Homework Average = 0%",
+ "label": "HW Avg"
+ },{
+ "category": "Lab",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Lab Average = 0%",
+ "label": "Lab Avg"
+ },{
+ "category": "Midterm Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Midterm Exam = 0%",
+ "label": "Midterm"
+ },{
+ "category": "Final Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Final Exam = 0%",
+ "label": "Final"
+ }]
+ }]
},
{
"course_id": "x/y/Z",
"is_active": true,
"mode": "verified",
"user": "student3",
- "created": "2018-01-01T00:00:01Z"
+ "created": "2018-01-01T00:00:01Z",
+ "finished": false,
+ "grading": [{
+ "certificate_eligible": false,
+ "current_grade": 0,
+ "summary": [{
+ "category": "Homework",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Homework Average = 0%",
+ "label": "HW Avg"
+ },{
+ "category": "Lab",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Lab Average = 0%",
+ "label": "Lab Avg"
+ },{
+ "category": "Midterm Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Midterm Exam = 0%",
+ "label": "Midterm"
+ },{
+ "category": "Final Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Final Exam = 0%",
+ "label": "Final"
+ }]
+ }]
}
]
],
@@ -81,7 +291,37 @@
"is_active": true,
"mode": "honor",
"user": "student2",
- "created": "2018-01-01T00:00:01Z"
+ "created": "2018-01-01T00:00:01Z",
+ "finished": false,
+ "grading": [{
+ "certificate_eligible": false,
+ "current_grade": 0,
+ "summary": [{
+ "category": "Homework",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Homework Average = 0%",
+ "label": "HW Avg"
+ },{
+ "category": "Lab",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Lab Average = 0%",
+ "label": "Lab Avg"
+ },{
+ "category": "Midterm Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Midterm Exam = 0%",
+ "label": "Midterm"
+ },{
+ "category": "Final Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Final Exam = 0%",
+ "label": "Final"
+ }]
+ }]
}
]
],
@@ -95,21 +335,111 @@
"is_active": true,
"mode": "verified",
"user": "staff",
- "created": "2018-01-01T00:00:01Z"
+ "created": "2018-01-01T00:00:01Z",
+ "finished": false,
+ "grading": [{
+ "certificate_eligible": false,
+ "current_grade": 0,
+ "summary": [{
+ "category": "Homework",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Homework Average = 0%",
+ "label": "HW Avg"
+ },{
+ "category": "Lab",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Lab Average = 0%",
+ "label": "Lab Avg"
+ },{
+ "category": "Midterm Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Midterm Exam = 0%",
+ "label": "Midterm"
+ },{
+ "category": "Final Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Final Exam = 0%",
+ "label": "Final"
+ }]
+ }]
},
{
"course_id": "e/d/X",
"is_active": true,
"mode": "honor",
"user": "student2",
- "created": "2018-01-01T00:00:01Z"
+ "created": "2018-01-01T00:00:01Z",
+ "finished": false,
+ "grading": [{
+ "certificate_eligible": false,
+ "current_grade": 0,
+ "summary": [{
+ "category": "Homework",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Homework Average = 0%",
+ "label": "HW Avg"
+ },{
+ "category": "Lab",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Lab Average = 0%",
+ "label": "Lab Avg"
+ },{
+ "category": "Midterm Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Midterm Exam = 0%",
+ "label": "Midterm"
+ },{
+ "category": "Final Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Final Exam = 0%",
+ "label": "Final"
+ }]
+ }]
},
{
"course_id": "x/y/Z",
"is_active": true,
"mode": "honor",
"user": "student2",
- "created": "2018-01-01T00:00:01Z"
+ "created": "2018-01-01T00:00:01Z",
+ "finished": false,
+ "grading": [{
+ "certificate_eligible": false,
+ "current_grade": 0,
+ "summary": [{
+ "category": "Homework",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Homework Average = 0%",
+ "label": "HW Avg"
+ },{
+ "category": "Lab",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Lab Average = 0%",
+ "label": "Lab Avg"
+ },{
+ "category": "Midterm Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Midterm Exam = 0%",
+ "label": "Midterm"
+ },{
+ "category": "Final Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Final Exam = 0%",
+ "label": "Final"
+ }]
+ }]
}
]
@@ -122,35 +452,185 @@
"is_active": true,
"mode": "honor",
"user": "student1",
- "created": "2018-01-01T00:00:01Z"
+ "created": "2018-01-01T00:00:01Z",
+ "finished": false,
+ "grading": [{
+ "certificate_eligible": false,
+ "current_grade": 0,
+ "summary": [{
+ "category": "Homework",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Homework Average = 0%",
+ "label": "HW Avg"
+ },{
+ "category": "Lab",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Lab Average = 0%",
+ "label": "Lab Avg"
+ },{
+ "category": "Midterm Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Midterm Exam = 0%",
+ "label": "Midterm"
+ },{
+ "category": "Final Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Final Exam = 0%",
+ "label": "Final"
+ }]
+ }]
},
{
"course_id": "e/d/X",
"is_active": true,
"mode": "honor",
"user": "student2",
- "created": "2018-01-01T00:00:01Z"
+ "created": "2018-01-01T00:00:01Z",
+ "finished": false,
+ "grading": [{
+ "certificate_eligible": false,
+ "current_grade": 0,
+ "summary": [{
+ "category": "Homework",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Homework Average = 0%",
+ "label": "HW Avg"
+ },{
+ "category": "Lab",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Lab Average = 0%",
+ "label": "Lab Avg"
+ },{
+ "category": "Midterm Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Midterm Exam = 0%",
+ "label": "Midterm"
+ },{
+ "category": "Final Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Final Exam = 0%",
+ "label": "Final"
+ }]
+ }]
},
{
"course_id": "x/y/Z",
"is_active": true,
"mode": "verified",
"user": "student3",
- "created": "2018-01-01T00:00:01Z"
+ "created": "2018-01-01T00:00:01Z",
+ "finished": false,
+ "grading": [{
+ "certificate_eligible": false,
+ "current_grade": 0,
+ "summary": [{
+ "category": "Homework",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Homework Average = 0%",
+ "label": "HW Avg"
+ },{
+ "category": "Lab",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Lab Average = 0%",
+ "label": "Lab Avg"
+ },{
+ "category": "Midterm Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Midterm Exam = 0%",
+ "label": "Midterm"
+ },{
+ "category": "Final Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Final Exam = 0%",
+ "label": "Final"
+ }]
+ }]
},
{
"course_id": "x/y/Z",
"is_active": true,
"mode": "honor",
"user": "student2",
- "created": "2018-01-01T00:00:01Z"
+ "created": "2018-01-01T00:00:01Z",
+ "finished": false,
+ "grading": [{
+ "certificate_eligible": false,
+ "current_grade": 0,
+ "summary": [{
+ "category": "Homework",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Homework Average = 0%",
+ "label": "HW Avg"
+ },{
+ "category": "Lab",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Lab Average = 0%",
+ "label": "Lab Avg"
+ },{
+ "category": "Midterm Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Midterm Exam = 0%",
+ "label": "Midterm"
+ },{
+ "category": "Final Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Final Exam = 0%",
+ "label": "Final"
+ }]
+ }]
},
{
"course_id": "x/y/Z",
"is_active": true,
"mode": "verified",
"user": "staff",
- "created": "2018-01-01T00:00:01Z"
+ "created": "2018-01-01T00:00:01Z",
+ "finished": false,
+ "grading": [{
+ "certificate_eligible": false,
+ "current_grade": 0,
+ "summary": [{
+ "category": "Homework",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Homework Average = 0%",
+ "label": "HW Avg"
+ },{
+ "category": "Lab",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Lab Average = 0%",
+ "label": "Lab Avg"
+ },{
+ "category": "Midterm Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Midterm Exam = 0%",
+ "label": "Midterm"
+ },{
+ "category": "Final Exam",
+ "prominent": true,
+ "percent": 0.0,
+ "detail": "Final Exam = 0%",
+ "label": "Final"
+ }]
+ }]
}
]
]
diff --git a/openedx/core/djangoapps/enrollments/tests/test_views.py b/openedx/core/djangoapps/enrollments/tests/test_views.py
index a55f2a776058..88e2d6401d8c 100644
--- a/openedx/core/djangoapps/enrollments/tests/test_views.py
+++ b/openedx/core/djangoapps/enrollments/tests/test_views.py
@@ -9,9 +9,10 @@
import json
import unittest
from unittest.mock import patch
-import pytest
+
import ddt
import httpretty
+import pytest
import pytz
from django.conf import settings
from django.core.cache import cache
@@ -23,9 +24,16 @@
from freezegun import freeze_time
from rest_framework import status
from rest_framework.test import APITestCase
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls_range
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
+from common.djangoapps.student.models import CourseEnrollment
+from common.djangoapps.student.roles import CourseStaffRole
+from common.djangoapps.student.tests.factories import AdminFactory, SuperuserFactory, UserFactory
+from common.djangoapps.util.models import RateLimitConfiguration
+from common.djangoapps.util.testing import UrlResetMixin
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.course_groups import cohorts
from openedx.core.djangoapps.embargo.models import Country, CountryAccessRule, RestrictedCourse
@@ -38,13 +46,6 @@
from openedx.core.lib.django_test_client_utils import get_absolute_url
from openedx.features.enterprise_support.tests import FAKE_ENTERPRISE_CUSTOMER
from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseServiceMockMixin
-from common.djangoapps.student.models import CourseEnrollment
-from common.djangoapps.student.roles import CourseStaffRole
-from common.djangoapps.student.tests.factories import AdminFactory, SuperuserFactory, UserFactory
-from common.djangoapps.util.models import RateLimitConfiguration
-from common.djangoapps.util.testing import UrlResetMixin
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
-from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls_range
class EnrollmentTestMixin:
@@ -62,7 +63,7 @@ def assert_enrollment_status(
is_active=None,
enrollment_attributes=None,
min_mongo_calls=0,
- max_mongo_calls=0,
+ max_mongo_calls=8,
linked_enterprise_customer=None,
cohort=None,
):
@@ -378,10 +379,7 @@ def test_enrollment_list_permissions(self):
mode_slug=CourseMode.DEFAULT_MODE_SLUG,
mode_display_name=CourseMode.DEFAULT_MODE_SLUG,
)
- self.assert_enrollment_status(
- course_id=str(course.id),
- max_mongo_calls=0,
- )
+ self.assert_enrollment_status(course_id=six.text_type(course.id))
# Verify the user himself can see both of his enrollments.
self._assert_enrollments_visible_in_list([self.course, other_course])
# Verify that self.other_user can't see any of the enrollments.
diff --git a/openedx/core/djangoapps/enrollments/urls.py b/openedx/core/djangoapps/enrollments/urls.py
index f50221bae005..e45f5a5cb851 100644
--- a/openedx/core/djangoapps/enrollments/urls.py
+++ b/openedx/core/djangoapps/enrollments/urls.py
@@ -13,6 +13,7 @@
EnrollmentListView,
EnrollmentUserRolesView,
EnrollmentView,
+ SubmissionHistoryView,
UnenrollmentView
)
@@ -29,4 +30,5 @@
EnrollmentCourseDetailView.as_view(), name='courseenrollmentdetails'),
url(r'^unenroll/$', UnenrollmentView.as_view(), name='unenrollment'),
url(r'^roles/$', EnrollmentUserRolesView.as_view(), name='roles'),
+ url(r'^submission_history$', SubmissionHistoryView.as_view(), name='submissionhistory'),
]
diff --git a/openedx/core/djangoapps/enrollments/views.py b/openedx/core/djangoapps/enrollments/views.py
index e1616e23e2a5..f0e48e2ef903 100644
--- a/openedx/core/djangoapps/enrollments/views.py
+++ b/openedx/core/djangoapps/enrollments/views.py
@@ -5,22 +5,45 @@
"""
+import json
import logging
-from common.djangoapps.course_modes.models import CourseMode
-from django.core.exceptions import ObjectDoesNotExist, ValidationError # lint-amnesty, pylint: disable=wrong-import-order
+from course_modes.models import CourseMode
+from django.core.exceptions import ( # lint-amnesty, pylint: disable=wrong-import-order
+ ObjectDoesNotExist,
+ ValidationError
+)
from django.utils.decorators import method_decorator # lint-amnesty, pylint: disable=wrong-import-order
-from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication # lint-amnesty, pylint: disable=wrong-import-order
-from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser # lint-amnesty, pylint: disable=wrong-import-order
+from edx_rest_framework_extensions.auth.jwt.authentication import \
+ JwtAuthentication # lint-amnesty, pylint: disable=wrong-import-order
+from edx_rest_framework_extensions.auth.session.authentication import \
+ SessionAuthenticationAllowInactiveUser # lint-amnesty, pylint: disable=wrong-import-order
from opaque_keys import InvalidKeyError # lint-amnesty, pylint: disable=wrong-import-order
from opaque_keys.edx.keys import CourseKey # lint-amnesty, pylint: disable=wrong-import-order
+from opaque_keys.edx.locator import CourseLocator
+from rest_framework import permissions, status # lint-amnesty, pylint: disable=wrong-import-order
+from rest_framework.generics import ListAPIView # lint-amnesty, pylint: disable=wrong-import-order
+from rest_framework.response import Response # lint-amnesty, pylint: disable=wrong-import-order
+from rest_framework.throttling import UserRateThrottle # lint-amnesty, pylint: disable=wrong-import-order
+from rest_framework.views import APIView # lint-amnesty, pylint: disable=wrong-import-order
+from six import text_type
+
+from common.djangoapps.course_modes.models import CourseMode
+from common.djangoapps.student.auth import user_has_role
+from common.djangoapps.student.models import CourseEnrollment, User
+from common.djangoapps.student.roles import CourseStaffRole, GlobalStaff
+from common.djangoapps.util.disable_rate_limit import can_disable_rate_limit
+from lms.djangoapps.courseware.courses import get_course
+from lms.djangoapps.courseware.models import BaseStudentModuleHistory, StudentModule
from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
from openedx.core.djangoapps.cors_csrf.decorators import ensure_csrf_cookie_cross_domain
from openedx.core.djangoapps.course_groups.cohorts import CourseUserGroup, add_user_to_cohort, get_cohort_by_name
from openedx.core.djangoapps.embargo import api as embargo_api
from openedx.core.djangoapps.enrollments import api
from openedx.core.djangoapps.enrollments.errors import (
- CourseEnrollmentError, CourseEnrollmentExistsError, CourseModeNotFoundError,
+ CourseEnrollmentError,
+ CourseEnrollmentExistsError,
+ CourseModeNotFoundError
)
from openedx.core.djangoapps.enrollments.forms import CourseEnrollmentsApiListForm
from openedx.core.djangoapps.enrollments.paginators import CourseEnrollmentsApiListPagination
@@ -28,7 +51,10 @@
from openedx.core.djangoapps.user_api.accounts.permissions import CanRetireUser
from openedx.core.djangoapps.user_api.models import UserRetirementStatus
from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in
-from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
+from openedx.core.lib.api.authentication import (
+ BearerAuthenticationAllowInactiveUser,
+ OAuth2AuthenticationAllowInactiveUser
+)
from openedx.core.lib.api.permissions import ApiKeyHeaderPermission, ApiKeyHeaderPermissionIsAuthenticated
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin
from openedx.core.lib.exceptions import CourseNotFoundError
@@ -39,15 +65,6 @@
EnterpriseApiServiceClient,
enterprise_enabled
)
-from rest_framework import permissions, status # lint-amnesty, pylint: disable=wrong-import-order
-from rest_framework.generics import ListAPIView # lint-amnesty, pylint: disable=wrong-import-order
-from rest_framework.response import Response # lint-amnesty, pylint: disable=wrong-import-order
-from rest_framework.throttling import UserRateThrottle # lint-amnesty, pylint: disable=wrong-import-order
-from rest_framework.views import APIView # lint-amnesty, pylint: disable=wrong-import-order
-from common.djangoapps.student.auth import user_has_role
-from common.djangoapps.student.models import CourseEnrollment, User
-from common.djangoapps.student.roles import CourseStaffRole, GlobalStaff
-from common.djangoapps.util.disable_rate_limit import can_disable_rate_limit
log = logging.getLogger(__name__)
REQUIRED_ATTRIBUTES = {
@@ -965,3 +982,164 @@ def get_queryset(self):
if usernames:
queryset = queryset.filter(user__username__in=usernames)
return queryset
+
+
+@can_disable_rate_limit
+class SubmissionHistoryView(APIView, ApiKeyPermissionMixIn):
+ """
+ Submission history view.
+ """
+ authentication_classes = (OAuth2AuthenticationAllowInactiveUser, EnrollmentCrossDomainSessionAuth)
+ permission_classes = (ApiKeyHeaderPermissionIsAuthenticated, )
+
+ def get(self, request):
+ """
+ Get submission history details.
+
+ **Usecases**:
+
+ Regular users can only retrieve their own submission history and users with GlobalStaff status
+ can retrieve everyone's submission history.
+
+ **Example Requests**:
+
+ GET /api/enrollment/v1/submission_history?course_id=course_id
+ GET /api/enrollment/v1/submission_history?course_id=course_id&user=username
+ GET /api/enrollment/v1/submission_history?course_id=course_id&all_users=true
+
+ **Query Parameters for GET**
+
+ * course_id: Course id to retrieve submission history.
+ * username: Single username for which this view will retrieve the submission history details.
+ If no username specified the requester's username will be used.
+ * all_users: If true and if the requester has the correct permissions,
+ retrieve history submission from every user in a course id.
+
+ **Response Values**:
+
+ If there's an error while getting the submission history an empty response will
+ be returned.
+ The submission history response has the following attributes:
+
+ * Results: A list of submission history:
+ * course_id: Course id
+ * course_name: Course name
+ * user: Username
+ * problems: List of problems
+ * location: problem location
+ * name: problem's display name
+ * submission_history: List of submission history
+ * state: State of submission.
+ * grade: Grade.
+ * max_grade: Maximum possible grade.
+ * data: problem's data.
+ """
+ username = request.GET.get('username', request.user.username)
+ data = []
+ if GlobalStaff().has_user(request.user):
+ all_users = bool(request.GET.get('all', False))
+ else:
+ all_users = False
+ course_id = request.GET.get('course_id')
+
+ if not (all_users or username == request.user.username or GlobalStaff().has_user(request.user) or
+ self.has_api_key_permissions(request)):
+ return Response(data)
+
+ course_enrollments = CourseEnrollment.objects.select_related('user').filter(is_active=True)
+ if course_id:
+ if not course_id.startswith("course-v1:"):
+ course_id = "course-v1:{}".format(course_id)
+ try:
+ course_enrollments = course_enrollments.filter(
+ course_id=CourseLocator.from_string(course_id.replace(' ', '+'))
+ ).order_by('created')
+ except KeyError:
+ return Response(data)
+
+ if not all_users:
+ course_enrollments = course_enrollments.filter(user__username=username).order_by('created')
+
+ courses = {}
+ for course_enrollment in course_enrollments:
+ try:
+ course_list = courses.get(course_enrollment.course_id)
+ if course_list:
+ course, course_children = course_list
+ else:
+ course = get_course(course_enrollment.course_id, depth=4)
+ course_children = course.get_children()
+ courses[course_enrollment.course_id] = [course, course_children]
+ except ValueError:
+ continue
+ course_data = self._get_course_data(course_enrollment, course, course_children)
+ data.append(course_data)
+
+ return Response({'results': data})
+
+ def _get_problem_data(self, course_enrollment, component):
+ """
+ Get problem data from a course enrollment.
+
+ Args:
+ -----
+ course_enrollment: Course Enrollment.
+ component: Component to analyze.
+ """
+ problem_data = {
+ 'location': str(component.location),
+ 'name': component.display_name,
+ 'submission_history': [],
+ 'data': component.data
+ }
+
+ csm = StudentModule.objects.filter(
+ module_state_key=component.location,
+ student__username=course_enrollment.user.username,
+ course_id=course_enrollment.course_id)
+
+ scores = BaseStudentModuleHistory.get_history(csm)
+ for i, score in enumerate(scores):
+ if i % 2 == 1:
+ continue
+
+ state = score.state
+ if state is not None:
+ state = json.loads(state)
+
+ history_data = {
+ 'state': state,
+ 'grade': score.grade,
+ 'max_grade': score.max_grade
+ }
+ problem_data['submission_history'].append(history_data)
+
+ return problem_data
+
+ def _get_course_data(self, course_enrollment, course, course_children):
+ """
+ Get course data.
+
+ Params:
+ --------
+
+ course_enrollment (CourseEnrollment): course enrollment
+ course: course
+ course_children: course children
+ """
+
+ course_data = {
+ 'course_id': str(course_enrollment.course_id),
+ 'course_name': course.display_name_with_default,
+ 'user': course_enrollment.user.username,
+ 'problems': []
+ }
+ for section in course_children:
+ for subsection in section.get_children():
+ for vertical in subsection.get_children():
+ for component in vertical.get_children():
+ if component.location.category == 'problem' and getattr(component, 'has_score', False):
+ problem_data = self._get_problem_data(course_enrollment, component)
+ course_data['problems'].append(problem_data)
+
+ return course_data
From 5e1453196f0cb1eec897f534a7fbd0014cf372ef Mon Sep 17 00:00:00 2001
From: Matjaz Gregoric
Date: Mon, 23 Aug 2021 16:38:01 +0200
Subject: [PATCH 02/20] fix: monkey-patch django db introspection to avoid
performance issues
(cherry picked from commit a26bb857fd7b03c797534d93fe2bf26b9b8698d2)
---
cms/__init__.py | 26 ++++++++++++++++++++++++++
lms/__init__.py | 26 ++++++++++++++++++++++++++
2 files changed, 52 insertions(+)
diff --git a/cms/__init__.py b/cms/__init__.py
index f9ed0bb3cea1..ff0885c93292 100644
--- a/cms/__init__.py
+++ b/cms/__init__.py
@@ -22,3 +22,29 @@
# that shared_task will use this app, and also ensures that the celery
# singleton is always configured for the CMS.
from .celery import APP as CELERY_APP # lint-amnesty, pylint: disable=wrong-import-position
+
+# FAL-2248: Monkey patch django's get_storage_engine to work around long migrations times.
+# This fixes a performance issue with database migrations in Ocim. We will need to keep
+# this patch in our opencraft-release/* branches until edx-platform upgrades to Django 4.*
+# which will include this commit:
+# https://github.com/django/django/commit/518ce7a51f994fc0585d31c4553e2072bf816f76
+import django.db.backends.mysql.introspection
+
+def get_storage_engine(self, cursor, table_name):
+ """
+ This is a patched version of `get_storage_engine` that fixes a
+ performance issue with migrations. For more info see FAL-2248 and
+ https://github.com/django/django/pull/14766
+ """
+ cursor.execute("""
+ SELECT engine
+ FROM information_schema.tables
+ WHERE table_name = %s
+ AND table_schema = DATABASE()""", [table_name])
+ result = cursor.fetchone()
+ if not result:
+ return self.connection.features._mysql_storage_engine # pylint: disable=protected-access
+ return result[0]
+
+
+django.db.backends.mysql.introspection.DatabaseIntrospection.get_storage_engine = get_storage_engine
diff --git a/lms/__init__.py b/lms/__init__.py
index 008640ac7147..3444b737f50f 100644
--- a/lms/__init__.py
+++ b/lms/__init__.py
@@ -18,3 +18,29 @@
# that shared_task will use this app, and also ensures that the celery
# singleton is always configured for the LMS.
from .celery import APP as CELERY_APP # lint-amnesty, pylint: disable=wrong-import-position
+
+# FAL-2248: Monkey patch django's get_storage_engine to work around long migrations times.
+# This fixes a performance issue with database migrations in Ocim. We will need to keep
+# this patch in our opencraft-release/* branches until edx-platform upgrades to Django 4.*
+# which will include this commit:
+# https://github.com/django/django/commit/518ce7a51f994fc0585d31c4553e2072bf816f76
+import django.db.backends.mysql.introspection
+
+def get_storage_engine(self, cursor, table_name):
+ """
+ This is a patched version of `get_storage_engine` that fixes a
+ performance issue with migrations. For more info see FAL-2248 and
+ https://github.com/django/django/pull/14766
+ """
+ cursor.execute("""
+ SELECT engine
+ FROM information_schema.tables
+ WHERE table_name = %s
+ AND table_schema = DATABASE()""", [table_name])
+ result = cursor.fetchone()
+ if not result:
+ return self.connection.features._mysql_storage_engine # pylint: disable=protected-access
+ return result[0]
+
+
+django.db.backends.mysql.introspection.DatabaseIntrospection.get_storage_engine = get_storage_engine
From f5a95b0d3df0e0a203ef3b7dd03933e25006dab6 Mon Sep 17 00:00:00 2001
From: ha-D
Date: Thu, 26 Aug 2021 23:13:34 +0000
Subject: [PATCH 03/20] fixup! feat: options for excluding courses from search
---
cms/djangoapps/contentstore/courseware_index.py | 2 ++
lms/envs/common.py | 17 ++++++++++++++++-
lms/envs/production.py | 9 +++++++++
.../courseware_search/lms_filter_generator.py | 6 ++++++
4 files changed, 33 insertions(+), 1 deletion(-)
diff --git a/cms/djangoapps/contentstore/courseware_index.py b/cms/djangoapps/contentstore/courseware_index.py
index 5d9b627ec02f..1878e04b3b01 100644
--- a/cms/djangoapps/contentstore/courseware_index.py
+++ b/cms/djangoapps/contentstore/courseware_index.py
@@ -587,6 +587,8 @@ class CourseAboutSearchIndexer(CoursewareSearchIndexer):
AboutInfo("org", AboutInfo.PROPERTY, AboutInfo.FROM_COURSE_PROPERTY),
AboutInfo("modes", AboutInfo.PROPERTY, AboutInfo.FROM_COURSE_MODE),
AboutInfo("language", AboutInfo.PROPERTY, AboutInfo.FROM_COURSE_PROPERTY),
+ AboutInfo("invitation_only", AboutInfo.PROPERTY, AboutInfo.FROM_COURSE_PROPERTY),
+ AboutInfo("catalog_visibility", AboutInfo.PROPERTY, AboutInfo.FROM_COURSE_PROPERTY),
]
@classmethod
diff --git a/lms/envs/common.py b/lms/envs/common.py
index bfd5df27bddd..5bfb4ba5e192 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -817,7 +817,7 @@
# .. toggle_tickets: https://openedx.atlassian.net/browse/OSPR-1880
'ENABLE_HTML_XBLOCK_STUDENT_VIEW_DATA': False,
- # .. toggle_name: FEATURES['ENABLE_CHANGE_USER_PASSWORD_ADMIN']
+ # .. toggle_name: FEATURES['ENABLE_PASSWORD_RESET_FAILURE_EMAIL']
# .. toggle_implementation: DjangoSetting
# .. toggle_default: False
# .. toggle_description: Whether to send an email for failed password reset attempts or not. This happens when a
@@ -3994,6 +3994,21 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
SEARCH_FILTER_GENERATOR = "lms.lib.courseware_search.lms_filter_generator.LmsSearchFilterGenerator"
# Override to skip enrollment start date filtering in course search
SEARCH_SKIP_ENROLLMENT_START_DATE_FILTERING = False
+# .. toggle_name: SEARCH_SKIP_INVITATION_ONLY_FILTERING
+# .. toggle_implementation: DjangoSetting
+# .. toggle_default: True
+# .. toggle_description: If enabled, invitation-only courses will appear in search results.
+# .. toggle_use_cases: open_edx
+# .. toggle_creation_date: 2021-08-27
+SEARCH_SKIP_INVITATION_ONLY_FILTERING = True
+# .. toggle_name: SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING
+# .. toggle_implementation: DjangoSetting
+# .. toggle_default: True
+# .. toggle_description: If enabled, courses with a catalog_visibility set to "none" will still
+# appear in search results.
+# .. toggle_use_cases: open_edx
+# .. toggle_creation_date: 2021-08-27
+SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING = True
# The configuration visibility of account fields.
ACCOUNT_VISIBILITY_CONFIGURATION = {
diff --git a/lms/envs/production.py b/lms/envs/production.py
index d517908c0608..89cb7f2b4196 100644
--- a/lms/envs/production.py
+++ b/lms/envs/production.py
@@ -729,6 +729,15 @@ def get_env_setting(setting):
SEARCH_ENGINE = "search.elastic.ElasticSearchEngine"
SEARCH_FILTER_GENERATOR = ENV_TOKENS.get('SEARCH_FILTER_GENERATOR', SEARCH_FILTER_GENERATOR)
+SEARCH_SKIP_INVITATION_ONLY_FILTERING = ENV_TOKENS.get(
+ 'SEARCH_SKIP_INVITATION_ONLY_FILTERING',
+ SEARCH_SKIP_INVITATION_ONLY_FILTERING,
+)
+SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING = ENV_TOKENS.get(
+ 'SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING',
+ SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING,
+)
+
# TODO: Once we have successfully upgraded to ES7, switch this back to ELASTIC_SEARCH_CONFIG.
ELASTIC_SEARCH_CONFIG = ENV_TOKENS.get('ELASTIC_SEARCH_CONFIG_ES7', [{}])
diff --git a/lms/lib/courseware_search/lms_filter_generator.py b/lms/lib/courseware_search/lms_filter_generator.py
index 14b539b4291e..c4e5ab7ac736 100644
--- a/lms/lib/courseware_search/lms_filter_generator.py
+++ b/lms/lib/courseware_search/lms_filter_generator.py
@@ -2,6 +2,7 @@
This file contains implementation override of SearchFilterGenerator which will allow
* Filter by all courses in which the user is enrolled in
"""
+from django.conf import settings
from search.filter_generator import SearchFilterGenerator
from openedx.core.djangoapps.course_groups.partition_scheme import CohortPartitionScheme
@@ -52,4 +53,9 @@ def exclude_dictionary(self, **kwargs):
if org_filter_out_set:
exclude_dictionary['org'] = list(org_filter_out_set)
+ if not getattr(settings, "SEARCH_SKIP_INVITATION_ONLY_FILTERING", True):
+ exclude_dictionary['invitation_only'] = True
+ if not getattr(settings, "SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING", True):
+ exclude_dictionary['catalog_visibility'] = 'none'
+
return exclude_dictionary
From ae9326d0118564763eaa742bb3c6386814bc1a2b Mon Sep 17 00:00:00 2001
From: Kaustav Banerjee
Date: Thu, 23 Dec 2021 07:18:58 +0530
Subject: [PATCH 04/20] feat: change studio schedule datetime inputs to user
timezone
---
cms/djangoapps/contentstore/views/course.py | 7 +++
cms/static/cms/js/spec/main.js | 1 +
cms/static/js/utils/date_utils.js | 61 ++++++++++++++++++++-
3 files changed, 67 insertions(+), 2 deletions(-)
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index ed8e09f8c886..f559a0b854c1 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -67,6 +67,7 @@
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.djangoapps.user_api.models import UserPreference
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
@@ -1215,6 +1216,12 @@ def settings_handler(request, course_key_string): # lint-amnesty, pylint: disab
elif 'application/json' in request.META.get('HTTP_ACCEPT', ''):
if request.method == 'GET':
course_details = CourseDetails.fetch(course_key)
+
+ # Fetch the prefered timezone setup by the user
+ # and pass it as part of Json response
+ user_timezone = UserPreference.get_value(request.user, 'time_zone')
+ course_details.user_timezone = user_timezone
+
return JsonResponse(
course_details,
# encoder serializes dates, old locations, and instances
diff --git a/cms/static/cms/js/spec/main.js b/cms/static/cms/js/spec/main.js
index f5aa089b0438..384eb7b83350 100644
--- a/cms/static/cms/js/spec/main.js
+++ b/cms/static/cms/js/spec/main.js
@@ -47,6 +47,7 @@
'jquery.simulate': 'xmodule_js/common_static/js/vendor/jquery.simulate',
'datepair': 'xmodule_js/common_static/js/vendor/timepicker/datepair',
'date': 'xmodule_js/common_static/js/vendor/date',
+ 'moment-timezone': 'common/js/vendor/moment-timezone-with-data',
moment: 'common/js/vendor/moment-with-locales',
'text': 'xmodule_js/common_static/js/vendor/requirejs/text',
'underscore': 'common/js/vendor/underscore',
diff --git a/cms/static/js/utils/date_utils.js b/cms/static/js/utils/date_utils.js
index 0c91e6347e72..26e2c52e86cf 100644
--- a/cms/static/js/utils/date_utils.js
+++ b/cms/static/js/utils/date_utils.js
@@ -1,5 +1,5 @@
-define(['jquery', 'date', 'js/utils/change_on_enter', 'jquery.ui', 'jquery.timepicker'],
-function($, date, TriggerChangeEventOnEnter) {
+define(['jquery', 'date', 'js/utils/change_on_enter', 'moment-timezone', 'jquery.ui', 'jquery.timepicker'],
+function($, date, TriggerChangeEventOnEnter, moment) {
'use strict';
function getDate(datepickerInput, timepickerInput) {
@@ -67,14 +67,54 @@ function($, date, TriggerChangeEventOnEnter) {
return obj;
}
+ /**
+ * Calculates the utc offset in miliseconds for given
+ * timezone and subtracts it from given localized time
+ * to get time in UTC
+ *
+ * @param {Date} localTime JS Date object in Local Time
+ * @param {string} timezone IANA timezone name ex. "Australia/Brisbane"
+ * @returns JS Date object in UTC
+ */
+ function convertLocalizedDateToUTC(localTime, timezone) {
+ const localTimeMS = localTime.getTime();
+ const utcOffset = moment.tz(localTime, timezone)._offset;
+ return new Date(localTimeMS - (utcOffset * 60 *1000));
+ }
+
+ /**
+ * Returns the timezone abbreviation for given
+ * timezone name
+ *
+ * @param {string} timezone IANA timezone name ex. "Australia/Brisbane"
+ * @returns Timezone abbreviation ex. "AEST"
+ */
+ function getTZAbbreviation(timezone) {
+ return moment(new Date()).tz(timezone).format('z');
+ }
+
+ /**
+ * Converts the given datetime string from UTC to localized time
+ *
+ * @param {string} utcDateTime JS Date object with UTC datetime
+ * @param {string} timezone IANA timezone name ex. "Australia/Brisbane"
+ * @returns Formatted datetime string with localized timezone
+ */
+ function getLocalizedCurrentDate(utcDateTime, timezone) {
+ const localDateTime = moment(utcDateTime).tz(timezone);
+ return localDateTime.format('YYYY-MM-DDTHH[:]mm[:]ss');
+ }
+
function setupDatePicker(fieldName, view, index) {
var cacheModel;
var div;
var datefield;
var timefield;
+ var tzfield;
var cacheview;
var setfield;
var currentDate;
+ var timezone;
if (typeof index !== 'undefined' && view.hasOwnProperty('collection')) {
cacheModel = view.collection.models[index];
div = view.$el.find('#' + view.collectionSelector(cacheModel.cid));
@@ -84,10 +124,18 @@ function($, date, TriggerChangeEventOnEnter) {
}
datefield = $(div).find('input.date');
timefield = $(div).find('input.time');
+ tzfield = $(div).find('span.timezone');
cacheview = view;
+
+ timezone = cacheModel.get('user_timezone');
+
setfield = function(event) {
var newVal = getDate(datefield, timefield);
+ if (timezone) {
+ newVal = convertLocalizedDateToUTC(newVal, timezone);
+ }
+
// Setting to null clears the time as well, as date and time are linked.
// Note also that the validation logic prevents us from clearing the start date
// (start date is required by the back end).
@@ -109,8 +157,17 @@ function($, date, TriggerChangeEventOnEnter) {
if (cacheModel) {
currentDate = cacheModel.get(fieldName);
}
+
+ if (timezone) {
+ const tz = getTZAbbreviation(timezone);
+ $(tzfield).text("("+tz+")");
+ }
+
// timepicker doesn't let us set null, so check that we have a time
if (currentDate) {
+ if (timezone) {
+ currentDate = getLocalizedCurrentDate(currentDate, timezone);
+ }
setDate(datefield, timefield, currentDate);
} else {
// but reset fields either way
From 7ab8541c6b8f1ab2d843ac156ffc7a096839e5df Mon Sep 17 00:00:00 2001
From: pkulkark
Date: Mon, 16 May 2022 13:14:20 +0530
Subject: [PATCH 05/20] fix: Bump edx-search version to 3.4.0
---
requirements/edx/base.txt | 2 +-
requirements/edx/development.txt | 2 +-
requirements/edx/testing.txt | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index 16b8ef7004ce..34ba292e7252 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -474,7 +474,7 @@ edx-rest-api-client==5.4.0
# -r requirements/edx/base.in
# edx-enterprise
# edx-proctoring
-edx-search==3.1.0
+edx-search==3.4.0
# via -r requirements/edx/base.in
edx-sga==0.17.2
# via -r requirements/edx/base.in
diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt
index c0e8f93bf6b5..5edd6adf77ee 100644
--- a/requirements/edx/development.txt
+++ b/requirements/edx/development.txt
@@ -585,7 +585,7 @@ edx-rest-api-client==5.4.0
# -r requirements/edx/testing.txt
# edx-enterprise
# edx-proctoring
-edx-search==3.1.0
+edx-search==3.4.0
# via -r requirements/edx/testing.txt
edx-sga==0.17.2
# via -r requirements/edx/testing.txt
diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt
index 4153ba3bfd37..450f6cce8c95 100644
--- a/requirements/edx/testing.txt
+++ b/requirements/edx/testing.txt
@@ -568,7 +568,7 @@ edx-rest-api-client==5.4.0
# -r requirements/edx/base.txt
# edx-enterprise
# edx-proctoring
-edx-search==3.1.0
+edx-search==3.4.0
# via -r requirements/edx/base.txt
edx-sga==0.17.2
# via -r requirements/edx/base.txt
From 41b56eaf9e36b02bc6c19b46aae497d7a96163ba Mon Sep 17 00:00:00 2001
From: Meysam
Date: Mon, 18 Oct 2021 17:20:17 +0300
Subject: [PATCH 06/20] fix: display right-to-left for rtl-languages in mobile
(#28861)
---
lms/static/sass/_header.scss | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lms/static/sass/_header.scss b/lms/static/sass/_header.scss
index aa4236fd744b..1949d557def2 100644
--- a/lms/static/sass/_header.scss
+++ b/lms/static/sass/_header.scss
@@ -366,7 +366,7 @@
width: 100%;
padding: $baseline*0.6 $baseline;
border-bottom: 1px solid theme-color('light');
- text-align: left;
+ @include text-align(left);
cursor: pointer;
&:hover,
From 23c75b34eefdb656d679f1770ff958369fbcdc0a Mon Sep 17 00:00:00 2001
From: Keith Grootboom
Date: Wed, 9 Feb 2022 20:30:21 +0200
Subject: [PATCH 07/20] feat: add PREPEND_LOCALE_PATHS configuration setting
(#29851)
edx-platform supports COMPREHENSIVE_THEME_LOCALE_PATHS setting, which
appends paths to the end of LOCALE_PATHS, but there's currently no
way to add additional paths to the start of the list.
https://tasks.opencraft.com/browse/SE-5299
---
cms/envs/common.py | 6 ++++++
cms/envs/production.py | 6 ++++++
lms/envs/common.py | 11 ++++++++++-
lms/envs/production.py | 7 +++++++
lms/envs/test.py | 2 ++
5 files changed, 31 insertions(+), 1 deletion(-)
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 13c1375a70f8..edba66f8b200 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -2007,6 +2007,12 @@
# "COMPREHENSIVE_THEME_LOCALE_PATHS" : ["/edx/src/edx-themes/conf/locale"].
COMPREHENSIVE_THEME_LOCALE_PATHS = []
+# .. setting_name: PREPEND_LOCALE_PATHS
+# .. setting_default: []
+# .. setting_description: A list of the paths to locale directories to load first e.g.
+# "PREPEND_LOCALE_PATHS" : ["/edx/my-locales/"].
+PREPEND_LOCALE_PATHS = []
+
# .. setting_name: DEFAULT_SITE_THEME
# .. setting_default: None
# .. setting_description: See LMS annotation.
diff --git a/cms/envs/production.py b/cms/envs/production.py
index aefd5793bdfb..3c4acdf0f77b 100644
--- a/cms/envs/production.py
+++ b/cms/envs/production.py
@@ -239,6 +239,12 @@ def get_env_setting(setting):
# ],
COMPREHENSIVE_THEME_LOCALE_PATHS = ENV_TOKENS.get('COMPREHENSIVE_THEME_LOCALE_PATHS', [])
+# PREPEND_LOCALE_PATHS contain the paths to locale directories to load first e.g.
+# "PREPEND_LOCALE_PATHS" : [
+# "/edx/my-locale/"
+# ],
+PREPEND_LOCALE_PATHS = ENV_TOKENS.get('PREPEND_LOCALE_PATHS', [])
+
#Timezone overrides
TIME_ZONE = ENV_TOKENS.get('CELERY_TIMEZONE', CELERY_TIMEZONE)
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 5bfb4ba5e192..18a31ad0e3ad 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -1852,7 +1852,9 @@ def _make_mako_template_dirs(settings):
# Localization strings (e.g. django.po) are under these directories
def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
- locale_paths = [settings.REPO_ROOT + '/conf/locale'] # edx-platform/conf/locale/
+ locale_paths = list(settings.PREPEND_LOCALE_PATHS)
+ locale_paths += [settings.REPO_ROOT + '/conf/locale'] # edx-platform/conf/locale/
+
if settings.ENABLE_COMPREHENSIVE_THEMING:
# Add locale paths to settings for comprehensive theming.
for locale_path in settings.COMPREHENSIVE_THEME_LOCALE_PATHS:
@@ -4350,6 +4352,13 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
# "COMPREHENSIVE_THEME_LOCALE_PATHS" : ["/edx/src/edx-themes/conf/locale"].
COMPREHENSIVE_THEME_LOCALE_PATHS = []
+
+# .. setting_name: PREPEND_LOCALE_PATHS
+# .. setting_default: []
+# .. setting_description: A list of the paths to locale directories to load first e.g.
+# "PREPEND_LOCALE_PATHS" : ["/edx/my-locales/"].
+PREPEND_LOCALE_PATHS = []
+
# .. setting_name: DEFAULT_SITE_THEME
# .. setting_default: None
# .. setting_description: Theme to use when no site or site theme is defined, for example
diff --git a/lms/envs/production.py b/lms/envs/production.py
index 89cb7f2b4196..fb6eff851d3e 100644
--- a/lms/envs/production.py
+++ b/lms/envs/production.py
@@ -281,6 +281,13 @@ def get_env_setting(setting):
COMPREHENSIVE_THEME_LOCALE_PATHS = ENV_TOKENS.get('COMPREHENSIVE_THEME_LOCALE_PATHS', [])
+# PREPEND_LOCALE_PATHS contain the paths to locale directories to load first e.g.
+# "PREPEND_LOCALE_PATHS" : [
+# "/edx/my-locale"
+# ],
+PREPEND_LOCALE_PATHS = ENV_TOKENS.get('PREPEND_LOCALE_PATHS', [])
+
+
MKTG_URL_LINK_MAP.update(ENV_TOKENS.get('MKTG_URL_LINK_MAP', {}))
ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS = ENV_TOKENS.get(
'ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS',
diff --git a/lms/envs/test.py b/lms/envs/test.py
index 69c30a966493..a5d8ce941e93 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -477,6 +477,8 @@
COMPREHENSIVE_THEME_LOCALE_PATHS = [REPO_ROOT / "themes/conf/locale", ]
ENABLE_COMPREHENSIVE_THEMING = True
+PREPEND_LOCALE_PATHS = []
+
LMS_ROOT_URL = "http://localhost:8000"
# Needed for derived settings used by cms only.
From ee4b81c53750e64c3c47fa6f653ec4fc68d8af56 Mon Sep 17 00:00:00 2001
From: Pooja Kulkarni <13742492+pkulkark@users.noreply.github.com>
Date: Thu, 31 Mar 2022 10:52:56 +0530
Subject: [PATCH 08/20] fix: Convert compliance warning back to html (#465)
---
openedx/core/djangoapps/password_policy/forms.py | 5 +++--
openedx/core/djangoapps/user_authn/views/login.py | 2 +-
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/openedx/core/djangoapps/password_policy/forms.py b/openedx/core/djangoapps/password_policy/forms.py
index 389669c370e1..7651583053b7 100644
--- a/openedx/core/djangoapps/password_policy/forms.py
+++ b/openedx/core/djangoapps/password_policy/forms.py
@@ -6,6 +6,7 @@
from django.forms import ValidationError
from openedx.core.djangoapps.password_policy import compliance as password_policy_compliance
+from openedx.core.djangolib.markup import HTML
class PasswordPolicyAwareAdminAuthForm(AdminAuthenticationForm):
@@ -24,9 +25,9 @@ def clean(self):
password_policy_compliance.enforce_compliance_on_login(self.user_cache, cleaned_data['password'])
except password_policy_compliance.NonCompliantPasswordWarning as e:
# Allow login, but warn the user that they will be required to reset their password soon.
- messages.warning(self.request, str(e))
+ messages.warning(self.request, HTML(str(e)))
except password_policy_compliance.NonCompliantPasswordException as e:
# Prevent the login attempt.
- raise ValidationError(str(e)) # lint-amnesty, pylint: disable=raise-missing-from
+ raise ValidationError(HTML(str(e))) # lint-amnesty, pylint: disable=raise-missing-from
return cleaned_data
diff --git a/openedx/core/djangoapps/user_authn/views/login.py b/openedx/core/djangoapps/user_authn/views/login.py
index f061c5a65419..cebafbbcf1a5 100644
--- a/openedx/core/djangoapps/user_authn/views/login.py
+++ b/openedx/core/djangoapps/user_authn/views/login.py
@@ -174,7 +174,7 @@ def _enforce_password_policy_compliance(request, user): # lint-amnesty, pylint:
password_policy_compliance.enforce_compliance_on_login(user, request.POST.get('password'))
except password_policy_compliance.NonCompliantPasswordWarning as e:
# Allow login, but warn the user that they will be required to reset their password soon.
- PageLevelMessages.register_warning_message(request, str(e))
+ PageLevelMessages.register_warning_message(request, HTML(str(e)))
except password_policy_compliance.NonCompliantPasswordException as e:
# Increment the lockout counter to safguard from further brute force requests
# if user's password has been compromised.
From 570bdd3d5e77e7e8843dd84238478bf964480625 Mon Sep 17 00:00:00 2001
From: pkulkark
Date: Fri, 20 May 2022 15:56:37 +0530
Subject: [PATCH 09/20] fix: pylint errors
---
cms/__init__.py | 12 ++++++------
lms/__init__.py | 1 +
.../core/djangoapps/content/block_structure/store.py | 4 +++-
.../core/djangoapps/enrollments/tests/test_views.py | 1 +
openedx/core/djangoapps/enrollments/views.py | 2 --
5 files changed, 11 insertions(+), 9 deletions(-)
diff --git a/cms/__init__.py b/cms/__init__.py
index ff0885c93292..d1bf27534315 100644
--- a/cms/__init__.py
+++ b/cms/__init__.py
@@ -6,6 +6,12 @@
isort:skip_file
"""
+# FAL-2248: Monkey patch django's get_storage_engine to work around long migrations times.
+# This fixes a performance issue with database migrations in Ocim. We will need to keep
+# this patch in our opencraft-release/* branches until edx-platform upgrades to Django 4.*
+# which will include this commit:
+# https://github.com/django/django/commit/518ce7a51f994fc0585d31c4553e2072bf816f76
+import django.db.backends.mysql.introspection
# We monkey patch Kombu's entrypoints listing because scanning through this
# accounts for the majority of LMS/Studio startup time for tests, and we don't
@@ -23,12 +29,6 @@
# singleton is always configured for the CMS.
from .celery import APP as CELERY_APP # lint-amnesty, pylint: disable=wrong-import-position
-# FAL-2248: Monkey patch django's get_storage_engine to work around long migrations times.
-# This fixes a performance issue with database migrations in Ocim. We will need to keep
-# this patch in our opencraft-release/* branches until edx-platform upgrades to Django 4.*
-# which will include this commit:
-# https://github.com/django/django/commit/518ce7a51f994fc0585d31c4553e2072bf816f76
-import django.db.backends.mysql.introspection
def get_storage_engine(self, cursor, table_name):
"""
diff --git a/lms/__init__.py b/lms/__init__.py
index 3444b737f50f..05a30f4ffad4 100644
--- a/lms/__init__.py
+++ b/lms/__init__.py
@@ -26,6 +26,7 @@
# https://github.com/django/django/commit/518ce7a51f994fc0585d31c4553e2072bf816f76
import django.db.backends.mysql.introspection
+
def get_storage_engine(self, cursor, table_name):
"""
This is a patched version of `get_storage_engine` that fixes a
diff --git a/openedx/core/djangoapps/content/block_structure/store.py b/openedx/core/djangoapps/content/block_structure/store.py
index 13688fc85e43..a1d20548b8c9 100644
--- a/openedx/core/djangoapps/content/block_structure/store.py
+++ b/openedx/core/djangoapps/content/block_structure/store.py
@@ -16,6 +16,8 @@
from .models import BlockStructureModel
from .transformer_registry import TransformerRegistry
+import six
+
logger = getLogger(__name__) # pylint: disable=C0103
@@ -228,7 +230,7 @@ def _encode_root_cache_key(bs_model):
Returns the cache key to use for the given
BlockStructureModel or StubModel.
"""
- if _is_storage_backing_enabled():
+ if config.STORAGE_BACKING_FOR_CACHE.is_enabled():
return six.text_type(bs_model)
else:
diff --git a/openedx/core/djangoapps/enrollments/tests/test_views.py b/openedx/core/djangoapps/enrollments/tests/test_views.py
index 88e2d6401d8c..5505461e987c 100644
--- a/openedx/core/djangoapps/enrollments/tests/test_views.py
+++ b/openedx/core/djangoapps/enrollments/tests/test_views.py
@@ -14,6 +14,7 @@
import httpretty
import pytest
import pytz
+import six
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
diff --git a/openedx/core/djangoapps/enrollments/views.py b/openedx/core/djangoapps/enrollments/views.py
index f0e48e2ef903..ed3d803836c6 100644
--- a/openedx/core/djangoapps/enrollments/views.py
+++ b/openedx/core/djangoapps/enrollments/views.py
@@ -8,7 +8,6 @@
import json
import logging
-from course_modes.models import CourseMode
from django.core.exceptions import ( # lint-amnesty, pylint: disable=wrong-import-order
ObjectDoesNotExist,
ValidationError
@@ -26,7 +25,6 @@
from rest_framework.response import Response # lint-amnesty, pylint: disable=wrong-import-order
from rest_framework.throttling import UserRateThrottle # lint-amnesty, pylint: disable=wrong-import-order
from rest_framework.views import APIView # lint-amnesty, pylint: disable=wrong-import-order
-from six import text_type
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.student.auth import user_has_role
From ca3d3e2271b30a821b61c37fb42348696e348b06 Mon Sep 17 00:00:00 2001
From: Agrendalath
Date: Fri, 9 Apr 2021 00:14:53 +0200
Subject: [PATCH 10/20] feat: support adding custom editors to Studio
This:
1. Introduces a variable for the Course Outline view in Studio.
A custom theme can override it to add new editors.
2. Exports a function for creating new editor modals.
A custom theme can use it to create editors without adding boilerplate code.
3. Adds a pluggable override for XBlock fields that are passed to the Studio.
Without this, custom editors in Studio cannot retrieve values of XBlock fields.
(cherry picked from commit e633cc9c249a31f308ac67036b7a62609fb29499)
---
cms/djangoapps/contentstore/views/item.py | 9 +++++++++
cms/envs/common.py | 1 +
cms/envs/production.py | 3 +++
cms/static/js/views/modals/course_outline_modals.js | 13 +++++++++++++
cms/static/js/views/pages/course_outline.js | 5 ++++-
openedx/core/lib/xblock_utils/__init__.py | 2 +-
6 files changed, 31 insertions(+), 2 deletions(-)
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index 5036723cd3c1..0d526bef08c8 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -13,6 +13,7 @@
from django.http import Http404, HttpResponse, HttpResponseBadRequest
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 edx_proctoring.api import (
does_backend_support_onboarding,
get_exam_by_content_id,
@@ -1116,6 +1117,7 @@ def _get_gating_info(course, xblock):
return info
+@pluggable_override('OVERRIDE_CREATE_XBLOCK_INFO')
def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=False, include_child_info=False, # lint-amnesty, pylint: disable=too-many-statements
course_outline=False, include_children_predicate=NEVER, parent_xblock=None, graders=None,
user=None, course=None, is_concise=False):
@@ -1134,6 +1136,13 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
In addition, an optional include_children_predicate argument can be provided to define whether or
not a particular xblock should have its children included.
+
+ You can customize the behavior of this function using the `OVERRIDE_CREATE_XBLOCK_INFO` pluggable override point.
+ For example:
+ >>> def create_xblock_info(default_fn, xblock, *args, **kwargs):
+ ... xblock_info = default_fn(xblock, *args, **kwargs)
+ ... xblock_info['icon'] = xblock.icon_override
+ ... return xblock_info
"""
is_library_block = isinstance(xblock.location, LibraryUsageLocator)
is_xblock_unit = is_unit(xblock, parent_xblock)
diff --git a/cms/envs/common.py b/cms/envs/common.py
index edba66f8b200..79e4906dd6ab 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -841,6 +841,7 @@
EditInfoMixin,
AuthoringMixin,
)
+XBLOCK_EXTRA_MIXINS = ()
XBLOCK_SELECT_FUNCTION = prefer_xmodules
diff --git a/cms/envs/production.py b/cms/envs/production.py
index 3c4acdf0f77b..d44c9c59f84b 100644
--- a/cms/envs/production.py
+++ b/cms/envs/production.py
@@ -598,6 +598,9 @@ def get_env_setting(setting):
LOGO_IMAGE_EXTRA_TEXT = ENV_TOKENS.get('LOGO_IMAGE_EXTRA_TEXT', '')
+############## XBlock extra mixins ############################
+XBLOCK_MIXINS += tuple(XBLOCK_EXTRA_MIXINS)
+
############## Settings for course import olx validation ############################
COURSE_OLX_VALIDATION_STAGE = ENV_TOKENS.get('COURSE_OLX_VALIDATION_STAGE', COURSE_OLX_VALIDATION_STAGE)
COURSE_OLX_VALIDATION_IGNORE_LIST = ENV_TOKENS.get(
diff --git a/cms/static/js/views/modals/course_outline_modals.js b/cms/static/js/views/modals/course_outline_modals.js
index db46623b85aa..43defc2c92cb 100644
--- a/cms/static/js/views/modals/course_outline_modals.js
+++ b/cms/static/js/views/modals/course_outline_modals.js
@@ -1208,6 +1208,19 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
}, options));
},
+ /**
+ * This function allows comprehensive themes to create custom editors without adding boilerplate code.
+ *
+ * A simple example theme for this can be found at https://github.com/open-craft/custom-unit-icons-theme
+ **/
+ getCustomEditModal: function(tabs, editors, xblockInfo, options) {
+ return new SettingsXBlockModal($.extend({
+ tabs: tabs,
+ editors: editors,
+ model: xblockInfo
+ }, options));
+ },
+
getPublishModal: function(xblockInfo, options) {
return new PublishXBlockModal($.extend({
editors: [PublishEditor],
diff --git a/cms/static/js/views/pages/course_outline.js b/cms/static/js/views/pages/course_outline.js
index cd86d6399795..08f25b3eed19 100644
--- a/cms/static/js/views/pages/course_outline.js
+++ b/cms/static/js/views/pages/course_outline.js
@@ -34,6 +34,9 @@ define([
collapsedClass: 'is-collapsed'
},
+ // Extracting this to a variable allows comprehensive themes to replace or extend `CourseOutlineView`.
+ outlineViewClass: CourseOutlineView,
+
initialize: function() {
var self = this;
this.initialState = this.options.initialState;
@@ -90,7 +93,7 @@ define([
this.highlightsEnableView.render();
}
- this.outlineView = new CourseOutlineView({
+ this.outlineView = new this.outlineViewClass({
el: this.$('.outline'),
model: this.model,
isRoot: true,
diff --git a/openedx/core/lib/xblock_utils/__init__.py b/openedx/core/lib/xblock_utils/__init__.py
index 1a320f050440..1e2acc7ef84b 100644
--- a/openedx/core/lib/xblock_utils/__init__.py
+++ b/openedx/core/lib/xblock_utils/__init__.py
@@ -558,6 +558,6 @@ def get_icon(block):
"""
A function that returns the CSS class representing an icon to use for this particular
XBlock (in the courseware navigation bar). Mostly used for Vertical/Unit XBlocks.
- It can be overridden by setting `GET_UNIT_ICON_IMPL` to an alternative implementation.
+ It can be overridden by setting `OVERRIDE_GET_UNIT_ICON` to an alternative implementation.
"""
return block.get_icon_class()
From 96bfcf5848bfea553e43a1801bbd0fba771bd25a Mon Sep 17 00:00:00 2001
From: Agrendalath
Date: Sun, 25 Jul 2021 02:06:51 +0200
Subject: [PATCH 11/20] feat: allow marking Library Content Block as complete
on view
edx/edx-platform#24365 has changed the completion mode of these blocks.
Before Koa, it was sufficient to view the block to get a completion checkmark.
Since Koa, all children of the block must be completed.
This adds a toggle to change the completion behavior back to the previous one
so that the user experience can be consistent if needed.
(cherry picked from commit d05e5c639f1c58029a75de041a19d0ca47c9f501)
---
cms/envs/common.py | 13 +++++++++++++
.../xmodule/xmodule/library_content_module.py | 15 ++++++++++++++-
lms/envs/common.py | 13 +++++++++++++
.../completion_integration/test_services.py | 18 ++++++++++++++++++
4 files changed, 58 insertions(+), 1 deletion(-)
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 79e4906dd6ab..d69043abf966 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -484,6 +484,19 @@
# in the LMS and CMS.
# .. toggle_tickets: 'https://github.com/open-craft/edx-platform/pull/429'
'DISABLE_UNENROLLMENT': False,
+
+ # .. toggle_name: MARK_LIBRARY_CONTENT_BLOCK_COMPLETE_ON_VIEW
+ # .. toggle_implementation: DjangoSetting
+ # .. toggle_default: False
+ # .. toggle_description: If enabled, the Library Content Block is marked as complete when users view it.
+ # Otherwise (by default), all children of this block must be completed.
+ # .. toggle_use_cases: open_edx
+ # .. toggle_creation_date: 2022-03-22
+ # .. toggle_target_removal_date: None
+ # .. toggle_tickets: https://github.com/edx/edx-platform/pull/28268
+ # .. toggle_warnings: For consistency in user-experience, keep the value in sync with the setting of the same name
+ # in the LMS and CMS.
+ 'MARK_LIBRARY_CONTENT_BLOCK_COMPLETE_ON_VIEW': False,
}
ENABLE_JASMINE = False
diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py
index 18dbb9ae11b6..82a23c2b4781 100644
--- a/common/lib/xmodule/xmodule/library_content_module.py
+++ b/common/lib/xmodule/xmodule/library_content_module.py
@@ -10,6 +10,8 @@
from gettext import ngettext
import bleach
+from django.conf import settings
+from django.utils.functional import classproperty
from lazy import lazy
from lxml import etree
from lxml.etree import XMLSyntaxError
@@ -115,7 +117,18 @@ class LibraryContentBlock(
show_in_read_only_mode = True
- completion_mode = XBlockCompletionMode.AGGREGATOR
+ # noinspection PyMethodParameters
+ @classproperty
+ def completion_mode(cls): # pylint: disable=no-self-argument
+ """
+ Allow overriding the completion mode with a feature flag.
+
+ This is a property, so it can be dynamically overridden in tests, as it is not evaluated at runtime.
+ """
+ if settings.FEATURES.get('MARK_LIBRARY_CONTENT_BLOCK_COMPLETE_ON_VIEW', False):
+ return XBlockCompletionMode.COMPLETABLE
+
+ return XBlockCompletionMode.AGGREGATOR
display_name = String(
display_name=_("Display Name"),
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 18a31ad0e3ad..0b8fde96b271 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -960,6 +960,19 @@
# in the LMS and CMS.
# .. toggle_tickets: 'https://github.com/open-craft/edx-platform/pull/429'
'DISABLE_UNENROLLMENT': False,
+
+ # .. toggle_name: MARK_LIBRARY_CONTENT_BLOCK_COMPLETE_ON_VIEW
+ # .. toggle_implementation: DjangoSetting
+ # .. toggle_default: False
+ # .. toggle_description: If enabled, the Library Content Block is marked as complete when users view it.
+ # Otherwise (by default), all children of this block must be completed.
+ # .. toggle_use_cases: open_edx
+ # .. toggle_creation_date: 2022-03-22
+ # .. toggle_target_removal_date: None
+ # .. toggle_tickets: https://github.com/edx/edx-platform/pull/28268
+ # .. toggle_warnings: For consistency in user-experience, keep the value in sync with the setting of the same name
+ # in the LMS and CMS.
+ 'MARK_LIBRARY_CONTENT_BLOCK_COMPLETE_ON_VIEW': False,
}
# Specifies extra XBlock fields that should available when requested via the Course Blocks API
diff --git a/openedx/tests/completion_integration/test_services.py b/openedx/tests/completion_integration/test_services.py
index 94fdf4e46b3d..867b89772cd1 100644
--- a/openedx/tests/completion_integration/test_services.py
+++ b/openedx/tests/completion_integration/test_services.py
@@ -7,6 +7,8 @@
from completion.models import BlockCompletion
from completion.services import CompletionService
from completion.test_utils import CompletionWaffleTestMixin
+from django.conf import settings
+from django.test import override_settings
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangolib.testing.utils import skip_unless_lms
@@ -183,6 +185,19 @@ def test_can_mark_block_complete_on_view(self):
assert self.completion_service.can_mark_block_complete_on_view(self.html) is True
assert self.completion_service.can_mark_block_complete_on_view(self.problem) is False
+ @override_settings(FEATURES={**settings.FEATURES, 'MARK_LIBRARY_CONTENT_BLOCK_COMPLETE_ON_VIEW': True})
+ def test_can_mark_library_content_complete_on_view(self):
+ library = LibraryFactory.create(modulestore=self.store)
+ lib_vertical = ItemFactory.create(parent=self.sequence, category='vertical', publish_item=False)
+ library_content_block = ItemFactory.create(
+ parent=lib_vertical,
+ category='library_content',
+ max_count=1,
+ source_library_id=str(library.location.library_key),
+ user_id=self.user.id,
+ )
+ self.assertTrue(self.completion_service.can_mark_block_complete_on_view(library_content_block))
+
def test_vertical_completion_with_library_content(self):
library = LibraryFactory.create(modulestore=self.store)
ItemFactory.create(parent=library, category='problem', publish_item=False, user_id=self.user.id)
@@ -204,6 +219,9 @@ def test_vertical_completion_with_library_content(self):
source_library_id=str(library.location.library_key),
user_id=self.user.id,
)
+ # Library Content Block needs its children to be completed.
+ self.assertFalse(self.completion_service.can_mark_block_complete_on_view(library_content_block))
+
library_content_block.refresh_children()
lib_vertical = self.store.get_item(lib_vertical.location)
self._bind_course_module(lib_vertical)
From 71c8693eaf762db782e1d06f3945a4b133e68a61 Mon Sep 17 00:00:00 2001
From: Agrendalath
Date: Mon, 25 Oct 2021 21:15:45 +0200
Subject: [PATCH 12/20] fix: do not index solutions in CAPA blocks
Most tags that could contain solutions or hints were already being removed,
but the regex did not include the case when they contained attributes.
(cherry picked from commit 0ef57eb136914a4dd0de1396095c80d8eddf73ac)
---
common/lib/xmodule/xmodule/capa_module.py | 18 +++++--
.../xmodule/xmodule/tests/test_capa_module.py | 47 +++++++++++--------
2 files changed, 40 insertions(+), 25 deletions(-)
diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py
index 3311e2eb85f8..43503de0de82 100644
--- a/common/lib/xmodule/xmodule/capa_module.py
+++ b/common/lib/xmodule/xmodule/capa_module.py
@@ -548,14 +548,22 @@ def index_dictionary(self):
# Make optioninput's options index friendly by replacing the actual tag with the values
capa_content = re.sub(r'\s*|\S*<\/optioninput>', r'\1', self.data)
- # Removing solutions and hints, as well as script and style
+ # Remove the following tags with content that can leak hints or solutions:
+ # - `solution` (with optional attributes) and `solutionset`.
+ # - `targetedfeedback` (with optional attributes) and `targetedfeedbackset`.
+ # - `answer` (with optional attributes).
+ # - `script` (with optional attributes).
+ # - `style` (with optional attributes).
+ # - various types of hints (with optional attributes) and `hintpart`.
capa_content = re.sub(
re.compile(
r"""
- .*? |
- |
- |
- <[a-z]*hint.*?>.*?[a-z]*hint>
+ .*? |
+ .*? |
+ .*? |
+ .*? |
+ .*? |
+ <[a-z]*hint.*?>.*?[a-z]*hint.*?>
""",
re.DOTALL |
re.VERBOSE),
diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py
index 30f2e5cd4bc5..4667dc4f92e8 100644
--- a/common/lib/xmodule/xmodule/tests/test_capa_module.py
+++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py
@@ -2561,25 +2561,32 @@ def test_response_types_multiple_tags(self):
def test_solutions_not_indexed(self):
xml = textwrap.dedent("""
-
-
-
Explanation
-
-
This is what the 1st solution.
-
-
-
-
-
-
-
Explanation
-
-
This is the 2nd solution.
-
-
-
-
-
+ Test solution.
+ Test solution with attribute.
+
+ Test solutionset.
+ Test solution within solutionset.
+
+
+ Test feedback.
+ Test feedback with attribute.
+
+ Test FeedbackSet.
+ Test feedback within feedbackset.
+
+
+ Test answer.
+ Test answer with attribute.
+
+
+
+
+
+
+
+ Test choicehint.
+ Test hint.
+ Test hintpart.
""")
name = "Blank Common Capa Problem"
@@ -2694,7 +2701,7 @@ def test_indexing_non_latin_problem(self):
""")
name = "Non latin Input"
descriptor = self._create_descriptor(sample_text_input_problem_xml, name=name)
- capa_content = " FX1_VAL='Καλημέρα' Δοκιμή με μεταβλητές με Ελληνικούς χαρακτήρες μέσα σε python: $FX1_VAL "
+ capa_content = " Δοκιμή με μεταβλητές με Ελληνικούς χαρακτήρες μέσα σε python: $FX1_VAL "
descriptor_dict = descriptor.index_dictionary()
assert descriptor_dict['content']['capa_content'] == smart_str(capa_content)
From 87424a155b53a47f017523062b8abfbfb75e242e Mon Sep 17 00:00:00 2001
From: Arunmozhi
Date: Wed, 1 Sep 2021 13:47:39 +0000
Subject: [PATCH 13/20] feat: add reset option to Randomized Content Block
This makes the reset button to refresh the contents of a Randomized
Content Block (RCB) without reloading the full page by fetching a new
set of problems in the "reset" response and replacing the DOM contents.
The reset button returns the student view as a string and the client
uses the HtmlUtils package to replace the contents and reinitializes the
XBlock.
This allows students to use the RCB as a flash card system.
Co-authored-by: tinumide
---
.../public/js/library_content_reset.js | 18 +++++++
.../xmodule/xmodule/library_content_module.py | 36 +++++++++++++-
.../xmodule/tests/test_library_content.py | 47 ++++++++++++++++++-
.../sass/course/courseware/_courseware.scss | 11 +++++
lms/templates/vert_module.html | 6 +++
5 files changed, 116 insertions(+), 2 deletions(-)
create mode 100644 common/lib/xmodule/xmodule/assets/library_content/public/js/library_content_reset.js
diff --git a/common/lib/xmodule/xmodule/assets/library_content/public/js/library_content_reset.js b/common/lib/xmodule/xmodule/assets/library_content/public/js/library_content_reset.js
new file mode 100644
index 000000000000..e985d3c2a692
--- /dev/null
+++ b/common/lib/xmodule/xmodule/assets/library_content/public/js/library_content_reset.js
@@ -0,0 +1,18 @@
+/* JavaScript for reset option that can be done on a randomized LibraryContentBlock */
+function LibraryContentReset(runtime, element) {
+ $('.problem-reset-btn', element).click((e) => {
+ e.preventDefault();
+ $.post({
+ url: runtime.handlerUrl(element, 'reset_selected_children'),
+ success(data) {
+ edx.HtmlUtils.setHtml(element, edx.HtmlUtils.HTML(data));
+ // Rebind the reset button for the block
+ XBlock.initializeBlock(element);
+ // Render the new set of problems (XBlocks)
+ $(".xblock", element).each(function(i, child) {
+ XBlock.initializeBlock(child);
+ });
+ },
+ });
+ });
+}
diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py
index 82a23c2b4781..c1ba0d1807fc 100644
--- a/common/lib/xmodule/xmodule/library_content_module.py
+++ b/common/lib/xmodule/xmodule/library_content_module.py
@@ -8,6 +8,7 @@
import random
from copy import copy
from gettext import ngettext
+from rest_framework import status
import bleach
from django.conf import settings
@@ -21,7 +22,7 @@
from webob import Response
from xblock.completable import XBlockCompletionMode
from xblock.core import XBlock
-from xblock.fields import Integer, List, Scope, String
+from xblock.fields import Integer, List, Scope, String, Boolean
from capa.responsetypes import registry
from xmodule.mako_module import MakoTemplateBlockBase
@@ -177,6 +178,14 @@ def completion_mode(cls): # pylint: disable=no-self-argument
default=[],
scope=Scope.user_state,
)
+ # This cannot be called `show_reset_button`, because children blocks inherit this as a default value.
+ allow_resetting_children = Boolean(
+ display_name=_("Show Reset Button"),
+ help=_("Determines whether a 'Reset Problems' button is shown, so users may reset their answers and reshuffle "
+ "selected items."),
+ scope=Scope.settings,
+ default=False
+ )
@property
def source_library_key(self):
@@ -347,6 +356,27 @@ def selected_children(self):
return self.selected
+ @XBlock.handler
+ def reset_selected_children(self, _, __):
+ """
+ Resets the XBlock's state for a user.
+
+ This resets the state of all `selected` children and then clears the `selected` field
+ so that the new blocks are randomly chosen for this user.
+ """
+ if not self.allow_resetting_children:
+ return Response('"Resetting selected children" is not allowed for this XBlock',
+ status=status.HTTP_400_BAD_REQUEST)
+
+ for block_type, block_id in self.selected_children():
+ block = self.runtime.get_block(self.location.course_key.make_usage_key(block_type, block_id))
+ if hasattr(block, 'reset_problem'):
+ block.reset_problem(None)
+ block.save()
+
+ self.selected = []
+ return Response(json.dumps(self.student_view({}).content))
+
def _get_selected_child_blocks(self):
"""
Generator returning XBlock instances of the children selected for the
@@ -384,7 +414,11 @@ def student_view(self, context): # lint-amnesty, pylint: disable=missing-functi
'show_bookmark_button': False,
'watched_completable_blocks': set(),
'completion_delay_ms': None,
+ 'reset_button': self.allow_resetting_children,
}))
+
+ fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_content_reset.js'))
+ fragment.initialize_js('LibraryContentReset')
return fragment
def author_view(self, context):
diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py
index 628f471e88eb..748a962f1d65 100644
--- a/common/lib/xmodule/xmodule/tests/test_library_content.py
+++ b/common/lib/xmodule/xmodule/tests/test_library_content.py
@@ -3,7 +3,8 @@
Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`.
"""
-from unittest.mock import Mock, patch
+import ddt
+from unittest.mock import MagicMock, Mock, patch
from bson.objectid import ObjectId
from fs.memoryfs import MemoryFS
@@ -11,6 +12,7 @@
from search.search_engine_base import SearchEngine
from web_fragments.fragment import Fragment
from xblock.runtime import Runtime as VanillaRuntime
+from rest_framework import status
from xmodule.library_content_module import ANY_CAPA_TYPE_VALUE, LibraryContentBlock
from xmodule.library_tools import LibraryToolsService
@@ -20,6 +22,7 @@
from xmodule.tests import get_test_system
from xmodule.validation import StudioValidationMessage
from xmodule.x_module import AUTHOR_VIEW
+from xmodule.capa_module import ProblemBlock
from .test_course_module import DummySystem as TestImportSystem
@@ -30,6 +33,7 @@ class LibraryContentTest(MixedSplitTestCase):
"""
Base class for tests of LibraryContentBlock (library_content_block.py)
"""
+
def setUp(self):
super().setUp()
@@ -164,6 +168,7 @@ def test_xml_import_with_comments(self):
self._verify_xblock_properties(imported_lc_block)
+@ddt.ddt
class LibraryContentBlockTestMixin:
"""
Basic unit tests for LibraryContentBlock
@@ -378,6 +383,45 @@ def _change_count_and_refresh_children(self, count):
assert len(selected) == count
return selected
+ @ddt.data(
+ # User resets selected children with reset button on content block
+ (True, 8),
+ # User resets selected children without reset button on content block
+ (False, 8),
+ )
+ @ddt.unpack
+ def test_reset_selected_children_capa_blocks(self, allow_resetting_children, max_count):
+ """
+ Tests that the `reset_selected_children` method of a content block resets only
+ XBlocks that have a `reset_problem` attribute when `allow_resetting_children` is True
+
+ This test block has 4 HTML XBlocks and 4 Problem XBlocks. Therefore, if we ensure
+ that the `reset_problem` has been called len(self.problem_types) times, then
+ it means that this is working correctly
+ """
+ self.lc_block.allow_resetting_children = allow_resetting_children
+ self.lc_block.max_count = max_count
+ # Add some capa blocks
+ self._create_capa_problems()
+ self.lc_block.refresh_children()
+ self.lc_block = self.store.get_item(self.lc_block.location)
+ # Mock the student view to return an empty dict to be returned as response
+ self.lc_block.student_view = MagicMock()
+ self.lc_block.student_view.return_value.content = {}
+
+ with patch.object(ProblemBlock, 'reset_problem', return_value={'success': True}) as reset_problem:
+ response = self.lc_block.reset_selected_children(None, None)
+
+ if allow_resetting_children:
+ self.lc_block.student_view.assert_called_once_with({})
+ assert reset_problem.call_count == len(self.problem_types)
+ assert response.status_code == status.HTTP_200_OK
+ assert response.content_type == "text/html"
+ assert response.body == b"{}"
+ else:
+ reset_problem.assert_not_called()
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
@patch('xmodule.library_tools.SearchEngine.get_search_engine', Mock(return_value=None, autospec=True))
class TestLibraryContentBlockNoSearchIndex(LibraryContentBlockTestMixin, LibraryContentTest):
@@ -396,6 +440,7 @@ class TestLibraryContentBlockWithSearchIndex(LibraryContentBlockTestMixin, Libra
"""
Tests for library container with mocked search engine response.
"""
+
def _get_search_response(self, field_dictionary=None):
""" Mocks search response as returned by search engine """
target_type = field_dictionary.get('problem_types')
diff --git a/lms/static/sass/course/courseware/_courseware.scss b/lms/static/sass/course/courseware/_courseware.scss
index 67a5a704050b..33f8dae798ad 100644
--- a/lms/static/sass/course/courseware/_courseware.scss
+++ b/lms/static/sass/course/courseware/_courseware.scss
@@ -635,6 +635,17 @@ html.video-fullscreen {
border-bottom: 1px solid #ddd;
margin-bottom: ($baseline*0.75);
padding: 0 0 15px;
+
+ .problem-reset-btn-wrapper {
+ position: relative;
+ .problem-reset-btn {
+ &:hover,
+ &:focus,
+ &:active {
+ color: $primary;
+ }
+ }
+ }
}
.vert > .xblock-student_view.is-hidden,
diff --git a/lms/templates/vert_module.html b/lms/templates/vert_module.html
index 131bbfc8cadc..0e52e3c7f426 100644
--- a/lms/templates/vert_module.html
+++ b/lms/templates/vert_module.html
@@ -69,6 +69,12 @@
${unit_title}
% endfor
+% if reset_button:
+
+
+
+% endif
+
<%static:require_module_async module_name="js/dateutil_factory" class_name="DateUtilFactory">
DateUtilFactory.transform('.localized-datetime');
%static:require_module_async>
From fd8257a99e9c6e56dd5bd77628620ea7255c280a Mon Sep 17 00:00:00 2001
From: Sandeep Kumar Choudhary
Date: Mon, 7 Jun 2021 05:52:39 +0000
Subject: [PATCH 14/20] feat: Allow delete course content in Studio only for
admin users
(cherry picked from commit c812a6c1d5c0961900507a6e7abe3d0f3b8a7570)
---
cms/djangoapps/contentstore/config/waffle.py | 16 +-
cms/djangoapps/contentstore/permissions.py | 10 +
cms/djangoapps/contentstore/views/item.py | 9 +-
.../contentstore/views/tests/test_item.py | 171 ++++++++++++++++--
.../spec/views/pages/course_outline_spec.js | 22 ++-
cms/static/js/views/xblock_outline.js | 3 +-
cms/templates/js/course-outline.underscore | 2 +-
7 files changed, 209 insertions(+), 24 deletions(-)
create mode 100644 cms/djangoapps/contentstore/permissions.py
diff --git a/cms/djangoapps/contentstore/config/waffle.py b/cms/djangoapps/contentstore/config/waffle.py
index 3dc567a14f0e..6a8c33aa98be 100644
--- a/cms/djangoapps/contentstore/config/waffle.py
+++ b/cms/djangoapps/contentstore/config/waffle.py
@@ -19,11 +19,9 @@
def waffle():
"""
Deprecated: Returns the namespaced, cached, audited Waffle Switch class for Studio pages.
-
IMPORTANT: Do NOT copy this pattern and do NOT use this to reference new switches.
Instead, replace the string constant above with the actual switch instance.
For example::
-
ENABLE_ACCESSIBILITY_POLICY_PAGE = WaffleSwitch(f'{WAFFLE_NAMESPACE}.enable_policy_page')
"""
return LegacyWaffleSwitchNamespace(name=WAFFLE_NAMESPACE, log_prefix='Studio: ')
@@ -32,13 +30,11 @@ def waffle():
def waffle_flags():
"""
Deprecated: Returns the namespaced, cached, audited Waffle Flag class for Studio pages.
-
IMPORTANT: Do NOT copy this pattern and do NOT use this to reference new flags.
See waffle() docstring for more details.
"""
return LegacyWaffleFlagNamespace(name=WAFFLE_NAMESPACE, log_prefix='Studio: ')
-
# TODO: After removing this flag, add a migration to remove waffle flag in a follow-up deployment.
ENABLE_CHECKLISTS_QUALITY = CourseWaffleFlag( # lint-amnesty, pylint: disable=toggle-missing-annotation
waffle_namespace=waffle_flags(),
@@ -81,3 +77,15 @@ def waffle_flags():
# .. toggle_warnings: Flag course_experience.relative_dates should also be active for relative dates functionalities to work.
# .. toggle_tickets: https://openedx.atlassian.net/browse/AA-844
CUSTOM_RELATIVE_DATES = CourseWaffleFlag(WAFFLE_NAMESPACE, 'custom_relative_dates', module_name=__name__,)
+
+# .. toggle_name: studio.prevent_staff_structure_deletion
+# .. toggle_implementation: WaffleFlag
+# .. toggle_default: False
+# .. toggle_description: Prevents staff from deleting course structures
+# .. toggle_use_cases: opt_in
+# .. toggle_creation_date: 2021-06-25
+PREVENT_STAFF_STRUCTURE_DELETION = LegacyWaffleFlag(
+ waffle_namespace=waffle_flags(),
+ flag_name='prevent_staff_structure_deletion',
+ module_name=__name__,
+)
diff --git a/cms/djangoapps/contentstore/permissions.py b/cms/djangoapps/contentstore/permissions.py
new file mode 100644
index 000000000000..14fe40c09ca7
--- /dev/null
+++ b/cms/djangoapps/contentstore/permissions.py
@@ -0,0 +1,10 @@
+"""
+Permission definitions for the contentstore djangoapp
+"""
+
+from bridgekeeper import perms
+
+from lms.djangoapps.courseware.rules import HasRolesRule
+
+DELETE_COURSE_CONTENT = 'contentstore.delete_course_content'
+perms[DELETE_COURSE_CONTENT] = HasRolesRule('instructor')
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index 0d526bef08c8..0c02bdbefb58 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -29,7 +29,8 @@
from xblock.core import XBlock
from xblock.fields import Scope
-from cms.djangoapps.contentstore.config.waffle import SHOW_REVIEW_RULES_FLAG
+from cms.djangoapps.contentstore.config.waffle import PREVENT_STAFF_STRUCTURE_DELETION, SHOW_REVIEW_RULES_FLAG
+from cms.djangoapps.contentstore.permissions import DELETE_COURSE_CONTENT
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW
from common.djangoapps.edxmako.shortcuts import render_to_string
@@ -1339,6 +1340,12 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
else:
xblock_info['staff_only_message'] = False
+ xblock_info['show_delete_button'] = True
+ if PREVENT_STAFF_STRUCTURE_DELETION.is_enabled():
+ xblock_info['show_delete_button'] = (
+ user.has_perm(DELETE_COURSE_CONTENT, xblock) if user is not None else False
+ )
+
xblock_info['has_partition_group_components'] = has_children_visible_to_specific_partition_groups(
xblock
)
diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py
index 0d93d8b4545d..c1cf8e9cedf7 100644
--- a/cms/djangoapps/contentstore/views/tests/test_item.py
+++ b/cms/djangoapps/contentstore/views/tests/test_item.py
@@ -13,6 +13,7 @@
from django.test.client import RequestFactory
from django.urls import reverse
from edx_proctoring.exceptions import ProctoredExamNotFoundException
+from edx_toggles.toggles.testutils import override_waffle_flag
from opaque_keys import InvalidKeyError
from opaque_keys.edx.asides import AsideUsageKeyV2
from opaque_keys.edx.keys import CourseKey, UsageKey
@@ -27,18 +28,6 @@
from xblock.runtime import DictKeyValueStore, KvsFieldData
from xblock.test.tools import TestRuntime
from xblock.validation import ValidationMessage
-
-from cms.djangoapps.contentstore.tests.utils import CourseTestCase
-from cms.djangoapps.contentstore.utils import reverse_course_url, reverse_usage_url
-from cms.djangoapps.contentstore.views import item as item_module
-from common.djangoapps.student.tests.factories import UserFactory
-from common.djangoapps.xblock_django.models import (
- XBlockConfiguration,
- XBlockStudioConfiguration,
- XBlockStudioConfigurationFlag
-)
-from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService
-from lms.djangoapps.lms_xblock.mixin import NONSENSICAL_ACCESS_RESTRICTION
from xmodule.capa_module import ProblemBlock
from xmodule.course_module import DEFAULT_START_DATE
from xmodule.modulestore import ModuleStoreEnum
@@ -55,6 +44,20 @@
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.views import item as item_module
+from cms.djangoapps.contentstore.config.waffle import PREVENT_STAFF_STRUCTURE_DELETION
+from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, CourseCreatorRole
+from common.djangoapps.student.tests.factories import UserFactory
+from common.djangoapps.xblock_django.models import (
+ XBlockConfiguration,
+ XBlockStudioConfiguration,
+ XBlockStudioConfigurationFlag
+)
+from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService
+from lms.djangoapps.lms_xblock.mixin import NONSENSICAL_ACCESS_RESTRICTION
+
from ..component import component_handler, get_component_templates
from ..item import (
ALWAYS,
@@ -3390,3 +3393,147 @@ def test_self_paced_item_visibility_state(self, store_type):
# 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)
+
+ def test_staff_show_delete_button(self):
+ """
+ Test delete button is *not visible* to user with CourseStaffRole
+ """
+ # Add user as course staff
+ CourseStaffRole(self.course_key).add_users(self.user)
+
+ # Get xblock outline
+ xblock_info = create_xblock_info(
+ self.course,
+ include_child_info=True,
+ course_outline=True,
+ include_children_predicate=lambda xblock: not xblock.category == 'vertical',
+ user=self.user
+ )
+ self.assertTrue(xblock_info['show_delete_button'])
+
+ def test_staff_show_delete_button_with_waffle(self):
+ """
+ Test delete button is *not visible* to user with CourseStaffRole and
+ PREVENT_STAFF_STRUCTURE_DELETION waffle set
+ """
+ # Add user as course staff
+ CourseStaffRole(self.course_key).add_users(self.user)
+
+ with override_waffle_flag(PREVENT_STAFF_STRUCTURE_DELETION, active=True):
+ # Get xblock outline
+ xblock_info = create_xblock_info(
+ self.course,
+ include_child_info=True,
+ course_outline=True,
+ include_children_predicate=lambda xblock: not xblock.category == 'vertical',
+ user=self.user
+ )
+
+ self.assertFalse(xblock_info['show_delete_button'])
+
+ def test_no_user_show_delete_button(self):
+ """
+ Test delete button is *visible* when user attribute is not set on
+ xblock. This happens with ajax requests.
+ """
+ # Get xblock outline
+ xblock_info = create_xblock_info(
+ self.course,
+ include_child_info=True,
+ course_outline=True,
+ include_children_predicate=lambda xblock: not xblock.category == 'vertical',
+ user=None
+ )
+ self.assertTrue(xblock_info['show_delete_button'])
+
+ def test_no_user_show_delete_button_with_waffle(self):
+ """
+ Test delete button is *visible* when user attribute is not set on
+ xblock (this happens with ajax requests) and PREVENT_STAFF_STRUCTURE_DELETION waffle set.
+ """
+
+ with override_waffle_flag(PREVENT_STAFF_STRUCTURE_DELETION, active=True):
+ # Get xblock outline
+ xblock_info = create_xblock_info(
+ self.course,
+ include_child_info=True,
+ course_outline=True,
+ include_children_predicate=lambda xblock: not xblock.category == 'vertical',
+ user=None
+ )
+
+ self.assertFalse(xblock_info['show_delete_button'])
+
+ def test_instructor_show_delete_button(self):
+ """
+ Test delete button is *visible* to user with CourseInstructorRole only
+ """
+ # Add user as course instructor
+ CourseInstructorRole(self.course_key).add_users(self.user)
+
+ # Get xblock outline
+ xblock_info = create_xblock_info(
+ self.course,
+ include_child_info=True,
+ course_outline=True,
+ include_children_predicate=lambda xblock: not xblock.category == 'vertical',
+ user=self.user
+ )
+ self.assertTrue(xblock_info['show_delete_button'])
+
+ def test_instructor_show_delete_button_with_waffle(self):
+ """
+ Test delete button is *visible* to user with CourseInstructorRole only
+ and PREVENT_STAFF_STRUCTURE_DELETION waffle set
+ """
+ # Add user as course instructor
+ CourseInstructorRole(self.course_key).add_users(self.user)
+
+ with override_waffle_flag(PREVENT_STAFF_STRUCTURE_DELETION, active=True):
+ # Get xblock outline
+ xblock_info = create_xblock_info(
+ self.course,
+ include_child_info=True,
+ course_outline=True,
+ include_children_predicate=lambda xblock: not xblock.category == 'vertical',
+ user=self.user
+ )
+
+ self.assertTrue(xblock_info['show_delete_button'])
+
+ def test_creator_show_delete_button(self):
+ """
+ Test delete button is *visible* to user with CourseInstructorRole only
+ """
+ # Add user as course creator
+ CourseCreatorRole(self.course_key).add_users(self.user)
+
+ # Get xblock outline
+ xblock_info = create_xblock_info(
+ self.course,
+ include_child_info=True,
+ course_outline=True,
+ include_children_predicate=lambda xblock: not xblock.category == 'vertical',
+ user=self.user
+ )
+ self.assertTrue(xblock_info['show_delete_button'])
+
+ def test_creator_show_delete_button_with_waffle(self):
+ """
+ Test delete button is *visible* to user with CourseInstructorRole only
+ and PREVENT_STAFF_STRUCTURE_DELETION waffle set
+ """
+ # Add user as course creator
+ CourseCreatorRole(self.course_key).add_users(self.user)
+
+ with override_waffle_flag(PREVENT_STAFF_STRUCTURE_DELETION, active=True):
+ # Get xblock outline
+ xblock_info = create_xblock_info(
+ self.course,
+ include_child_info=True,
+ course_outline=True,
+ include_children_predicate=lambda xblock: not xblock.category == 'vertical',
+ user=self.user
+ )
+
+ self.assertFalse(xblock_info['show_delete_button'])
diff --git a/cms/static/js/spec/views/pages/course_outline_spec.js b/cms/static/js/spec/views/pages/course_outline_spec.js
index b4fd8143b768..f6388d6f30cb 100644
--- a/cms/static/js/spec/views/pages/course_outline_spec.js
+++ b/cms/static/js/spec/views/pages/course_outline_spec.js
@@ -40,7 +40,8 @@ describe('CourseOutlinePage', function() {
user_partitions: [],
user_partition_info: {},
highlights_enabled: true,
- highlights_enabled_for_messaging: false
+ highlights_enabled_for_messaging: false,
+ show_delete_button: true
}, options, {child_info: {children: children}});
};
@@ -67,7 +68,8 @@ describe('CourseOutlinePage', function() {
show_review_rules: true,
user_partition_info: {},
highlights_enabled: true,
- highlights_enabled_for_messaging: false
+ highlights_enabled_for_messaging: false,
+ show_delete_button: true
}, options, {child_info: {children: children}});
};
@@ -92,7 +94,8 @@ describe('CourseOutlinePage', function() {
group_access: {},
user_partition_info: {},
highlights: [],
- highlights_enabled: true
+ highlights_enabled: true,
+ show_delete_button: true
}, options, {child_info: {children: children}});
};
@@ -122,7 +125,8 @@ describe('CourseOutlinePage', function() {
},
user_partitions: [],
group_access: {},
- user_partition_info: {}
+ user_partition_info: {},
+ show_delete_button: true
}, options, {child_info: {children: children}});
};
@@ -140,7 +144,8 @@ describe('CourseOutlinePage', function() {
edited_by: 'MockUser',
user_partitions: [],
group_access: {},
- user_partition_info: {}
+ user_partition_info: {},
+ show_delete_button: true
}, options);
};
@@ -857,6 +862,13 @@ describe('CourseOutlinePage', function() {
expect(outlinePage.$('[data-locator="mock-section-2"]')).toExist();
});
+ it('remains un-visible if show_delete_button is false ', function() {
+ createCourseOutlinePage(this, createMockCourseJSON({show_delete_button: false}, [
+ createMockSectionJSON({show_delete_button: false})
+ ]));
+ expect(getItemHeaders('section').find('.delete-button').first()).not.toExist();
+ });
+
it('can be deleted if it is the only section', function() {
var promptSpy = EditHelpers.createPromptSpy();
createCourseOutlinePage(this, mockSingleSectionCourseJSON);
diff --git a/cms/static/js/views/xblock_outline.js b/cms/static/js/views/xblock_outline.js
index badf43dc1fa9..2d63ec774909 100644
--- a/cms/static/js/views/xblock_outline.js
+++ b/cms/static/js/views/xblock_outline.js
@@ -109,7 +109,8 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/compo
includesChildren: this.shouldRenderChildren(),
hasExplicitStaffLock: this.model.get('has_explicit_staff_lock'),
staffOnlyMessage: this.model.get('staff_only_message'),
- course: course
+ course: course,
+ showDeleteButton: this.model.get('show_delete_button')
};
},
diff --git a/cms/templates/js/course-outline.underscore b/cms/templates/js/course-outline.underscore
index df43d0913bba..23dd9f8efff7 100644
--- a/cms/templates/js/course-outline.underscore
+++ b/cms/templates/js/course-outline.underscore
@@ -161,7 +161,7 @@ if (is_proctored_exam) {
<% } %>
- <% if (xblockInfo.isDeletable()) { %>
+ <% if (xblockInfo.isDeletable() && showDeleteButton) { %>
diff --git a/cms/templates/js/certificate-editor.underscore b/cms/templates/js/certificate-editor.underscore
index 513113b80500..3b1d90969b5a 100644
--- a/cms/templates/js/certificate-editor.underscore
+++ b/cms/templates/js/certificate-editor.underscore
@@ -31,6 +31,11 @@
" value="<%- course_title %>" aria-describedby="certificate-course-title-<%-uniqueId %>-tip" />
<%- gettext("Specify an alternative to the official course title to display on certificates. Leave blank to use the official course title.") %>
+
+
+ " value="<%- course_description %>" aria-describedby="certificate-course-description-<%-uniqueId %>-tip" />
+ <%- gettext("Specify an alternative to the official course description to display on certificates. Leave blank to use default text.") %>
+