[^\]]+)",
'YOUTUBE_URL_BASE': 'https://www.youtube.com/watch?v=',
+ 'ALLOWED_LANGUAGE_CODES': ["en", "en-US", "en-GB"],
},
'IMAGE_API': 'http://img.youtube.com/vi/{youtube_id}/0.jpg', # /maxresdefault.jpg for 1920*1080
diff --git a/lms/static/js/i18n/eo/djangojs.js b/lms/static/js/i18n/eo/djangojs.js
index 4cd0f31106..bbc836b794 100644
--- a/lms/static/js/i18n/eo/djangojs.js
+++ b/lms/static/js/i18n/eo/djangojs.js
@@ -1158,6 +1158,7 @@
"New Password": "N\u00e9w P\u00e4ssw\u00f6rd \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455#",
"New document": "N\u00e9w d\u00f6\u00e7\u00fcm\u00e9nt \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455#",
"New enrollment mode:": "N\u00e9w \u00e9nr\u00f6llm\u00e9nt m\u00f6d\u00e9: \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, #",
+ "New files were added to this course's Files & Uploads": "N\u00e9w f\u00efl\u00e9s w\u00e9r\u00e9 \u00e4dd\u00e9d t\u00f6 th\u00efs \u00e7\u00f6\u00fcrs\u00e9's F\u00efl\u00e9s & \u00dbpl\u00f6\u00e4ds \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1#",
"New window": "N\u00e9w w\u00efnd\u00f6w \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3#",
"New {component_type}": "N\u00e9w {component_type} \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c #",
"Next": "N\u00e9xt \u2c60'\u03c3\u044f\u0454\u043c \u03b9#",
@@ -1617,6 +1618,7 @@
"Skip": "Sk\u00efp \u2c60'\u03c3\u044f\u0454\u043c \u03b9#",
"Social Media Links": "S\u00f6\u00e7\u00ef\u00e4l M\u00e9d\u00ef\u00e4 L\u00efnks \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442#",
"Some Rights Reserved": "S\u00f6m\u00e9 R\u00efghts R\u00e9s\u00e9rv\u00e9d \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, #",
+ "Some errors occurred": "S\u00f6m\u00e9 \u00e9rr\u00f6rs \u00f6\u00e7\u00e7\u00fcrr\u00e9d \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, #",
"Some images in this post have been omitted": "S\u00f6m\u00e9 \u00efm\u00e4g\u00e9s \u00efn th\u00efs p\u00f6st h\u00e4v\u00e9 \u00df\u00e9\u00e9n \u00f6m\u00eftt\u00e9d \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f #",
"Something has gone wrong ending your exam. Please double-check that the application is running.": "S\u00f6m\u00e9th\u00efng h\u00e4s g\u00f6n\u00e9 wr\u00f6ng \u00e9nd\u00efng \u00fd\u00f6\u00fcr \u00e9x\u00e4m. Pl\u00e9\u00e4s\u00e9 d\u00f6\u00fc\u00dfl\u00e9-\u00e7h\u00e9\u00e7k th\u00e4t th\u00e9 \u00e4ppl\u00ef\u00e7\u00e4t\u00ef\u00f6n \u00efs r\u00fcnn\u00efng. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2#",
"Something has gone wrong ending your exam. Please reload the page and start again.": "S\u00f6m\u00e9th\u00efng h\u00e4s g\u00f6n\u00e9 wr\u00f6ng \u00e9nd\u00efng \u00fd\u00f6\u00fcr \u00e9x\u00e4m. Pl\u00e9\u00e4s\u00e9 r\u00e9l\u00f6\u00e4d th\u00e9 p\u00e4g\u00e9 \u00e4nd st\u00e4rt \u00e4g\u00e4\u00efn. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454#",
@@ -1785,9 +1787,12 @@
"The following email addresses and/or usernames are invalid:": "Th\u00e9 f\u00f6ll\u00f6w\u00efng \u00e9m\u00e4\u00efl \u00e4ddr\u00e9ss\u00e9s \u00e4nd/\u00f6r \u00fcs\u00e9rn\u00e4m\u00e9s \u00e4r\u00e9 \u00efnv\u00e4l\u00efd: \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1#",
"The following errors were generated:": "Th\u00e9 f\u00f6ll\u00f6w\u00efng \u00e9rr\u00f6rs w\u00e9r\u00e9 g\u00e9n\u00e9r\u00e4t\u00e9d: \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5#",
"The following file types are not allowed: ": "Th\u00e9 f\u00f6ll\u00f6w\u00efng f\u00efl\u00e9 t\u00fdp\u00e9s \u00e4r\u00e9 n\u00f6t \u00e4ll\u00f6w\u00e9d: \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f #",
+ "The following files already exist in this course but don't match the version used by the component you pasted:": "Th\u00e9 f\u00f6ll\u00f6w\u00efng f\u00efl\u00e9s \u00e4lr\u00e9\u00e4d\u00fd \u00e9x\u00efst \u00efn th\u00efs \u00e7\u00f6\u00fcrs\u00e9 \u00df\u00fct d\u00f6n't m\u00e4t\u00e7h th\u00e9 v\u00e9rs\u00ef\u00f6n \u00fcs\u00e9d \u00df\u00fd th\u00e9 \u00e7\u00f6mp\u00f6n\u00e9nt \u00fd\u00f6\u00fc p\u00e4st\u00e9d: \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455#",
"The following information is already a part of your {platform} profile. We've included it here for your application.": "Th\u00e9 f\u00f6ll\u00f6w\u00efng \u00efnf\u00f6rm\u00e4t\u00ef\u00f6n \u00efs \u00e4lr\u00e9\u00e4d\u00fd \u00e4 p\u00e4rt \u00f6f \u00fd\u00f6\u00fcr {platform} pr\u00f6f\u00efl\u00e9. W\u00e9'v\u00e9 \u00efn\u00e7l\u00fcd\u00e9d \u00eft h\u00e9r\u00e9 f\u00f6r \u00fd\u00f6\u00fcr \u00e4ppl\u00ef\u00e7\u00e4t\u00ef\u00f6n. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455#",
"The following message will be displayed at the bottom of the courseware pages within your course:": "Th\u00e9 f\u00f6ll\u00f6w\u00efng m\u00e9ss\u00e4g\u00e9 w\u00efll \u00df\u00e9 d\u00efspl\u00e4\u00fd\u00e9d \u00e4t th\u00e9 \u00df\u00f6tt\u00f6m \u00f6f th\u00e9 \u00e7\u00f6\u00fcrs\u00e9w\u00e4r\u00e9 p\u00e4g\u00e9s w\u00efth\u00efn \u00fd\u00f6\u00fcr \u00e7\u00f6\u00fcrs\u00e9: \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, #",
"The following options are available for the {license_name} license.": "Th\u00e9 f\u00f6ll\u00f6w\u00efng \u00f6pt\u00ef\u00f6ns \u00e4r\u00e9 \u00e4v\u00e4\u00efl\u00e4\u00dfl\u00e9 f\u00f6r th\u00e9 {license_name} l\u00ef\u00e7\u00e9ns\u00e9. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1#",
+ "The following required files could not be added to the course:": "Th\u00e9 f\u00f6ll\u00f6w\u00efng r\u00e9q\u00fc\u00efr\u00e9d f\u00efl\u00e9s \u00e7\u00f6\u00fcld n\u00f6t \u00df\u00e9 \u00e4dd\u00e9d t\u00f6 th\u00e9 \u00e7\u00f6\u00fcrs\u00e9: \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1#",
+ "The following required files were imported to this course:": "Th\u00e9 f\u00f6ll\u00f6w\u00efng r\u00e9q\u00fc\u00efr\u00e9d f\u00efl\u00e9s w\u00e9r\u00e9 \u00efmp\u00f6rt\u00e9d t\u00f6 th\u00efs \u00e7\u00f6\u00fcrs\u00e9: \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1#",
"The following users are no longer enrolled in the course:": "Th\u00e9 f\u00f6ll\u00f6w\u00efng \u00fcs\u00e9rs \u00e4r\u00e9 n\u00f6 l\u00f6ng\u00e9r \u00e9nr\u00f6ll\u00e9d \u00efn th\u00e9 \u00e7\u00f6\u00fcrs\u00e9: \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1#",
"The following warnings were generated:": "Th\u00e9 f\u00f6ll\u00f6w\u00efng w\u00e4rn\u00efngs w\u00e9r\u00e9 g\u00e9n\u00e9r\u00e4t\u00e9d: \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f#",
"The general category for this type of assignment, for example, Homework or Midterm Exam. This name is visible to learners.": "Th\u00e9 g\u00e9n\u00e9r\u00e4l \u00e7\u00e4t\u00e9g\u00f6r\u00fd f\u00f6r th\u00efs t\u00fdp\u00e9 \u00f6f \u00e4ss\u00efgnm\u00e9nt, f\u00f6r \u00e9x\u00e4mpl\u00e9, H\u00f6m\u00e9w\u00f6rk \u00f6r M\u00efdt\u00e9rm \u00c9x\u00e4m. Th\u00efs n\u00e4m\u00e9 \u00efs v\u00efs\u00ef\u00dfl\u00e9 t\u00f6 l\u00e9\u00e4rn\u00e9rs. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455#",
@@ -2284,6 +2289,7 @@
"You may also lose access to verified certificates and other program credentials like MicroMasters certificates. If you want to make a copy of these for your records before proceeding with deletion, follow the instructions for {htmlStart}printing or downloading a certificate{htmlEnd}.": "\u00dd\u00f6\u00fc m\u00e4\u00fd \u00e4ls\u00f6 l\u00f6s\u00e9 \u00e4\u00e7\u00e7\u00e9ss t\u00f6 v\u00e9r\u00eff\u00ef\u00e9d \u00e7\u00e9rt\u00eff\u00ef\u00e7\u00e4t\u00e9s \u00e4nd \u00f6th\u00e9r pr\u00f6gr\u00e4m \u00e7r\u00e9d\u00e9nt\u00ef\u00e4ls l\u00efk\u00e9 M\u00ef\u00e7r\u00f6M\u00e4st\u00e9rs \u00e7\u00e9rt\u00eff\u00ef\u00e7\u00e4t\u00e9s. \u00ccf \u00fd\u00f6\u00fc w\u00e4nt t\u00f6 m\u00e4k\u00e9 \u00e4 \u00e7\u00f6p\u00fd \u00f6f th\u00e9s\u00e9 f\u00f6r \u00fd\u00f6\u00fcr r\u00e9\u00e7\u00f6rds \u00df\u00e9f\u00f6r\u00e9 pr\u00f6\u00e7\u00e9\u00e9d\u00efng w\u00efth d\u00e9l\u00e9t\u00ef\u00f6n, f\u00f6ll\u00f6w th\u00e9 \u00efnstr\u00fc\u00e7t\u00ef\u00f6ns f\u00f6r {htmlStart}pr\u00efnt\u00efng \u00f6r d\u00f6wnl\u00f6\u00e4d\u00efng \u00e4 \u00e7\u00e9rt\u00eff\u00ef\u00e7\u00e4t\u00e9{htmlEnd}. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1\u2202\u03b9\u03c1\u03b9\u0455\u03b9\u00a2\u03b9\u03b7g \u0454\u0142\u03b9\u0442, \u0455\u0454\u2202 \u2202\u03c3 \u0454\u03b9\u03c5\u0455\u043c\u03c3\u2202 \u0442\u0454\u043c\u03c1\u03c3\u044f \u03b9\u03b7\u00a2\u03b9\u2202\u03b9\u2202\u03c5\u03b7\u0442 \u03c5\u0442 \u0142\u03b1\u0432\u03c3\u044f\u0454 \u0454\u0442 \u2202\u03c3\u0142\u03c3\u044f\u0454 \u043c\u03b1g\u03b7\u03b1 \u03b1\u0142\u03b9q\u03c5\u03b1. \u03c5\u0442 \u0454\u03b7\u03b9\u043c \u03b1\u2202 \u043c\u03b9\u03b7\u03b9\u043c \u03bd\u0454\u03b7\u03b9\u03b1\u043c, q\u03c5\u03b9\u0455 \u03b7\u03c3\u0455\u0442\u044f\u03c5\u2202 \u0454\u03c7\u0454\u044f\u00a2\u03b9\u0442\u03b1\u0442\u03b9\u03c3\u03b7 \u03c5\u0142\u0142\u03b1\u043c\u00a2\u03c3 \u0142\u03b1\u0432\u03c3\u044f\u03b9\u0455 \u03b7\u03b9\u0455\u03b9 \u03c5\u0442 \u03b1\u0142\u03b9q\u03c5\u03b9\u03c1 \u0454\u03c7 \u0454\u03b1 \u00a2\u03c3\u043c\u043c\u03c3\u2202\u03c3 \u00a2\u03c3\u03b7\u0455\u0454q\u03c5\u03b1\u0442. \u2202#",
"You may be able to complete the image capture procedure without assistance, but it may take a couple of submission attempts to get the camera positioning right. Optimal camera positioning varies with each computer, but generally the best position for a headshot is approximately 12-18 inches (30-45 centimeters) from the camera, with your head centered relative to the computer screen. ": "\u00dd\u00f6\u00fc m\u00e4\u00fd \u00df\u00e9 \u00e4\u00dfl\u00e9 t\u00f6 \u00e7\u00f6mpl\u00e9t\u00e9 th\u00e9 \u00efm\u00e4g\u00e9 \u00e7\u00e4pt\u00fcr\u00e9 pr\u00f6\u00e7\u00e9d\u00fcr\u00e9 w\u00efth\u00f6\u00fct \u00e4ss\u00efst\u00e4n\u00e7\u00e9, \u00df\u00fct \u00eft m\u00e4\u00fd t\u00e4k\u00e9 \u00e4 \u00e7\u00f6\u00fcpl\u00e9 \u00f6f s\u00fc\u00dfm\u00efss\u00ef\u00f6n \u00e4tt\u00e9mpts t\u00f6 g\u00e9t th\u00e9 \u00e7\u00e4m\u00e9r\u00e4 p\u00f6s\u00eft\u00ef\u00f6n\u00efng r\u00efght. \u00d6pt\u00efm\u00e4l \u00e7\u00e4m\u00e9r\u00e4 p\u00f6s\u00eft\u00ef\u00f6n\u00efng v\u00e4r\u00ef\u00e9s w\u00efth \u00e9\u00e4\u00e7h \u00e7\u00f6mp\u00fct\u00e9r, \u00df\u00fct g\u00e9n\u00e9r\u00e4ll\u00fd th\u00e9 \u00df\u00e9st p\u00f6s\u00eft\u00ef\u00f6n f\u00f6r \u00e4 h\u00e9\u00e4dsh\u00f6t \u00efs \u00e4ppr\u00f6x\u00efm\u00e4t\u00e9l\u00fd 12-18 \u00efn\u00e7h\u00e9s (30-45 \u00e7\u00e9nt\u00efm\u00e9t\u00e9rs) fr\u00f6m th\u00e9 \u00e7\u00e4m\u00e9r\u00e4, w\u00efth \u00fd\u00f6\u00fcr h\u00e9\u00e4d \u00e7\u00e9nt\u00e9r\u00e9d r\u00e9l\u00e4t\u00efv\u00e9 t\u00f6 th\u00e9 \u00e7\u00f6mp\u00fct\u00e9r s\u00e7r\u00e9\u00e9n. #",
"You may be able to complete the image capture procedure without assistance, but it may take a couple of submission attempts to get the camera positioning right. Optimal camera positioning varies with each computer, but generally, the best position for a photo of an ID card is 8-12 inches (20-30 centimeters) from the camera, with the ID card centered relative to the camera. ": "\u00dd\u00f6\u00fc m\u00e4\u00fd \u00df\u00e9 \u00e4\u00dfl\u00e9 t\u00f6 \u00e7\u00f6mpl\u00e9t\u00e9 th\u00e9 \u00efm\u00e4g\u00e9 \u00e7\u00e4pt\u00fcr\u00e9 pr\u00f6\u00e7\u00e9d\u00fcr\u00e9 w\u00efth\u00f6\u00fct \u00e4ss\u00efst\u00e4n\u00e7\u00e9, \u00df\u00fct \u00eft m\u00e4\u00fd t\u00e4k\u00e9 \u00e4 \u00e7\u00f6\u00fcpl\u00e9 \u00f6f s\u00fc\u00dfm\u00efss\u00ef\u00f6n \u00e4tt\u00e9mpts t\u00f6 g\u00e9t th\u00e9 \u00e7\u00e4m\u00e9r\u00e4 p\u00f6s\u00eft\u00ef\u00f6n\u00efng r\u00efght. \u00d6pt\u00efm\u00e4l \u00e7\u00e4m\u00e9r\u00e4 p\u00f6s\u00eft\u00ef\u00f6n\u00efng v\u00e4r\u00ef\u00e9s w\u00efth \u00e9\u00e4\u00e7h \u00e7\u00f6mp\u00fct\u00e9r, \u00df\u00fct g\u00e9n\u00e9r\u00e4ll\u00fd, th\u00e9 \u00df\u00e9st p\u00f6s\u00eft\u00ef\u00f6n f\u00f6r \u00e4 ph\u00f6t\u00f6 \u00f6f \u00e4n \u00ccD \u00e7\u00e4rd \u00efs 8-12 \u00efn\u00e7h\u00e9s (20-30 \u00e7\u00e9nt\u00efm\u00e9t\u00e9rs) fr\u00f6m th\u00e9 \u00e7\u00e4m\u00e9r\u00e4, w\u00efth th\u00e9 \u00ccD \u00e7\u00e4rd \u00e7\u00e9nt\u00e9r\u00e9d r\u00e9l\u00e4t\u00efv\u00e9 t\u00f6 th\u00e9 \u00e7\u00e4m\u00e9r\u00e4. #",
+ "You may need to update a file(s) manually": "\u00dd\u00f6\u00fc m\u00e4\u00fd n\u00e9\u00e9d t\u00f6 \u00fcpd\u00e4t\u00e9 \u00e4 f\u00efl\u00e9(s) m\u00e4n\u00fc\u00e4ll\u00fd \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f #",
"You must be over 13 to share a full profile. If you are over 13, make sure that you have specified a birth year on the {account_settings_page_link}": "\u00dd\u00f6\u00fc m\u00fcst \u00df\u00e9 \u00f6v\u00e9r 13 t\u00f6 sh\u00e4r\u00e9 \u00e4 f\u00fcll pr\u00f6f\u00efl\u00e9. \u00ccf \u00fd\u00f6\u00fc \u00e4r\u00e9 \u00f6v\u00e9r 13, m\u00e4k\u00e9 s\u00fcr\u00e9 th\u00e4t \u00fd\u00f6\u00fc h\u00e4v\u00e9 sp\u00e9\u00e7\u00eff\u00ef\u00e9d \u00e4 \u00df\u00efrth \u00fd\u00e9\u00e4r \u00f6n th\u00e9 {account_settings_page_link} \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455#",
"You must enter a valid email address in order to add a new team member": "\u00dd\u00f6\u00fc m\u00fcst \u00e9nt\u00e9r \u00e4 v\u00e4l\u00efd \u00e9m\u00e4\u00efl \u00e4ddr\u00e9ss \u00efn \u00f6rd\u00e9r t\u00f6 \u00e4dd \u00e4 n\u00e9w t\u00e9\u00e4m m\u00e9m\u00df\u00e9r \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f #",
"You must have at least one undroppable <%- types %> assignment.": "\u00dd\u00f6\u00fc m\u00fcst h\u00e4v\u00e9 \u00e4t l\u00e9\u00e4st \u00f6n\u00e9 \u00fcndr\u00f6pp\u00e4\u00dfl\u00e9 <%- types %> \u00e4ss\u00efgnm\u00e9nt. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1#",
diff --git a/lms/static/js/i18n/rtl/djangojs.js b/lms/static/js/i18n/rtl/djangojs.js
index a3178df133..371b67dc0f 100644
--- a/lms/static/js/i18n/rtl/djangojs.js
+++ b/lms/static/js/i18n/rtl/djangojs.js
@@ -1082,6 +1082,7 @@
"New Password": "N\u01dd\u028d \u2c63\u0250ss\u028d\u00f8\u0279d",
"New document": "N\u01dd\u028d d\u00f8\u0254n\u026f\u01ddn\u0287",
"New enrollment mode:": "N\u01dd\u028d \u01ddn\u0279\u00f8ll\u026f\u01ddn\u0287 \u026f\u00f8d\u01dd:",
+ "New files were added to this course's Files & Uploads": "N\u01dd\u028d \u025f\u1d09l\u01dds \u028d\u01dd\u0279\u01dd \u0250dd\u01ddd \u0287\u00f8 \u0287\u0265\u1d09s \u0254\u00f8n\u0279s\u01dd's F\u1d09l\u01dds & \u0244dl\u00f8\u0250ds",
"New window": "N\u01dd\u028d \u028d\u1d09nd\u00f8\u028d",
"New {component_type}": "N\u01dd\u028d {component_type}",
"Next": "N\u01ddx\u0287",
@@ -1497,6 +1498,7 @@
"Skip": "S\u029e\u1d09d",
"Social Media Links": "S\u00f8\u0254\u1d09\u0250l M\u01ddd\u1d09\u0250 \u0141\u1d09n\u029es",
"Some Rights Reserved": "S\u00f8\u026f\u01dd \u024c\u1d09\u0183\u0265\u0287s \u024c\u01dds\u01dd\u0279\u028c\u01ddd",
+ "Some errors occurred": "S\u00f8\u026f\u01dd \u01dd\u0279\u0279\u00f8\u0279s \u00f8\u0254\u0254n\u0279\u0279\u01ddd",
"Some images in this post have been omitted": "S\u00f8\u026f\u01dd \u1d09\u026f\u0250\u0183\u01dds \u1d09n \u0287\u0265\u1d09s d\u00f8s\u0287 \u0265\u0250\u028c\u01dd b\u01dd\u01ddn \u00f8\u026f\u1d09\u0287\u0287\u01ddd",
"Something went wrong changing this enrollment. Please try again.": "S\u00f8\u026f\u01dd\u0287\u0265\u1d09n\u0183 \u028d\u01ddn\u0287 \u028d\u0279\u00f8n\u0183 \u0254\u0265\u0250n\u0183\u1d09n\u0183 \u0287\u0265\u1d09s \u01ddn\u0279\u00f8ll\u026f\u01ddn\u0287. \u2c63l\u01dd\u0250s\u01dd \u0287\u0279\u028e \u0250\u0183\u0250\u1d09n.",
"Something went wrong. Please try again later.": "S\u00f8\u026f\u01dd\u0287\u0265\u1d09n\u0183 \u028d\u01ddn\u0287 \u028d\u0279\u00f8n\u0183. \u2c63l\u01dd\u0250s\u01dd \u0287\u0279\u028e \u0250\u0183\u0250\u1d09n l\u0250\u0287\u01dd\u0279.",
@@ -1654,9 +1656,12 @@
"The file you are trying to upload is too large.": "\u0641\u0627\u062b \u0628\u0647\u0645\u062b \u063a\u062e\u0639 \u0634\u0642\u062b \u0641\u0642\u063a\u0647\u0631\u0644 \u0641\u062e \u0639\u062d\u0645\u062e\u0634\u064a \u0647\u0633 \u0641\u062e\u062e \u0645\u0634\u0642\u0644\u062b.",
"The following email addresses and/or usernames are invalid:": "\u0166\u0265\u01dd \u025f\u00f8ll\u00f8\u028d\u1d09n\u0183 \u01dd\u026f\u0250\u1d09l \u0250dd\u0279\u01ddss\u01dds \u0250nd/\u00f8\u0279 ns\u01dd\u0279n\u0250\u026f\u01dds \u0250\u0279\u01dd \u1d09n\u028c\u0250l\u1d09d:",
"The following errors were generated:": "\u0166\u0265\u01dd \u025f\u00f8ll\u00f8\u028d\u1d09n\u0183 \u01dd\u0279\u0279\u00f8\u0279s \u028d\u01dd\u0279\u01dd \u0183\u01ddn\u01dd\u0279\u0250\u0287\u01ddd:",
+ "The following files already exist in this course but don't match the version used by the component you pasted:": "\u0166\u0265\u01dd \u025f\u00f8ll\u00f8\u028d\u1d09n\u0183 \u025f\u1d09l\u01dds \u0250l\u0279\u01dd\u0250d\u028e \u01ddx\u1d09s\u0287 \u1d09n \u0287\u0265\u1d09s \u0254\u00f8n\u0279s\u01dd bn\u0287 d\u00f8n'\u0287 \u026f\u0250\u0287\u0254\u0265 \u0287\u0265\u01dd \u028c\u01dd\u0279s\u1d09\u00f8n ns\u01ddd b\u028e \u0287\u0265\u01dd \u0254\u00f8\u026fd\u00f8n\u01ddn\u0287 \u028e\u00f8n d\u0250s\u0287\u01ddd:",
"The following information is already a part of your {platform} profile. We've included it here for your application.": "\u0166\u0265\u01dd \u025f\u00f8ll\u00f8\u028d\u1d09n\u0183 \u1d09n\u025f\u00f8\u0279\u026f\u0250\u0287\u1d09\u00f8n \u1d09s \u0250l\u0279\u01dd\u0250d\u028e \u0250 d\u0250\u0279\u0287 \u00f8\u025f \u028e\u00f8n\u0279 {platform} d\u0279\u00f8\u025f\u1d09l\u01dd. W\u01dd'\u028c\u01dd \u1d09n\u0254lnd\u01ddd \u1d09\u0287 \u0265\u01dd\u0279\u01dd \u025f\u00f8\u0279 \u028e\u00f8n\u0279 \u0250ddl\u1d09\u0254\u0250\u0287\u1d09\u00f8n.",
"The following message will be displayed at the bottom of the courseware pages within your course:": "\u0166\u0265\u01dd \u025f\u00f8ll\u00f8\u028d\u1d09n\u0183 \u026f\u01ddss\u0250\u0183\u01dd \u028d\u1d09ll b\u01dd d\u1d09sdl\u0250\u028e\u01ddd \u0250\u0287 \u0287\u0265\u01dd b\u00f8\u0287\u0287\u00f8\u026f \u00f8\u025f \u0287\u0265\u01dd \u0254\u00f8n\u0279s\u01dd\u028d\u0250\u0279\u01dd d\u0250\u0183\u01dds \u028d\u1d09\u0287\u0265\u1d09n \u028e\u00f8n\u0279 \u0254\u00f8n\u0279s\u01dd:",
"The following options are available for the {license_name} license.": "\u0166\u0265\u01dd \u025f\u00f8ll\u00f8\u028d\u1d09n\u0183 \u00f8d\u0287\u1d09\u00f8ns \u0250\u0279\u01dd \u0250\u028c\u0250\u1d09l\u0250bl\u01dd \u025f\u00f8\u0279 \u0287\u0265\u01dd {license_name} l\u1d09\u0254\u01ddns\u01dd.",
+ "The following required files could not be added to the course:": "\u0166\u0265\u01dd \u025f\u00f8ll\u00f8\u028d\u1d09n\u0183 \u0279\u01ddbn\u1d09\u0279\u01ddd \u025f\u1d09l\u01dds \u0254\u00f8nld n\u00f8\u0287 b\u01dd \u0250dd\u01ddd \u0287\u00f8 \u0287\u0265\u01dd \u0254\u00f8n\u0279s\u01dd:",
+ "The following required files were imported to this course:": "\u0166\u0265\u01dd \u025f\u00f8ll\u00f8\u028d\u1d09n\u0183 \u0279\u01ddbn\u1d09\u0279\u01ddd \u025f\u1d09l\u01dds \u028d\u01dd\u0279\u01dd \u1d09\u026fd\u00f8\u0279\u0287\u01ddd \u0287\u00f8 \u0287\u0265\u1d09s \u0254\u00f8n\u0279s\u01dd:",
"The following users are no longer enrolled in the course:": "\u0166\u0265\u01dd \u025f\u00f8ll\u00f8\u028d\u1d09n\u0183 ns\u01dd\u0279s \u0250\u0279\u01dd n\u00f8 l\u00f8n\u0183\u01dd\u0279 \u01ddn\u0279\u00f8ll\u01ddd \u1d09n \u0287\u0265\u01dd \u0254\u00f8n\u0279s\u01dd:",
"The following warnings were generated:": "\u0166\u0265\u01dd \u025f\u00f8ll\u00f8\u028d\u1d09n\u0183 \u028d\u0250\u0279n\u1d09n\u0183s \u028d\u01dd\u0279\u01dd \u0183\u01ddn\u01dd\u0279\u0250\u0287\u01ddd:",
"The general category for this type of assignment, for example, Homework or Midterm Exam. This name is visible to learners.": "\u0166\u0265\u01dd \u0183\u01ddn\u01dd\u0279\u0250l \u0254\u0250\u0287\u01dd\u0183\u00f8\u0279\u028e \u025f\u00f8\u0279 \u0287\u0265\u1d09s \u0287\u028ed\u01dd \u00f8\u025f \u0250ss\u1d09\u0183n\u026f\u01ddn\u0287, \u025f\u00f8\u0279 \u01ddx\u0250\u026fdl\u01dd, \u0126\u00f8\u026f\u01dd\u028d\u00f8\u0279\u029e \u00f8\u0279 M\u1d09d\u0287\u01dd\u0279\u026f \u0246x\u0250\u026f. \u0166\u0265\u1d09s n\u0250\u026f\u01dd \u1d09s \u028c\u1d09s\u1d09bl\u01dd \u0287\u00f8 l\u01dd\u0250\u0279n\u01dd\u0279s.",
@@ -2107,6 +2112,7 @@
"You may also lose access to verified certificates and other program credentials like MicroMasters certificates. If you want to make a copy of these for your records before proceeding with deletion, follow the instructions for {htmlStart}printing or downloading a certificate{htmlEnd}.": "\u024e\u00f8n \u026f\u0250\u028e \u0250ls\u00f8 l\u00f8s\u01dd \u0250\u0254\u0254\u01ddss \u0287\u00f8 \u028c\u01dd\u0279\u1d09\u025f\u1d09\u01ddd \u0254\u01dd\u0279\u0287\u1d09\u025f\u1d09\u0254\u0250\u0287\u01dds \u0250nd \u00f8\u0287\u0265\u01dd\u0279 d\u0279\u00f8\u0183\u0279\u0250\u026f \u0254\u0279\u01ddd\u01ddn\u0287\u1d09\u0250ls l\u1d09\u029e\u01dd M\u1d09\u0254\u0279\u00f8M\u0250s\u0287\u01dd\u0279s \u0254\u01dd\u0279\u0287\u1d09\u025f\u1d09\u0254\u0250\u0287\u01dds. \u0197\u025f \u028e\u00f8n \u028d\u0250n\u0287 \u0287\u00f8 \u026f\u0250\u029e\u01dd \u0250 \u0254\u00f8d\u028e \u00f8\u025f \u0287\u0265\u01dds\u01dd \u025f\u00f8\u0279 \u028e\u00f8n\u0279 \u0279\u01dd\u0254\u00f8\u0279ds b\u01dd\u025f\u00f8\u0279\u01dd d\u0279\u00f8\u0254\u01dd\u01ddd\u1d09n\u0183 \u028d\u1d09\u0287\u0265 d\u01ddl\u01dd\u0287\u1d09\u00f8n, \u025f\u00f8ll\u00f8\u028d \u0287\u0265\u01dd \u1d09ns\u0287\u0279n\u0254\u0287\u1d09\u00f8ns \u025f\u00f8\u0279 {htmlStart}d\u0279\u1d09n\u0287\u1d09n\u0183 \u00f8\u0279 d\u00f8\u028dnl\u00f8\u0250d\u1d09n\u0183 \u0250 \u0254\u01dd\u0279\u0287\u1d09\u025f\u1d09\u0254\u0250\u0287\u01dd{htmlEnd}.",
"You may be able to complete the image capture procedure without assistance, but it may take a couple of submission attempts to get the camera positioning right. Optimal camera positioning varies with each computer, but generally the best position for a headshot is approximately 12-18 inches (30-45 centimeters) from the camera, with your head centered relative to the computer screen. ": "\u024e\u00f8n \u026f\u0250\u028e b\u01dd \u0250bl\u01dd \u0287\u00f8 \u0254\u00f8\u026fdl\u01dd\u0287\u01dd \u0287\u0265\u01dd \u1d09\u026f\u0250\u0183\u01dd \u0254\u0250d\u0287n\u0279\u01dd d\u0279\u00f8\u0254\u01dddn\u0279\u01dd \u028d\u1d09\u0287\u0265\u00f8n\u0287 \u0250ss\u1d09s\u0287\u0250n\u0254\u01dd, bn\u0287 \u1d09\u0287 \u026f\u0250\u028e \u0287\u0250\u029e\u01dd \u0250 \u0254\u00f8ndl\u01dd \u00f8\u025f snb\u026f\u1d09ss\u1d09\u00f8n \u0250\u0287\u0287\u01dd\u026fd\u0287s \u0287\u00f8 \u0183\u01dd\u0287 \u0287\u0265\u01dd \u0254\u0250\u026f\u01dd\u0279\u0250 d\u00f8s\u1d09\u0287\u1d09\u00f8n\u1d09n\u0183 \u0279\u1d09\u0183\u0265\u0287. \u00d8d\u0287\u1d09\u026f\u0250l \u0254\u0250\u026f\u01dd\u0279\u0250 d\u00f8s\u1d09\u0287\u1d09\u00f8n\u1d09n\u0183 \u028c\u0250\u0279\u1d09\u01dds \u028d\u1d09\u0287\u0265 \u01dd\u0250\u0254\u0265 \u0254\u00f8\u026fdn\u0287\u01dd\u0279, bn\u0287 \u0183\u01ddn\u01dd\u0279\u0250ll\u028e \u0287\u0265\u01dd b\u01dds\u0287 d\u00f8s\u1d09\u0287\u1d09\u00f8n \u025f\u00f8\u0279 \u0250 \u0265\u01dd\u0250ds\u0265\u00f8\u0287 \u1d09s \u0250dd\u0279\u00f8x\u1d09\u026f\u0250\u0287\u01ddl\u028e 12-18 \u1d09n\u0254\u0265\u01dds (30-45 \u0254\u01ddn\u0287\u1d09\u026f\u01dd\u0287\u01dd\u0279s) \u025f\u0279\u00f8\u026f \u0287\u0265\u01dd \u0254\u0250\u026f\u01dd\u0279\u0250, \u028d\u1d09\u0287\u0265 \u028e\u00f8n\u0279 \u0265\u01dd\u0250d \u0254\u01ddn\u0287\u01dd\u0279\u01ddd \u0279\u01ddl\u0250\u0287\u1d09\u028c\u01dd \u0287\u00f8 \u0287\u0265\u01dd \u0254\u00f8\u026fdn\u0287\u01dd\u0279 s\u0254\u0279\u01dd\u01ddn. ",
"You may be able to complete the image capture procedure without assistance, but it may take a couple of submission attempts to get the camera positioning right. Optimal camera positioning varies with each computer, but generally, the best position for a photo of an ID card is 8-12 inches (20-30 centimeters) from the camera, with the ID card centered relative to the camera. ": "\u024e\u00f8n \u026f\u0250\u028e b\u01dd \u0250bl\u01dd \u0287\u00f8 \u0254\u00f8\u026fdl\u01dd\u0287\u01dd \u0287\u0265\u01dd \u1d09\u026f\u0250\u0183\u01dd \u0254\u0250d\u0287n\u0279\u01dd d\u0279\u00f8\u0254\u01dddn\u0279\u01dd \u028d\u1d09\u0287\u0265\u00f8n\u0287 \u0250ss\u1d09s\u0287\u0250n\u0254\u01dd, bn\u0287 \u1d09\u0287 \u026f\u0250\u028e \u0287\u0250\u029e\u01dd \u0250 \u0254\u00f8ndl\u01dd \u00f8\u025f snb\u026f\u1d09ss\u1d09\u00f8n \u0250\u0287\u0287\u01dd\u026fd\u0287s \u0287\u00f8 \u0183\u01dd\u0287 \u0287\u0265\u01dd \u0254\u0250\u026f\u01dd\u0279\u0250 d\u00f8s\u1d09\u0287\u1d09\u00f8n\u1d09n\u0183 \u0279\u1d09\u0183\u0265\u0287. \u00d8d\u0287\u1d09\u026f\u0250l \u0254\u0250\u026f\u01dd\u0279\u0250 d\u00f8s\u1d09\u0287\u1d09\u00f8n\u1d09n\u0183 \u028c\u0250\u0279\u1d09\u01dds \u028d\u1d09\u0287\u0265 \u01dd\u0250\u0254\u0265 \u0254\u00f8\u026fdn\u0287\u01dd\u0279, bn\u0287 \u0183\u01ddn\u01dd\u0279\u0250ll\u028e, \u0287\u0265\u01dd b\u01dds\u0287 d\u00f8s\u1d09\u0287\u1d09\u00f8n \u025f\u00f8\u0279 \u0250 d\u0265\u00f8\u0287\u00f8 \u00f8\u025f \u0250n \u0197\u0110 \u0254\u0250\u0279d \u1d09s 8-12 \u1d09n\u0254\u0265\u01dds (20-30 \u0254\u01ddn\u0287\u1d09\u026f\u01dd\u0287\u01dd\u0279s) \u025f\u0279\u00f8\u026f \u0287\u0265\u01dd \u0254\u0250\u026f\u01dd\u0279\u0250, \u028d\u1d09\u0287\u0265 \u0287\u0265\u01dd \u0197\u0110 \u0254\u0250\u0279d \u0254\u01ddn\u0287\u01dd\u0279\u01ddd \u0279\u01ddl\u0250\u0287\u1d09\u028c\u01dd \u0287\u00f8 \u0287\u0265\u01dd \u0254\u0250\u026f\u01dd\u0279\u0250. ",
+ "You may need to update a file(s) manually": "\u024e\u00f8n \u026f\u0250\u028e n\u01dd\u01ddd \u0287\u00f8 ndd\u0250\u0287\u01dd \u0250 \u025f\u1d09l\u01dd(s) \u026f\u0250nn\u0250ll\u028e",
"You must be over 13 to share a full profile. If you are over 13, make sure that you have specified a birth year on the {account_settings_page_link}": "\u024e\u00f8n \u026fns\u0287 b\u01dd \u00f8\u028c\u01dd\u0279 13 \u0287\u00f8 s\u0265\u0250\u0279\u01dd \u0250 \u025fnll d\u0279\u00f8\u025f\u1d09l\u01dd. \u0197\u025f \u028e\u00f8n \u0250\u0279\u01dd \u00f8\u028c\u01dd\u0279 13, \u026f\u0250\u029e\u01dd sn\u0279\u01dd \u0287\u0265\u0250\u0287 \u028e\u00f8n \u0265\u0250\u028c\u01dd sd\u01dd\u0254\u1d09\u025f\u1d09\u01ddd \u0250 b\u1d09\u0279\u0287\u0265 \u028e\u01dd\u0250\u0279 \u00f8n \u0287\u0265\u01dd {account_settings_page_link}",
"You must enter a valid email address in order to add a new team member": "\u024e\u00f8n \u026fns\u0287 \u01ddn\u0287\u01dd\u0279 \u0250 \u028c\u0250l\u1d09d \u01dd\u026f\u0250\u1d09l \u0250dd\u0279\u01ddss \u1d09n \u00f8\u0279d\u01dd\u0279 \u0287\u00f8 \u0250dd \u0250 n\u01dd\u028d \u0287\u01dd\u0250\u026f \u026f\u01dd\u026fb\u01dd\u0279",
"You must have at least one undroppable <%- types %> assignment.": "\u024e\u00f8n \u026fns\u0287 \u0265\u0250\u028c\u01dd \u0250\u0287 l\u01dd\u0250s\u0287 \u00f8n\u01dd nnd\u0279\u00f8dd\u0250bl\u01dd <%- types %> \u0250ss\u1d09\u0183n\u026f\u01ddn\u0287.",
diff --git a/openedx/core/djangoapps/bookmarks/serializers.py b/openedx/core/djangoapps/bookmarks/serializers.py
index 37148cc6f2..67ca301b67 100644
--- a/openedx/core/djangoapps/bookmarks/serializers.py
+++ b/openedx/core/djangoapps/bookmarks/serializers.py
@@ -51,7 +51,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
# Drop any fields that are not specified in the `fields` argument.
required_fields = set(fields)
- if 'request' in kwargs['context'] and is_schema_request(kwargs['context']['request']):
+ if 'context' in kwargs and 'request' in kwargs['context'] and is_schema_request(kwargs['context']['request']):
# We are serving the schema: include everything
required_fields.update(OPTIONAL_FIELDS)
diff --git a/openedx/core/djangoapps/content/block_structure/config/__init__.py b/openedx/core/djangoapps/content/block_structure/config/__init__.py
index 9bac61bf9b..df82bfb068 100644
--- a/openedx/core/djangoapps/content/block_structure/config/__init__.py
+++ b/openedx/core/djangoapps/content/block_structure/config/__init__.py
@@ -10,23 +10,6 @@ from openedx.core.lib.cache_utils import request_cached
from .models import BlockStructureConfiguration
# Switches
-# .. toggle_name: block_structure.invalidate_cache_on_publish
-# .. toggle_implementation: WaffleSwitch
-# .. toggle_default: False
-# .. toggle_description: When enabled, the block structure cache is invalidated when changes to
-# courses are published. If `block_structure.storage_backing_for_cache` is active, all block
-# structures related to the published course are also cleared from storage.
-# .. toggle_warning: This switch will likely be deprecated and removed.
-# .. toggle_use_cases: temporary
-# .. toggle_creation_date: 2017-02-23
-# .. toggle_target_removal_date: 2017-05-23
-# .. toggle_tickets: https://github.com/openedx/edx-platform/pull/14358,
-# https://github.com/openedx/edx-platform/pull/14571,
-# https://openedx.atlassian.net/browse/DEPR-144
-INVALIDATE_CACHE_ON_PUBLISH = WaffleSwitch(
- "block_structure.invalidate_cache_on_publish", __name__
-)
-
# .. toggle_name: block_structure.storage_backing_for_cache
# .. toggle_implementation: WaffleSwitch
# .. toggle_default: False
diff --git a/openedx/core/djangoapps/content/block_structure/signals.py b/openedx/core/djangoapps/content/block_structure/signals.py
index d3cae5f2c0..d097216d26 100644
--- a/openedx/core/djangoapps/content/block_structure/signals.py
+++ b/openedx/core/djangoapps/content/block_structure/signals.py
@@ -10,9 +10,7 @@ from opaque_keys.edx.locator import LibraryLocator
from xmodule.modulestore.django import SignalHandler
-from . import config
from .api import clear_course_from_cache
-from .models import BlockStructureNotFound
from .tasks import update_course_in_cache_v2
log = logging.getLogger(__name__)
@@ -28,15 +26,6 @@ def update_block_structure_on_course_publish(sender, course_key, **kwargs): # p
if isinstance(course_key, LibraryLocator):
return
- if config.INVALIDATE_CACHE_ON_PUBLISH.is_enabled():
- try:
- clear_course_from_cache(course_key)
- except BlockStructureNotFound:
- log.warning(
- "BlockStructure: %s not found when trying to clear course from cache",
- course_key,
- )
-
update_course_in_cache_v2.apply_async(
kwargs=dict(course_id=str(course_key)),
countdown=settings.BLOCK_STRUCTURES_SETTINGS['COURSE_PUBLISH_TASK_DELAY'],
diff --git a/openedx/core/djangoapps/content/block_structure/tests/test_signals.py b/openedx/core/djangoapps/content/block_structure/tests/test_signals.py
index 920b663cb8..af35873125 100644
--- a/openedx/core/djangoapps/content/block_structure/tests/test_signals.py
+++ b/openedx/core/djangoapps/content/block_structure/tests/test_signals.py
@@ -5,14 +5,12 @@ from unittest.mock import patch
import pytest
import ddt
-from edx_toggles.toggles.testutils import override_waffle_switch
from opaque_keys.edx.locator import CourseLocator, LibraryLocator
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from ..api import get_block_structure_manager
-from ..config import INVALIDATE_CACHE_ON_PUBLISH
from ..signals import update_block_structure_on_course_publish
from .helpers import is_course_in_block_structure_cache
@@ -45,17 +43,6 @@ class CourseBlocksSignalTest(ModuleStoreTestCase):
updated_block_structure = bs_manager.get_collected()
assert test_display_name == updated_block_structure.get_xblock_field(self.course_usage_key, 'display_name')
- @ddt.data(True, False)
- @patch('openedx.core.djangoapps.content.block_structure.manager.BlockStructureManager.clear')
- def test_cache_invalidation(self, invalidate_cache_enabled, mock_bs_manager_clear):
- test_display_name = "Jedi 101"
-
- with override_waffle_switch(INVALIDATE_CACHE_ON_PUBLISH, active=invalidate_cache_enabled):
- self.course.display_name = test_display_name
- self.update_course(self.course, self.user.id)
-
- assert mock_bs_manager_clear.called == invalidate_cache_enabled
-
def test_course_delete(self):
bs_manager = get_block_structure_manager(self.course.id)
assert bs_manager.get_collected() is not None
diff --git a/openedx/core/djangoapps/content/course_overviews/migrations/0028_flex_peer_ora_course_setting.py b/openedx/core/djangoapps/content/course_overviews/migrations/0028_flex_peer_ora_course_setting.py
new file mode 100644
index 0000000000..5a5ad9b2b6
--- /dev/null
+++ b/openedx/core/djangoapps/content/course_overviews/migrations/0028_flex_peer_ora_course_setting.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.19 on 2023-06-28 13:35
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('course_overviews', '0027_auto_20221102_1109'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='courseoverview',
+ name='force_on_flexible_peer_openassessments',
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name='historicalcourseoverview',
+ name='force_on_flexible_peer_openassessments',
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/openedx/core/djangoapps/content/course_overviews/models.py b/openedx/core/djangoapps/content/course_overviews/models.py
index 3c7290a755..d3101b6054 100644
--- a/openedx/core/djangoapps/content/course_overviews/models.py
+++ b/openedx/core/djangoapps/content/course_overviews/models.py
@@ -64,7 +64,7 @@ class CourseOverview(TimeStampedModel):
app_label = 'course_overviews'
# IMPORTANT: Bump this whenever you modify this model and/or add a migration.
- VERSION = 18
+ VERSION = 19
# Cache entry versioning.
version = models.IntegerField()
@@ -144,6 +144,9 @@ class CourseOverview(TimeStampedModel):
entrance_exam_id = models.CharField(max_length=255, blank=True)
entrance_exam_minimum_score_pct = models.FloatField(default=0.65)
+ # Open Response Assessment configuration
+ force_on_flexible_peer_openassessments = models.BooleanField(default=False)
+
external_id = models.CharField(max_length=128, null=True, blank=True)
language = models.TextField(null=True)
@@ -268,6 +271,8 @@ class CourseOverview(TimeStampedModel):
else:
course_overview.entrance_exam_minimum_score_pct = course.entrance_exam_minimum_score_pct
+ course_overview.force_on_flexible_peer_openassessments = course.force_on_flexible_peer_openassessments
+
if not CatalogIntegration.is_enabled():
course_overview.language = course.language
diff --git a/openedx/core/djangoapps/content_staging/admin.py b/openedx/core/djangoapps/content_staging/admin.py
index 6cc567d87d..a40a13913b 100644
--- a/openedx/core/djangoapps/content_staging/admin.py
+++ b/openedx/core/djangoapps/content_staging/admin.py
@@ -4,7 +4,13 @@ Admin views for Staged Content and Clipboard
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
-from .models import StagedContent, UserClipboard
+from .models import StagedContent, StagedContentFile, UserClipboard
+
+
+class StagedContentFileInline(admin.TabularInline):
+ """ Inline admin UI for StagedContentFile """
+ model = StagedContentFile
+ readonly_fields = ('filename', 'md5_hash', 'source_key_str', 'data_file')
@admin.register(StagedContent)
@@ -14,6 +20,7 @@ class StagedContentAdmin(admin.ModelAdmin):
list_filter = ('purpose', 'status', 'block_type')
search_fields = ('user__username', 'display_name', 'suggested_url_name')
readonly_fields = ('id', 'user', 'created', 'purpose', 'status', 'block_type', 'olx')
+ inlines = (StagedContentFileInline, )
@admin.register(UserClipboard)
diff --git a/openedx/core/djangoapps/content_staging/api.py b/openedx/core/djangoapps/content_staging/api.py
index 8e43458956..87616b706d 100644
--- a/openedx/core/djangoapps/content_staging/api.py
+++ b/openedx/core/djangoapps/content_staging/api.py
@@ -4,8 +4,10 @@ Public python API for content staging
from __future__ import annotations
from django.http import HttpRequest
+from opaque_keys import InvalidKeyError
+from opaque_keys.edx.keys import AssetKey, UsageKey
-from .data import StagedContentData, StagedContentStatus, UserClipboardData
+from .data import StagedContentData, StagedContentFileData, StagedContentStatus, UserClipboardData
from .models import UserClipboard as _UserClipboard, StagedContent as _StagedContent
from .serializers import UserClipboardSerializer as _UserClipboardSerializer
@@ -75,3 +77,45 @@ def get_staged_content_olx(staged_content_id: int) -> str | None:
return sc.olx
except _StagedContent.DoesNotExist:
return None
+
+
+def get_staged_content_static_files(staged_content_id: int) -> list[StagedContentFileData]:
+ """
+ Get the filenames and metadata for any static files used by the given staged content.
+
+ Does not check permissions!
+ """
+ sc = _StagedContent.objects.get(pk=staged_content_id)
+
+ def str_to_key(source_key_str: str):
+ if not str:
+ return None
+ try:
+ return AssetKey.from_string(source_key_str)
+ except InvalidKeyError:
+ return UsageKey.from_string(source_key_str)
+
+ return [
+ StagedContentFileData(
+ filename=f.filename,
+ # For performance, we don't load data unless it's needed, so there's
+ # a separate API call for that.
+ data=None,
+ source_key=str_to_key(f.source_key_str),
+ md5_hash=f.md5_hash,
+ )
+ for f in sc.files.all()
+ ]
+
+
+def get_staged_content_static_file_data(staged_content_id: int, filename: str) -> bytes | None:
+ """
+ Get the data for the static asset associated with the given staged content.
+
+ Does not check permissions!
+ """
+ sc = _StagedContent.objects.get(pk=staged_content_id)
+ file_data_obj = sc.files.filter(filename=filename).first()
+ if file_data_obj:
+ return file_data_obj.data_file.open().read()
+ return None
diff --git a/openedx/core/djangoapps/content_staging/data.py b/openedx/core/djangoapps/content_staging/data.py
index f913891583..a33dcca470 100644
--- a/openedx/core/djangoapps/content_staging/data.py
+++ b/openedx/core/djangoapps/content_staging/data.py
@@ -1,12 +1,13 @@
"""
Public python data types for content staging
"""
+from __future__ import annotations
from attrs import field, frozen, validators
from datetime import datetime
from django.db.models import TextChoices
from django.utils.translation import gettext_lazy as _
-from opaque_keys.edx.keys import UsageKey
+from opaque_keys.edx.keys import UsageKey, AssetKey
class StagedContentStatus(TextChoices):
@@ -43,6 +44,21 @@ class StagedContentData:
display_name: str = field(validator=validators.instance_of(str))
+@frozen
+class StagedContentFileData:
+ """Read-only data model for a single file used by some staged content."""
+ filename: str = field(validator=validators.instance_of(str))
+ # Everything below is optional:
+ data: bytes | None = field(validator=validators.optional(validators.instance_of(bytes)))
+ # If this asset came from Files & Uploads in a course, this is an AssetKey
+ # as a string. If this asset came from an XBlock's filesystem, this is the
+ # UsageKey of the XBlock.
+ source_key: AssetKey | UsageKey | None = field(
+ validator=validators.optional(validators.instance_of((AssetKey, UsageKey)))
+ )
+ md5_hash: str | None = field(validator=validators.optional(validators.instance_of(str)))
+
+
@frozen
class UserClipboardData:
""" Read-only data model for User Clipboard data (copied OLX) """
diff --git a/openedx/core/djangoapps/content_staging/filters.py b/openedx/core/djangoapps/content_staging/filters.py
new file mode 100644
index 0000000000..a6e0741c31
--- /dev/null
+++ b/openedx/core/djangoapps/content_staging/filters.py
@@ -0,0 +1,55 @@
+"""
+Filters that affect the behavior of staged content (and the clipboard)
+"""
+# pylint: disable=unused-argument
+from __future__ import annotations
+
+from attrs import asdict
+
+from openedx_filters import PipelineStep
+from openedx_filters.tooling import OpenEdxPublicFilter
+from .data import StagedContentFileData
+from .models import StagedContent
+
+
+class StagingStaticAssetFilter(OpenEdxPublicFilter):
+ """
+ A filter used to determine which static assets associate with an XBlock(s)
+ should be staged in the StagedContent app (e.g. the clipboard).
+
+ This API is considered BETA. Once it is stable, this definition should be moved into openedx_filters.
+ """
+
+ filter_type = "org.openedx.content_authoring.staged_content.static_filter_source.v1"
+
+ @classmethod
+ def run_filter(cls, staged_content: StagedContent, file_datas: list[StagedContentFileData]):
+ """
+ Run this filter, which requires the following arguments:
+ staged_content (StagedContent): details of the content being staged, as saved to the DB.
+ file_datas (list[StagedContentFileData]): details of the files being staged
+ """
+ data = super().run_pipeline(staged_content=staged_content, file_datas=file_datas)
+ return data.get("file_datas")
+
+
+class IgnoreLargeFiles(PipelineStep):
+ """
+ Don't copy files over 10MB into the clipboard
+ """
+
+ # pylint: disable=arguments-differ
+ def run_filter(self, staged_content: StagedContent, file_datas: list[StagedContentFileData]):
+ """
+ Filter the list of file_datas to remove any large files
+ """
+ limit = 10 * 1024 * 1024
+
+ def remove_large_data(fd: StagedContentFileData):
+ """ Remove 'data' from the immutable StagedContentFileData object, if it's above the size limit """
+ if fd.data and len(fd.data) > limit:
+ # these data objects are immutable so make a copy with data=None:
+ return StagedContentFileData(**{**asdict(fd), "data": None})
+ return fd
+
+ return {"file_datas": [remove_large_data(fd) for fd in file_datas]}
diff --git a/openedx/core/djangoapps/content_staging/migrations/0002_stagedcontentfile.py b/openedx/core/djangoapps/content_staging/migrations/0002_stagedcontentfile.py
new file mode 100644
index 0000000000..5c3501ac0d
--- /dev/null
+++ b/openedx/core/djangoapps/content_staging/migrations/0002_stagedcontentfile.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.2.19 on 2023-06-02 00:47
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('content_staging', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='StagedContentFile',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('filename', models.CharField(max_length=255)),
+ ('data_file', models.FileField(blank=True, upload_to='staged-content-temp/')),
+ ('source_key_str', models.CharField(blank=True, max_length=255)),
+ ('md5_hash', models.CharField(blank=True, max_length=32)),
+ ('for_content', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='content_staging.stagedcontent')),
+ ],
+ ),
+ ]
diff --git a/openedx/core/djangoapps/content_staging/models.py b/openedx/core/djangoapps/content_staging/models.py
index 9f89177f27..d1a790e3b2 100644
--- a/openedx/core/djangoapps/content_staging/models.py
+++ b/openedx/core/djangoapps/content_staging/models.py
@@ -67,6 +67,25 @@ class StagedContent(models.Model):
return f'Staged {self.block_type} block "{self.display_name}" ({self.status})'
+class StagedContentFile(models.Model):
+ """
+ A data file ("Static Asset") associated with some StagedContent.
+
+ These usually come from a course's Files & Uploads page, but can also come
+ from per-xblock file storage (e.g. video transcripts or images used in
+ v2 content libraries).
+ """
+ for_content = models.ForeignKey(StagedContent, on_delete=models.CASCADE, related_name="files")
+ filename = models.CharField(max_length=255, blank=False)
+ # Everything below is optional:
+ data_file = models.FileField(upload_to="staged-content-temp/", blank=True)
+ # If this asset came from Files & Uploads in a course, this is an AssetKey
+ # as a string. If this asset came from an XBlock's filesystem, this is the
+ # UsageKey of the XBlock.
+ source_key_str = models.CharField(max_length=255, blank=True)
+ md5_hash = models.CharField(max_length=32, blank=True)
+
+
class UserClipboard(models.Model):
"""
Each user has a clipboard that can hold one item at a time, where an item
diff --git a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py
index b12da4d95c..17d6c5f9af 100644
--- a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py
+++ b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py
@@ -5,8 +5,9 @@ from textwrap import dedent
from xml.etree import ElementTree
from rest_framework.test import APIClient
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
-from xmodule.modulestore.tests.factories import ToyCourseFactory
+from xmodule.contentstore.django import contentstore
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, upload_file_to_course
+from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, ToyCourseFactory
from openedx.core.djangoapps.content_staging import api as python_api
@@ -134,7 +135,7 @@ class ClipboardTestCase(ModuleStoreTestCase):
"""
course_key, client = self._setup_course()
- # Copy the video
+ # Copy the HTML
html_key = course_key.make_usage_key("html", "toyhtml")
response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(html_key)}, format="json")
@@ -199,6 +200,67 @@ class ClipboardTestCase(ModuleStoreTestCase):
# The OLX link from the video will no longer work:
self.assertEqual(client.get(old_olx_url).status_code, 404)
+ def test_copy_static_assets(self):
+ """
+ Test copying an HTML from the course that references a static asset file.
+ """
+ course_key, client = self._setup_course()
+ # The toy course references static files that don't actually exist yet, so we have to upload one now:
+ upload_file_to_course(
+ course_key=course_key,
+ contentstore=contentstore(),
+ source_file='./common/test/data/toy/static/just_a_test.jpg',
+ target_filename="foo_bar.jpg",
+ )
+
+ # Copy the HTML XBlock to the clipboard:
+ html_key = course_key.make_usage_key("html", "just_img")
+ response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(html_key)}, format="json")
+
+ # Validate the response:
+ self.assertEqual(response.status_code, 200)
+ response_data = response.json()
+ staged_content_id = response_data["content"]["id"]
+ olx_str = python_api.get_staged_content_olx(staged_content_id)
+ assert '

' in olx_str
+ static_assets = python_api.get_staged_content_static_files(staged_content_id)
+
+ assert static_assets == [python_api.StagedContentFileData(
+ filename="foo_bar.jpg",
+ source_key=course_key.make_asset_key("asset", "foo_bar.jpg"),
+ md5_hash="addd3c218c0c0c41e7e1ae73f5969810",
+ data=None,
+ )]
+
+ def test_copy_static_assets_nonexistent(self):
+ """
+ Test copying a HTML block which references non-existent static assets.
+ """
+ _other_course_key, client = self._setup_course()
+ course = CourseFactory.create()
+ html_block = BlockFactory.create(
+ parent_location=course.location,
+ category="html",
+ display_name="Some HTML",
+ data="""
+
+ Picture 1
+ Picture 2
+
+ """,
+ )
+ # Copy the HTML
+ response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(html_block.location)}, format="json")
+
+ # Validate the response:
+ self.assertEqual(response.status_code, 200)
+ response_data = response.json()
+ staged_content_id = response_data["content"]["id"]
+ olx_str = python_api.get_staged_content_olx(staged_content_id)
+ assert '
' in olx_str
+ static_assets = python_api.get_staged_content_static_files(staged_content_id)
+ assert static_assets == []
+
def test_no_course_permission(self):
"""
Test that a user without read access cannot copy items in a course
diff --git a/openedx/core/djangoapps/content_staging/views.py b/openedx/core/djangoapps/content_staging/views.py
index ecebffda98..6e3647b590 100644
--- a/openedx/core/djangoapps/content_staging/views.py
+++ b/openedx/core/djangoapps/content_staging/views.py
@@ -1,8 +1,11 @@
"""
REST API views for content staging
"""
+from __future__ import annotations
+import hashlib
import logging
+from django.core.files.base import ContentFile
from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
@@ -17,13 +20,16 @@ from rest_framework.views import APIView
from common.djangoapps.student.auth import has_studio_read_access
from openedx.core.lib.api.view_utils import view_auth_classes
-from openedx.core.lib.xblock_serializer.api import serialize_xblock_to_olx
+from openedx.core.lib.xblock_serializer.api import serialize_xblock_to_olx, StaticFile
from xmodule import block_metadata_utils
+from xmodule.contentstore.content import StaticContent
+from xmodule.contentstore.django import contentstore
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
-from .data import CLIPBOARD_PURPOSE, StagedContentStatus
-from .models import StagedContent, UserClipboard
+from .data import CLIPBOARD_PURPOSE, StagedContentFileData, StagedContentStatus
+from .filters import StagingStaticAssetFilter
+from .models import StagedContent, StagedContentFile, UserClipboard
from .serializers import UserClipboardSerializer, PostToClipboardSerializer
from .tasks import delete_expired_clipboards
@@ -149,10 +155,76 @@ class ClipboardEndpoint(APIView):
serializer = UserClipboardSerializer(clipboard, context={"request": request})
# Log an event so we can analyze how this feature is used:
log.info(f"Copied {usage_key.block_type} component \"{usage_key}\" to their clipboard.")
+
+ # Try to copy the static files. If this fails, we still consider the overall copy attempt to have succeeded,
+ # because intra-course pasting will still work fine, and in any case users can manually resolve the file issue.
+ try:
+ self._save_static_assets_to_clipboard(block_data.static_files, usage_key, staged_content)
+ except Exception: # pylint: disable=broad-except
+ # Regardless of what happened, with get_asset_key_from_path or contentstore or run_filter, we don't want the
+ # whole "copy to clipboard" operation to fail, which would be a bad user experience. For copying and pasting
+ # within a single course, static assets don't even matter. So any such errors become warnings here.
+ log.exception(f"Unable to copy static files to clipboard for component {usage_key}")
+
# Enqueue a (potentially slow) task to delete the old staged content
try:
delete_expired_clipboards.delay(expired_ids)
- except Exception as err: # pylint: disable=broad-except
+ except Exception: # pylint: disable=broad-except
log.exception(f"Unable to enqueue cleanup task for StagedContents: {','.join(str(x) for x in expired_ids)}")
# Return the response:
return Response(serializer.data)
+
+ @staticmethod
+ def _save_static_assets_to_clipboard(
+ static_files: list[StaticFile], usage_key: UsageKey, staged_content: StagedContent
+ ):
+ """
+ Helper method for "post to clipboard" API endpoint. This deals with copying static files into the clipboard.
+ """
+ files_to_save: list[StagedContentFileData] = []
+ for f in static_files:
+ source_key = (
+ StaticContent.get_asset_key_from_path(usage_key.context_key, f.url)
+ if (f.url and f.url.startswith('/')) else None
+ )
+ # Compute the MD5 hash and get the content:
+ content: bytes | None = f.data
+ md5_hash = "" # Unknown
+ if content:
+ md5_hash = hashlib.md5(f.data).hexdigest()
+ # This asset came from the XBlock's filesystem, e.g. a video block's transcript file
+ source_key = usage_key
+ # Check if the asset file exists. It can be absent if an XBlock contains an invalid link.
+ elif source_key and (sc := contentstore().find(source_key, throw_on_not_found=False)):
+ md5_hash = sc.content_digest
+ content = sc.data
+ else:
+ continue # Skip this file - we don't need a reference to a non-existent file.
+
+ # Load the data:
+ entry = StagedContentFileData(
+ filename=f.name,
+ data=content,
+ source_key=source_key,
+ md5_hash=md5_hash,
+ )
+ files_to_save.append(entry)
+
+ # run filters on files_to_save. e.g. remove large files
+ files_to_save = StagingStaticAssetFilter.run_filter(staged_content=staged_content, file_datas=files_to_save)
+
+ for f in files_to_save:
+ try:
+ StagedContentFile.objects.create(
+ for_content=staged_content,
+ filename=f.filename,
+ # In some cases (e.g. really large files), we don't store the data here but we still keep track of
+ # the metadata. You can still use the metadata to determine if the file is already present or not,
+ # and then either inform the user or find another way to import the file (e.g. if the file still
+ # exists in the "Files & Uploads" contentstore of the source course, based on source_key_str).
+ data_file=ContentFile(content=f.data, name=f.filename) if f.data else None,
+ source_key_str=str(f.source_key) if f.source_key else "",
+ md5_hash=f.md5_hash or "",
+ )
+ except Exception: # pylint: disable=broad-except
+ log.exception(f"Unable to copy static file {f.filename} to clipboard for component {usage_key}")
diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py
index 84f7116964..8bce8898d0 100644
--- a/openedx/core/djangoapps/notifications/tests/test_views.py
+++ b/openedx/core/djangoapps/notifications/tests/test_views.py
@@ -76,9 +76,12 @@ class CourseEnrollmentListViewTest(ModuleStoreTestCase):
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
+ data = response.data['results']
enrollments = CourseEnrollment.objects.filter(user=self.user, is_active=True)
expected_data = NotificationCourseEnrollmentSerializer(enrollments, many=True).data
- self.assertEqual(response.data, expected_data)
+
+ self.assertEqual(len(data), 1)
+ self.assertEqual(data, expected_data)
def test_course_enrollment_api_permission(self):
"""
diff --git a/openedx/core/djangoapps/notifications/views.py b/openedx/core/djangoapps/notifications/views.py
index 04543dc524..f083649989 100644
--- a/openedx/core/djangoapps/notifications/views.py
+++ b/openedx/core/djangoapps/notifications/views.py
@@ -36,23 +36,31 @@ class CourseEnrollmentListView(generics.ListAPIView):
API endpoint to get active CourseEnrollments for requester.
**Permissions**: User must be authenticated.
+ **Response Format** (paginated):
- **Response Format**:
- [
- {
- "course": {
- "id": (int) course_id,
- "display_name": (str) course_display_name
+ {
+ "next": (str) url_to_next_page_of_courses,
+ "previous": (str) url_to_previous_page_of_courses,
+ "count": (int) total_number_of_courses,
+ "num_pages": (int) total_number_of_pages,
+ "current_page": (int) current_page_number,
+ "start": (int) index_of_first_course_on_page,
+ "results" : [
+ {
+ "course": {
+ "id": (int) course_id,
+ "display_name": (str) course_display_name
+ },
},
- },
- ...
- ]
- **Response Error Codes**:
- - 403: The requester cannot access resource.
+ ...
+ ],
+ }
+
+ Response Error Codes:
+ - 403: The requester cannot access resource.
"""
serializer_class = NotificationCourseEnrollmentSerializer
permission_classes = [permissions.IsAuthenticated]
- pagination_class = None
def get_queryset(self):
user = self.request.user
@@ -63,14 +71,20 @@ class CourseEnrollmentListView(generics.ListAPIView):
Returns the list of active course enrollments for which ENABLE_NOTIFICATIONS
Waffle flag is enabled
"""
- enrollment_queryset = self.get_queryset().select_related('course')
- enrollments = [
- enrollment
- for enrollment in enrollment_queryset
- if ENABLE_NOTIFICATIONS.is_enabled(enrollment.course.id)
- ]
- serializer = self.get_serializer(enrollments, many=True)
- return Response(serializer.data)
+ queryset = self.filter_queryset(self.get_queryset())
+ course_ids = queryset.values_list('course_id', flat=True)
+
+ for course_id in course_ids:
+ if not ENABLE_NOTIFICATIONS.is_enabled(course_id):
+ queryset = queryset.exclude(course_id=course_id)
+
+ queryset = queryset.select_related('course').order_by('-id')
+ page = self.paginate_queryset(queryset)
+ if page is not None:
+ serializer = self.get_serializer(page, many=True)
+ return self.get_paginated_response(serializer.data)
+
+ return Response(self.get_serializer(queryset, many=True).data)
class UserNotificationPreferenceView(APIView):
diff --git a/openedx/core/djangoapps/schedules/tests/test_resolvers.py b/openedx/core/djangoapps/schedules/tests/test_resolvers.py
index 8bc7f9b4e7..5881861b22 100644
--- a/openedx/core/djangoapps/schedules/tests/test_resolvers.py
+++ b/openedx/core/djangoapps/schedules/tests/test_resolvers.py
@@ -274,7 +274,7 @@ class TestCourseNextSectionUpdateResolver(SchedulesResolverTestMixin, ModuleStor
def test_schedule_context(self):
resolver = self.create_resolver()
# using this to make sure the select_related stays intact
- with self.assertNumQueries(40):
+ with self.assertNumQueries(30):
sc = resolver.get_schedules()
schedules = list(sc)
apple_logo_url = 'http://email-media.s3.amazonaws.com/edX/2021/store_apple_229x78.jpg'
diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py
index c9c8cf085b..1326c36a2e 100644
--- a/openedx/core/djangoapps/user_api/accounts/utils.py
+++ b/openedx/core/djangoapps/user_api/accounts/utils.py
@@ -229,13 +229,18 @@ def username_suffix_generator(suffix_length=4):
Generates a random, alternating number and letter string for the purpose of
appending to non-unique usernames. Alternating is less likey to produce
a significant/meaningful substring like an offensive word.
+ Whether the suffix starts with a letter or number is also randomized.
"""
+ # pick from letters, or numbers
+ choice_collections = [string.ascii_lowercase, string.digits]
+ # randomize which collection to pick from first
+ random.shuffle(choice_collections)
output = ''
for i in range(suffix_length):
if (i % 2) == 0:
- output += random.choice(string.ascii_lowercase)
+ output += random.choice(choice_collections[0])
else:
- output += random.choice(string.digits)
+ output += random.choice(choice_collections[1])
return output
diff --git a/openedx/core/djangoapps/user_authn/views/register.py b/openedx/core/djangoapps/user_authn/views/register.py
index 44b11a1c3f..61b28376fa 100644
--- a/openedx/core/djangoapps/user_authn/views/register.py
+++ b/openedx/core/djangoapps/user_authn/views/register.py
@@ -394,6 +394,7 @@ def _track_user_registration(user, profile, params, third_party_provider, regist
'total_registration_time': round(float(params.get('totalRegistrationTime', '0'))),
'activation_key': registration.activation_key if registration else None,
'host': params.get('host', ''),
+ 'utm_campaign': params.get('utm_campaign', ''),
}
# VAN-738 - added below properties to experiment marketing emails opt in/out events on Braze.
if params.get('marketing_emails_opt_in') and settings.MARKETING_EMAILS_OPT_IN:
diff --git a/openedx/core/djangoapps/xblock/runtime/runtime.py b/openedx/core/djangoapps/xblock/runtime/runtime.py
index 8ea9af9dcb..6805c26491 100644
--- a/openedx/core/djangoapps/xblock/runtime/runtime.py
+++ b/openedx/core/djangoapps/xblock/runtime/runtime.py
@@ -32,7 +32,6 @@ from common.djangoapps.track import contexts as track_contexts
from common.djangoapps.track import views as track_views
from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService
from lms.djangoapps.courseware.model_data import DjangoKeyValueStore, FieldDataCache
-from lms.djangoapps.courseware import block_render
from lms.djangoapps.grades.api import signals as grades_signals
from openedx.core.djangoapps.xblock.apps import get_xblock_app_config
from openedx.core.djangoapps.xblock.runtime.blockstore_field_data import BlockstoreChildrenData, BlockstoreFieldData
@@ -270,7 +269,6 @@ class XBlockRuntime(RuntimeShim, Runtime):
return RebindUserService(
self.user,
context_key,
- block_render.prepare_runtime_for_user,
track_function=make_track_function(),
request_token=request_token(crum.get_current_request()),
)
diff --git a/openedx/core/lib/xblock_serializer/block_serializer.py b/openedx/core/lib/xblock_serializer/block_serializer.py
index 92bb7475c3..9b6e970b11 100644
--- a/openedx/core/lib/xblock_serializer/block_serializer.py
+++ b/openedx/core/lib/xblock_serializer/block_serializer.py
@@ -1,24 +1,23 @@
"""
Code for serializing a modulestore XBlock to OLX.
"""
+from __future__ import annotations
import logging
import os
-from collections import namedtuple
from lxml import etree
+from .data import StaticFile
from . import utils
log = logging.getLogger(__name__)
-# A static file required by an XBlock
-StaticFile = namedtuple('StaticFile', ['name', 'url', 'data'])
-
class XBlockSerializer:
"""
A class that can serialize an XBlock to OLX.
"""
+ static_files: list[StaticFile]
def __init__(self, block):
"""
@@ -39,6 +38,11 @@ class XBlockSerializer:
if path not in [sf.name for sf in self.static_files]:
self.static_files.append(StaticFile(name=path, url=asset['url'], data=None))
+ if block.scope_ids.usage_id.block_type == 'problem':
+ py_lib_zip_file = utils.get_python_lib_zip_if_using(self.olx_str, course_key)
+ if py_lib_zip_file:
+ self.static_files.append(py_lib_zip_file)
+
def _serialize_block(self, block) -> etree.Element:
""" Serialize an XBlock to OLX/XML. """
if block.scope_ids.usage_id.block_type == 'html':
@@ -100,6 +104,10 @@ class XBlockSerializer:
olx_node.attrib["url_name"] = block.scope_ids.usage_id.block_id
if block.display_name:
olx_node.attrib["display_name"] = block.display_name
+ if block.fields["editor"].is_set_on(block):
+ olx_node.attrib["editor"] = block.editor
+ if block.use_latex_compiler:
+ olx_node.attrib["use_latex_compiler"] = "true"
olx_node.text = etree.CDATA("\n" + block.data + "\n")
return olx_node
diff --git a/openedx/core/lib/xblock_serializer/data.py b/openedx/core/lib/xblock_serializer/data.py
new file mode 100644
index 0000000000..141fff7dfd
--- /dev/null
+++ b/openedx/core/lib/xblock_serializer/data.py
@@ -0,0 +1,12 @@
+"""
+Simple data structures used for XBlock serialization
+"""
+from __future__ import annotations
+from typing import NamedTuple
+
+
+class StaticFile(NamedTuple):
+ """ A static file required by an XBlock """
+ name: str
+ url: str | None
+ data: bytes | None
diff --git a/openedx/core/lib/xblock_serializer/test_api.py b/openedx/core/lib/xblock_serializer/test_api.py
index f9258f1855..2867b4ae08 100644
--- a/openedx/core/lib/xblock_serializer/test_api.py
+++ b/openedx/core/lib/xblock_serializer/test_api.py
@@ -4,9 +4,10 @@ Test for the XBlock serialization lib's API
from xml.etree import ElementTree
from openedx.core.djangolib.testing.utils import skip_unless_cms
-from xmodule.modulestore.django import modulestore
-from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
-from xmodule.modulestore.tests.factories import ToyCourseFactory
+from xmodule.modulestore.django import contentstore, modulestore
+from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase, upload_file_to_course
+from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, ToyCourseFactory
+from xmodule.util.sandboxing import DEFAULT_PYTHON_LIB_FILENAME
from . import api
@@ -134,6 +135,32 @@ class XBlockSerializationTestCase(SharedModuleStoreTestCase):
# This is the only other difference - an extra field with the blockstore-specific definition ID:
self.assertEqual(serialized_blockstore.def_id, "html/just_img")
+ def test_html_with_fields(self):
+ """ Test an HTML Block with non-default fields like editor='raw' """
+ course = CourseFactory.create(display_name='test course', run="Testing_course")
+ html_block = BlockFactory.create(
+ parent_location=course.location,
+ category="html",
+ display_name="Non-default HTML Block",
+ editor="raw",
+ use_latex_compiler=True,
+ data="🍔",
+ )
+ serialized = api.serialize_xblock_to_olx(html_block)
+ self.assertXmlEqual(
+ serialized.olx_str,
+ """
+
+ """
+ )
+
def test_export_sequential(self):
"""
Export a sequential from the toy course, including all of its children.
@@ -165,3 +192,55 @@ class XBlockSerializationTestCase(SharedModuleStoreTestCase):
""")
+
+ def test_capa_python_lib(self):
+ """ Test capa problem blocks with and without python_lib.zip """
+ course = CourseFactory.create(display_name='Python Testing course', run="PY")
+ upload_file_to_course(
+ course_key=course.id,
+ contentstore=contentstore(),
+ source_file='./common/test/data/uploads/python_lib.zip',
+ target_filename=DEFAULT_PYTHON_LIB_FILENAME,
+ )
+
+ regular_problem = BlockFactory.create(
+ parent_location=course.location,
+ category="problem",
+ display_name="Problem No Python",
+ max_attempts=3,
+ data="",
+ )
+
+ python_problem = BlockFactory.create(
+ parent_location=course.location,
+ category="problem",
+ display_name="Python Problem",
+ data='This uses python: ...',
+ )
+
+ # The regular problem doesn't use python so shouldn't contain python_lib.zip:
+
+ serialized = api.serialize_xblock_to_olx(regular_problem)
+ assert not serialized.static_files
+ self.assertXmlEqual(
+ serialized.olx_str,
+ """
+
+
+
+ """
+ )
+
+ # The python problem should contain python_lib.zip:
+
+ serialized = api.serialize_xblock_to_olx(python_problem)
+ assert len(serialized.static_files) == 1
+ assert serialized.static_files[0].name == "python_lib.zip"
+ self.assertXmlEqual(
+ serialized.olx_str,
+ """
+
+ This uses python: ...
+
+ """
+ )
diff --git a/openedx/core/lib/xblock_serializer/test_utils.py b/openedx/core/lib/xblock_serializer/test_utils.py
index 5517a69273..e86ce21eaf 100644
--- a/openedx/core/lib/xblock_serializer/test_utils.py
+++ b/openedx/core/lib/xblock_serializer/test_utils.py
@@ -3,11 +3,13 @@ Test the OLX serialization utils
"""
import unittest
+import ddt
from opaque_keys.edx.keys import CourseKey
from . import utils
+@ddt.ddt
class TestUtils(unittest.TestCase):
"""
Test the OLX serialization utils
@@ -47,3 +49,17 @@ class TestUtils(unittest.TestCase):
"""
olx_out = utils.rewrite_absolute_static_urls(olx_in, course_id)
assert olx_out == olx_expected
+
+ @ddt.unpack
+ @ddt.data(
+ ('''\n''', False),
+ ('''\n''', True),
+ ('''\n''', True),
+ ('''\n''', True),
+ ('''\n''', True),
+ )
+ def test_has_python_script(self, olx: str, has_script: bool):
+ """
+ Test the _has_python_script() helper
+ """
+ assert utils._has_python_script(olx) == has_script # pylint: disable=protected-access
diff --git a/openedx/core/lib/xblock_serializer/utils.py b/openedx/core/lib/xblock_serializer/utils.py
index fd2253ccad..389571e4cb 100644
--- a/openedx/core/lib/xblock_serializer/utils.py
+++ b/openedx/core/lib/xblock_serializer/utils.py
@@ -1,21 +1,26 @@
"""
Helper functions for XBlock serialization
"""
+from __future__ import annotations
import logging
import re
from contextlib import contextmanager
+from django.conf import settings
from fs.memoryfs import MemoryFS
from fs.wrapfs import WrapFS
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import AssetKey, CourseKey
+
+from common.djangoapps.static_replace import replace_static_urls
from xmodule.assetstore.assetmgr import AssetManager
from xmodule.contentstore.content import StaticContent
from xmodule.exceptions import NotFoundError
from xmodule.modulestore.exceptions import ItemNotFoundError
+from xmodule.util.sandboxing import DEFAULT_PYTHON_LIB_FILENAME
from xmodule.xml_block import XmlMixin
-from common.djangoapps.static_replace import replace_static_urls
+from .data import StaticFile
log = logging.getLogger(__name__)
@@ -89,6 +94,42 @@ def collect_assets_from_text(text, course_id, include_content=False):
yield info
+def get_python_lib_zip_if_using(olx: str, course_id: CourseKey) -> StaticFile | None:
+ """
+ When python_lib is in use, capa problems that contain python code should be assumed to depend on it.
+
+ Note: for any given problem that uses python, there is no way to tell if it
+ actually uses any imports from python_lib.zip because the imports could be
+ named anything. So we just have to assume that any python problems may be
+ using python_lib.zip
+ """
+ if _has_python_script(olx):
+ python_lib_filename = getattr(settings, 'PYTHON_LIB_FILENAME', DEFAULT_PYTHON_LIB_FILENAME)
+ asset_key = StaticContent.get_asset_key_from_path(course_id, python_lib_filename)
+ # Now, it seems like this capa problem uses python_lib.zip - but does it exist in the course?
+ if AssetManager.find(asset_key, throw_on_not_found=False):
+ url = '/' + str(StaticContent.compute_location(course_id, python_lib_filename))
+ return StaticFile(name=python_lib_filename, url=url, data=None)
+ return None
+
+
+def _has_python_script(olx: str) -> bool:
+ """
+ Check if the given OLX block string seems to contain any python
+ code. (If it does, we know that it may be using python_lib.zip.)
+ """
+ match_strings = (
+ '