From 5c0fd189100ebe84c540f4e675aeac49b7569c00 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Mon, 15 Jul 2013 11:00:43 -0400 Subject: [PATCH 01/77] Validate date/time settings when typed in directly. The changeTime event isn't fired when the user types in the field, but only when clicking on the time in the dropdown. I'd consider this a timepicker bug, but for now we can just listen to the user typing in the field and update the value (and thus validate) like we do with other field types. --- cms/static/js/views/settings/main_settings_view.js | 1 + 1 file changed, 1 insertion(+) diff --git a/cms/static/js/views/settings/main_settings_view.js b/cms/static/js/views/settings/main_settings_view.js index 5550c550c0..0cbf573ba9 100644 --- a/cms/static/js/views/settings/main_settings_view.js +++ b/cms/static/js/views/settings/main_settings_view.js @@ -107,6 +107,7 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ // to pick up when the date is typed directly in the field. datefield.change(setfield); timefield.on('changeTime', setfield); + timefield.on('input', setfield); datefield.datepicker('setDate', this.model.get(fieldName)); // timepicker doesn't let us set null, so check that we have a time From 3d1188b12605be7fe20d960c48898ba6f600159d Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 15 Jul 2013 14:56:13 -0400 Subject: [PATCH 02/77] Make release email script wiki-friendly --- scripts/release-email-list.sh | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/scripts/release-email-list.sh b/scripts/release-email-list.sh index 64fa7c00d1..f54018f4f5 100755 --- a/scripts/release-email-list.sh +++ b/scripts/release-email-list.sh @@ -4,27 +4,36 @@ LOG_CMD="git --no-pager log $1..$2" RESPONSIBLE=$(sort -u <($LOG_CMD --format='tformat:%ae' && $LOG_CMD --format='tformat:%ce')) +echo "~~~~ Email ~~~~~" + echo -n 'To: ' echo ${RESPONSIBLE} | sed "s/ /, /g" echo echo "You've made changes that are about to be released. All of the commits that you either authored or committed are listed below. Please verify them on -\$ENVIRONMENT" +\$ENVIRONMENT. + +Please record your notes on https://edx-wiki.atlassian.net/wiki/display/ENG/Release+Page%3A+\$DATE +and add any bugs found to the Release Candidate Bugs section" echo +echo "~~~~~ Wiki Table ~~~~~" +echo "Type Ctrl+Shift+D on Confluence to embed the following table in your release wiki page" +echo + +echo '||Author||Changes||Commit Link||Testing Notes||' + for EMAIL in $RESPONSIBLE; do AUTHORED_BY="$LOG_CMD --author=<${EMAIL}>" COMMITTED_BY="$LOG_CMD --committer=<${EMAIL}>" COMMITTED_NOT_AUTHORED="$COMMITTED_BY $($AUTHORED_BY --format='tformat:^%h')" - echo $EMAIL "authored the following commits:" - $AUTHORED_BY --format='tformat: %s - https://github.com/edx/edx-platform/commit/%h' - echo + $AUTHORED_BY --format="tformat:|$EMAIL|%s|[commit|https://github.com/edx/edx-platform/commit/%h]| |" | head -n 1 + $AUTHORED_BY --format="tformat:| |%s|[commit|https://github.com/edx/edx-platform/commit/%h]| |" | tail -n +2 if [[ $($COMMITTED_NOT_AUTHORED) != "" ]]; then - echo $EMAIL "committed but didn't author the following commits:" - $COMMITTED_NOT_AUTHORED --format='tformat: %s - https://github.com/edx/edx-platform/commit/%h' - echo + $COMMITTED_NOT_AUTHORED --format="tformat:|$EMAIL|%s|[commit|https://github.com/edx/edx-platform/commit/%h]|Committed, didn't author|" | head -n 1 + $COMMITTED_NOT_AUTHORED --format="tformat:| |%s|[commit|https://github.com/edx/edx-platform/commit/%h]| |" | tail -n +2 fi done \ No newline at end of file From 813c22d1382262349c3ccaa06efe4ef581acf016 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Mon, 27 May 2013 18:09:29 +0300 Subject: [PATCH 03/77] Adds integration tests for word_cloud module --- lms/djangoapps/courseware/tests/__init__.py | 6 +- .../courseware/tests/test_word_cloud.py | 256 ++++++++++++++++++ 2 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 lms/djangoapps/courseware/tests/test_word_cloud.py diff --git a/lms/djangoapps/courseware/tests/__init__.py b/lms/djangoapps/courseware/tests/__init__.py index 31fe376d69..33c8d12701 100644 --- a/lms/djangoapps/courseware/tests/__init__.py +++ b/lms/djangoapps/courseware/tests/__init__.py @@ -26,17 +26,19 @@ class BaseTestXmodule(ModuleStoreTestCase): This class prepares course and users for tests: 1. create test course; - 2. create, enrol and login users for this course; + 2. create, enroll and login users for this course; Any xmodule should overwrite only next parameters for test: 1. CATEGORY 2. DATA 3. MODEL_DATA + 4. COURSE_DATA and USER_COUNT if needed This class should not contain any tests, because CATEGORY should be defined in child class. """ USER_COUNT = 2 + COURSE_DATA = {} # Data from YAML common/lib/xmodule/xmodule/templates/NAME/default.yaml CATEGORY = "" @@ -45,7 +47,7 @@ class BaseTestXmodule(ModuleStoreTestCase): def setUp(self): - self.course = CourseFactory.create() + self.course = CourseFactory.create(data=self.COURSE_DATA) # Turn off cache. modulestore().request_cache = None diff --git a/lms/djangoapps/courseware/tests/test_word_cloud.py b/lms/djangoapps/courseware/tests/test_word_cloud.py new file mode 100644 index 0000000000..7c214f3458 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_word_cloud.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- +"""Word cloud integration tests using mongo modulestore.""" + +import json +from operator import itemgetter + +from . import BaseTestXmodule + + +class TestWordCloud(BaseTestXmodule): + """Integration test for word cloud xmodule.""" + CATEGORY = "word_cloud" + + def _get_users_state(self): + """Return current state for each user: + + {username: json_state} + """ + # check word cloud response for every user + users_state = {} + + for user in self.users: + response = self.clients[user.username].post(self.get_url('get_state')) + users_state[user.username] = json.loads(response.content) + + return users_state + + def _post_words(self, words): + """Post `words` and return current state for each user: + + {username: json_state} + """ + users_state = {} + + for user in self.users: + response = self.clients[user.username].post( + self.get_url('submit'), + {'student_words[]': words}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest' + ) + users_state[user.username] = json.loads(response.content) + + return users_state + + def _check_response(self, response_contents, correct_jsons): + """Utility function that compares correct and real responses.""" + for username, content in response_contents.items(): + + # Used in debugger for comparing objects. + # self.maxDiff = None + + # We should compare top_words for manually, + # because they are unsorted. + keys_to_compare = set(content.keys()).difference(set(['top_words'])) + self.assertDictEqual( + {k: content[k] for k in keys_to_compare}, + {k: correct_jsons[username][k] for k in keys_to_compare}) + + # comparing top_words: + top_words_content = sorted( + content['top_words'], + key=itemgetter('text') + ) + top_words_correct = sorted( + correct_jsons[username]['top_words'], + key=itemgetter('text') + ) + self.assertListEqual(top_words_content, top_words_correct) + + def test_initial_state(self): + """Inital state of word cloud is correct. Those state that + is sended from server to frontend, when students load word + cloud page. + """ + users_state = self._get_users_state() + + self.assertEqual( + ''.join(set([ + content['status'] + for _, content in users_state.items() + ])), + 'success') + + # correct initial data: + correct_initial_data = { + u'status': u'success', + u'student_words': {}, + u'total_count': 0, + u'submitted': False, + u'top_words': {}, + u'display_student_percents': False + } + + for _, response_content in users_state.items(): + self.assertEquals(response_content, correct_initial_data) + + def test_post_words(self): + """Students can submit data succesfully. + Word cloud data properly updates after students submit. + """ + input_words = [ + "small", + "BIG", + " Spaced ", + " few words", + ] + + correct_words = [ + u"small", + u"big", + u"spaced", + u"few words", + ] + + users_state = self._post_words(input_words) + + self.assertEqual( + ''.join(set([ + content['status'] + for _, content in users_state.items() + ])), + 'success') + + correct_state = {} + for index, user in enumerate(self.users): + + correct_state[user.username] = { + u'status': u'success', + u'submitted': True, + u'display_student_percents': True, + u'student_words': {word: 1 + index for word in correct_words}, + u'total_count': len(input_words) * (1 + index), + u'top_words': [ + { + u'text': word, u'percent': 100 / len(input_words), + u'size': (1 + index) + } + for word in correct_words + ] + } + + self._check_response(users_state, correct_state) + + def test_collective_users_submits(self): + """Test word cloud data flow per single and collective users submits. + + Make sures that: + + 1. Inital state of word cloud is correct. Those state that + is sended from server to frontend, when students load word + cloud page. + + 2. Students can submit data succesfully. + + 3. Next submits produce "already voted" error. Next submits for user + are not allowed by user interface, but techically it possible, and + word_cloud should properly react. + + 4. State of word cloud after #3 is still as after #2. + """ + + # 1. + users_state = self._get_users_state() + + self.assertEqual( + ''.join(set([ + content['status'] + for _, content in users_state.items() + ])), + 'success') + + # 2. + # Invcemental state per user. + users_state_after_post = self._post_words(['word1', 'word2']) + + self.assertEqual( + ''.join(set([ + content['status'] + for _, content in users_state_after_post.items() + ])), + 'success') + + # Final state after all posts. + users_state_before_fail = self._get_users_state() + + # 3. + users_state_after_post = self._post_words( + ['word1', 'word2', 'word3']) + + self.assertEqual( + ''.join(set([ + content['status'] + for _, content in users_state_after_post.items() + ])), + 'fail') + + # 4. + current_users_state = self._get_users_state() + self._check_response(users_state_before_fail, current_users_state) + + def test_unicode(self): + input_words = [u" this is unicode Юникод"] + correct_words = [u"this is unicode юникод"] + + users_state = self._post_words(input_words) + + self.assertEqual( + ''.join(set([ + content['status'] + for _, content in users_state.items() + ])), + 'success') + + for user in self.users: + self.assertListEqual( + users_state[user.username]['student_words'].keys(), + correct_words) + + def test_handle_ajax_incorrect_dispatch(self): + responses = { + user.username: self.clients[user.username].post( + self.get_url('whatever'), + {}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest') + for user in self.users + } + + self.assertEqual( + set([ + response.status_code + for _, response in responses.items() + ]).pop(), + 200) + + for user in self.users: + self.assertDictEqual( + json.loads(responses[user.username].content), + { + 'status': 'fail', + 'error': 'Unknown Command!' + }) + + def test_word_cloud_constructor(self): + """Make sure that all parameters extracted correclty from xml""" + # `get_html` return only context, cause we + # overwrite `system.render_template` + context = self.item_module.get_html() + + expected_context = { + 'ajax_url': self.item_module.system.ajax_url, + 'element_class': self.item_module.location.category, + 'element_id': self.item_module.location.html_id(), + 'num_inputs': 5, # default value + 'submitted': False # default value + } + self.assertDictEqual(context, expected_context) From 58f147c161f94e09bf850034879ac941af8cca5b Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Thu, 18 Jul 2013 08:49:58 -0400 Subject: [PATCH 04/77] Change delete asset prompt to Warning type for consistency. --- cms/static/js/views/assets.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/static/js/views/assets.js b/cms/static/js/views/assets.js index 224ec928fb..282aeab69c 100644 --- a/cms/static/js/views/assets.js +++ b/cms/static/js/views/assets.js @@ -9,7 +9,7 @@ function removeAsset(e){ e.preventDefault(); var that = this; - var msg = new CMS.Views.Prompt.Confirmation({ + var msg = new CMS.Views.Prompt.Warning({ title: gettext("Delete File Confirmation"), message: gettext("Are you sure you wish to delete this item. It cannot be reversed!\n\nAlso any content that links/refers to this item will no longer work (e.g. broken images and/or links)"), actions: { From bf8e5e6ba13e543972bdac9c5aa45c634e14fb74 Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Fri, 17 May 2013 15:52:09 -0700 Subject: [PATCH 05/77] Simple port of Class2Go's chat feature Embed a chat widget (much like the calculator widget) into the courseware. To use, you must point it at an ejabberd box, configured as `JABBER_DOMAIN` in the settings. --- common/lib/xmodule/xmodule/course_module.py | 5 +- lms/djangoapps/courseware/views.py | 10 +- lms/static/candy_res/audioplayer.swf | Bin 0 -> 2680 bytes lms/static/candy_res/candy_full.css | 606 +++ .../candy_res/img/action/autoscroll-off.png | Bin 0 -> 442 bytes .../candy_res/img/action/autoscroll-on.png | Bin 0 -> 223 bytes lms/static/candy_res/img/action/ban.png | Bin 0 -> 796 bytes lms/static/candy_res/img/action/emoticons.png | Bin 0 -> 725 bytes lms/static/candy_res/img/action/ignore.png | Bin 0 -> 715 bytes lms/static/candy_res/img/action/kick.png | Bin 0 -> 859 bytes lms/static/candy_res/img/action/menu.png | Bin 0 -> 1229 bytes lms/static/candy_res/img/action/private.png | Bin 0 -> 557 bytes lms/static/candy_res/img/action/settings.png | Bin 0 -> 744 bytes lms/static/candy_res/img/action/sound-off.png | Bin 0 -> 3172 bytes lms/static/candy_res/img/action/sound-on.png | Bin 0 -> 544 bytes .../img/action/statusmessage-off.png | Bin 0 -> 764 bytes .../candy_res/img/action/statusmessage-on.png | Bin 0 -> 659 bytes lms/static/candy_res/img/action/subject.png | Bin 0 -> 413 bytes lms/static/candy_res/img/action/unignore.png | Bin 0 -> 781 bytes lms/static/candy_res/img/action/usercount.png | Bin 0 -> 753 bytes lms/static/candy_res/img/context-arrows.gif | Bin 0 -> 91 bytes lms/static/candy_res/img/emoticons/Angel.png | Bin 0 -> 3467 bytes lms/static/candy_res/img/emoticons/Angry.png | Bin 0 -> 3449 bytes lms/static/candy_res/img/emoticons/Aww.png | Bin 0 -> 3352 bytes lms/static/candy_res/img/emoticons/Aww_2.png | Bin 0 -> 3402 bytes .../candy_res/img/emoticons/Blushing.png | Bin 0 -> 3403 bytes .../candy_res/img/emoticons/Childish.png | Bin 0 -> 3411 bytes .../candy_res/img/emoticons/Confused.png | Bin 0 -> 3392 bytes lms/static/candy_res/img/emoticons/Creepy.png | Bin 0 -> 3417 bytes lms/static/candy_res/img/emoticons/Crying.png | Bin 0 -> 3434 bytes .../candy_res/img/emoticons/Cthulhu.png | Bin 0 -> 775 bytes lms/static/candy_res/img/emoticons/Cute.png | Bin 0 -> 3369 bytes .../candy_res/img/emoticons/Cute_Winking.png | Bin 0 -> 3380 bytes lms/static/candy_res/img/emoticons/Devil.png | Bin 0 -> 3478 bytes lms/static/candy_res/img/emoticons/Gah.png | Bin 0 -> 3415 bytes lms/static/candy_res/img/emoticons/Gah_2.png | Bin 0 -> 3401 bytes .../candy_res/img/emoticons/Gasping.png | Bin 0 -> 3384 bytes lms/static/candy_res/img/emoticons/Greedy.png | Bin 0 -> 3456 bytes .../candy_res/img/emoticons/Grinning.png | Bin 0 -> 3381 bytes .../img/emoticons/Grinning_Winking.png | Bin 0 -> 3394 bytes lms/static/candy_res/img/emoticons/Happy.png | Bin 0 -> 3413 bytes .../candy_res/img/emoticons/Happy_2.png | Bin 0 -> 3433 bytes .../candy_res/img/emoticons/Happy_3.png | Bin 0 -> 3408 bytes lms/static/candy_res/img/emoticons/Heart.png | Bin 0 -> 3209 bytes lms/static/candy_res/img/emoticons/Huh.png | Bin 0 -> 3417 bytes lms/static/candy_res/img/emoticons/Huh_2.png | Bin 0 -> 3429 bytes .../candy_res/img/emoticons/Laughing.png | Bin 0 -> 3450 bytes .../candy_res/img/emoticons/Lips_Sealed.png | Bin 0 -> 3368 bytes .../candy_res/img/emoticons/Madness.png | Bin 0 -> 3418 bytes .../candy_res/img/emoticons/Malicious.png | Bin 0 -> 751 bytes lms/static/candy_res/img/emoticons/README | 2 + lms/static/candy_res/img/emoticons/Sick.png | Bin 0 -> 3439 bytes .../candy_res/img/emoticons/Smiling.png | Bin 0 -> 3390 bytes .../candy_res/img/emoticons/Speechless.png | Bin 0 -> 3352 bytes .../candy_res/img/emoticons/Spiteful.png | Bin 0 -> 3417 bytes lms/static/candy_res/img/emoticons/Stupid.png | Bin 0 -> 3422 bytes .../candy_res/img/emoticons/Sunglasses.png | Bin 0 -> 3433 bytes .../candy_res/img/emoticons/Terrified.png | Bin 0 -> 3382 bytes .../candy_res/img/emoticons/Thumb_Down.png | Bin 0 -> 572 bytes .../candy_res/img/emoticons/Thumb_Up.png | Bin 0 -> 530 bytes lms/static/candy_res/img/emoticons/Tired.png | Bin 0 -> 3338 bytes .../candy_res/img/emoticons/Tongue_Out.png | Bin 0 -> 3403 bytes .../img/emoticons/Tongue_Out_Laughing.png | Bin 0 -> 3468 bytes .../img/emoticons/Tongue_Out_Left.png | Bin 0 -> 3387 bytes .../candy_res/img/emoticons/Tongue_Out_Up.png | Bin 0 -> 3362 bytes .../img/emoticons/Tongue_Out_Up_Left.png | Bin 0 -> 704 bytes .../img/emoticons/Tongue_Out_Winking.png | Bin 0 -> 3416 bytes .../candy_res/img/emoticons/Uncertain.png | Bin 0 -> 3390 bytes .../candy_res/img/emoticons/Uncertain_2.png | Bin 0 -> 3378 bytes .../candy_res/img/emoticons/Unhappy.png | Bin 0 -> 3408 bytes .../candy_res/img/emoticons/Winking.png | Bin 0 -> 3404 bytes lms/static/candy_res/img/favicon.png | Bin 0 -> 490 bytes lms/static/candy_res/img/modal-bg.png | Bin 0 -> 109 bytes lms/static/candy_res/img/modal-spinner.gif | Bin 0 -> 723 bytes lms/static/candy_res/img/overlay.png | Bin 0 -> 109 bytes .../img/roster/affiliation-owner.png | Bin 0 -> 670 bytes lms/static/candy_res/img/roster/ignore.png | Bin 0 -> 715 bytes .../candy_res/img/roster/role-moderator.png | Bin 0 -> 594 bytes lms/static/candy_res/img/tab-transitions.png | Bin 0 -> 490 bytes lms/static/candy_res/img/tooltip-arrows.gif | Bin 0 -> 66 bytes lms/static/candy_res/notify.mp3 | Bin 0 -> 1095 bytes lms/static/js/candy.min.js | 1 + .../js/candy_libs/dateformat/dateFormat.js | 127 + lms/static/js/candy_libs/libs.bundle.js | 4521 +++++++++++++++++ lms/static/js/candy_libs/libs.min.js | 1 + lms/static/js/candy_ui.js | 49 + lms/static/sass/course.scss.mako | 1 + lms/static/sass/course/layout/_chat.scss | 57 + lms/templates/courseware/courseware.html | 58 + 89 files changed, 5434 insertions(+), 4 deletions(-) create mode 100644 lms/static/candy_res/audioplayer.swf create mode 100644 lms/static/candy_res/candy_full.css create mode 100644 lms/static/candy_res/img/action/autoscroll-off.png create mode 100644 lms/static/candy_res/img/action/autoscroll-on.png create mode 100644 lms/static/candy_res/img/action/ban.png create mode 100755 lms/static/candy_res/img/action/emoticons.png create mode 100644 lms/static/candy_res/img/action/ignore.png create mode 100644 lms/static/candy_res/img/action/kick.png create mode 100644 lms/static/candy_res/img/action/menu.png create mode 100644 lms/static/candy_res/img/action/private.png create mode 100755 lms/static/candy_res/img/action/settings.png create mode 100644 lms/static/candy_res/img/action/sound-off.png create mode 100644 lms/static/candy_res/img/action/sound-on.png create mode 100644 lms/static/candy_res/img/action/statusmessage-off.png create mode 100644 lms/static/candy_res/img/action/statusmessage-on.png create mode 100644 lms/static/candy_res/img/action/subject.png create mode 100644 lms/static/candy_res/img/action/unignore.png create mode 100755 lms/static/candy_res/img/action/usercount.png create mode 100644 lms/static/candy_res/img/context-arrows.gif create mode 100755 lms/static/candy_res/img/emoticons/Angel.png create mode 100755 lms/static/candy_res/img/emoticons/Angry.png create mode 100755 lms/static/candy_res/img/emoticons/Aww.png create mode 100755 lms/static/candy_res/img/emoticons/Aww_2.png create mode 100755 lms/static/candy_res/img/emoticons/Blushing.png create mode 100755 lms/static/candy_res/img/emoticons/Childish.png create mode 100755 lms/static/candy_res/img/emoticons/Confused.png create mode 100755 lms/static/candy_res/img/emoticons/Creepy.png create mode 100755 lms/static/candy_res/img/emoticons/Crying.png create mode 100755 lms/static/candy_res/img/emoticons/Cthulhu.png create mode 100755 lms/static/candy_res/img/emoticons/Cute.png create mode 100755 lms/static/candy_res/img/emoticons/Cute_Winking.png create mode 100755 lms/static/candy_res/img/emoticons/Devil.png create mode 100755 lms/static/candy_res/img/emoticons/Gah.png create mode 100755 lms/static/candy_res/img/emoticons/Gah_2.png create mode 100755 lms/static/candy_res/img/emoticons/Gasping.png create mode 100755 lms/static/candy_res/img/emoticons/Greedy.png create mode 100755 lms/static/candy_res/img/emoticons/Grinning.png create mode 100755 lms/static/candy_res/img/emoticons/Grinning_Winking.png create mode 100755 lms/static/candy_res/img/emoticons/Happy.png create mode 100755 lms/static/candy_res/img/emoticons/Happy_2.png create mode 100755 lms/static/candy_res/img/emoticons/Happy_3.png create mode 100755 lms/static/candy_res/img/emoticons/Heart.png create mode 100755 lms/static/candy_res/img/emoticons/Huh.png create mode 100755 lms/static/candy_res/img/emoticons/Huh_2.png create mode 100755 lms/static/candy_res/img/emoticons/Laughing.png create mode 100755 lms/static/candy_res/img/emoticons/Lips_Sealed.png create mode 100755 lms/static/candy_res/img/emoticons/Madness.png create mode 100755 lms/static/candy_res/img/emoticons/Malicious.png create mode 100644 lms/static/candy_res/img/emoticons/README create mode 100755 lms/static/candy_res/img/emoticons/Sick.png create mode 100755 lms/static/candy_res/img/emoticons/Smiling.png create mode 100755 lms/static/candy_res/img/emoticons/Speechless.png create mode 100755 lms/static/candy_res/img/emoticons/Spiteful.png create mode 100755 lms/static/candy_res/img/emoticons/Stupid.png create mode 100755 lms/static/candy_res/img/emoticons/Sunglasses.png create mode 100755 lms/static/candy_res/img/emoticons/Terrified.png create mode 100755 lms/static/candy_res/img/emoticons/Thumb_Down.png create mode 100755 lms/static/candy_res/img/emoticons/Thumb_Up.png create mode 100755 lms/static/candy_res/img/emoticons/Tired.png create mode 100755 lms/static/candy_res/img/emoticons/Tongue_Out.png create mode 100755 lms/static/candy_res/img/emoticons/Tongue_Out_Laughing.png create mode 100755 lms/static/candy_res/img/emoticons/Tongue_Out_Left.png create mode 100755 lms/static/candy_res/img/emoticons/Tongue_Out_Up.png create mode 100755 lms/static/candy_res/img/emoticons/Tongue_Out_Up_Left.png create mode 100755 lms/static/candy_res/img/emoticons/Tongue_Out_Winking.png create mode 100755 lms/static/candy_res/img/emoticons/Uncertain.png create mode 100755 lms/static/candy_res/img/emoticons/Uncertain_2.png create mode 100755 lms/static/candy_res/img/emoticons/Unhappy.png create mode 100755 lms/static/candy_res/img/emoticons/Winking.png create mode 100644 lms/static/candy_res/img/favicon.png create mode 100644 lms/static/candy_res/img/modal-bg.png create mode 100644 lms/static/candy_res/img/modal-spinner.gif create mode 100644 lms/static/candy_res/img/overlay.png create mode 100644 lms/static/candy_res/img/roster/affiliation-owner.png create mode 100644 lms/static/candy_res/img/roster/ignore.png create mode 100644 lms/static/candy_res/img/roster/role-moderator.png create mode 100644 lms/static/candy_res/img/tab-transitions.png create mode 100644 lms/static/candy_res/img/tooltip-arrows.gif create mode 100644 lms/static/candy_res/notify.mp3 create mode 100644 lms/static/js/candy.min.js create mode 100644 lms/static/js/candy_libs/dateformat/dateFormat.js create mode 100644 lms/static/js/candy_libs/libs.bundle.js create mode 100644 lms/static/js/candy_libs/libs.min.js create mode 100644 lms/static/js/candy_ui.js create mode 100644 lms/static/sass/course/layout/_chat.scss diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index d4aac5b0ae..d1a60b5683 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -192,9 +192,8 @@ class CourseFields(object): }}, scope=Scope.content) show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings) - display_name = String( - help="Display name for this module", default="Empty", - display_name="Display Name", scope=Scope.settings) + display_name = String(help="Display name for this module", default="Empty", display_name="Display Name", scope=Scope.settings) + show_chat = Boolean(help="Whether to show the chat widget in this course", default=False, scope=Scope.settings) tabs = List(help="List of tabs to enable in this course", scope=Scope.settings) end_of_course_survey_url = String(help="Url for the end-of-course survey", scope=Scope.settings) discussion_blackouts = List(help="List of pairs of start/end dates for discussion blackouts", scope=Scope.settings) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index e6e062c494..360eb143a5 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -40,7 +40,6 @@ log = logging.getLogger("mitx.courseware") template_imports = {'urllib': urllib} - def user_groups(user): """ TODO (vshnayder): This is not used. When we have a new plan for groups, adjust appropriately. @@ -300,6 +299,15 @@ def index(request, course_id, chapter=None, section=None, 'xqa_server': settings.MITX_FEATURES.get('USE_XQA_SERVER', 'http://xqa:server@content-qa.mitx.mit.edu/xqa') } + if course.show_chat: + context['chat'] = { + 'domain': settings.JABBER_DOMAIN, + 'room': "{ID}_class".format(ID=course.id.replace('/', '-')), # Jabber doesn't like /s + 'username': "{USER}@{DOMAIN}".format(USER=user.username, DOMAIN=settings.JABBER_DOMAIN), + # TODO: clearly this needs to be something other than the username + 'password': "{USER}@{DOMAIN}".format(USER=user.username, DOMAIN=settings.JABBER_DOMAIN), + } + chapter_descriptor = course.get_child_by(lambda m: m.url_name == chapter) if chapter_descriptor is not None: save_child_position(course_module, chapter) diff --git a/lms/static/candy_res/audioplayer.swf b/lms/static/candy_res/audioplayer.swf new file mode 100644 index 0000000000000000000000000000000000000000..72390d415719d633b1598a0cc253380c85fc2966 GIT binary patch literal 2680 zcmV-;3WxPWS5pW!8~^}#+MF0$PaErTyt~HB#SjR=+;S4g4RTJ>ko1}a0)fO_f}snE z`cheoy}+hxuVt@MP*tfHBP;^^3j3>vVFX8BRPa7T>;BQP*NE1< zT67GAFIGxWs(ZNZRizf4Zigqnw;K302&=EEkyn3RcPpOYr2)!b;2p8eqPkmc_>mu0 zq1XtU)$(IFIE}nU;8*+ziuC@5-rZ&tGSOgxBy^C2+wh=)Q2Ab~hfx@v);uQfo6s%$ z)g!@{L0%Fw1bgx4ei)(h+b-%;C%S;+BZAkwddaJj`n#bJvnF2}8n75cBex!rRNyz* zK{FXhZe+67th+>2mM_Iazut%#9qiHmmD&@Mfq&K9ChGM952+kh7J>h>o!^B)vqE}` zx0Ss;l&2onUI*?`16l^yFg-HztpSxD_K}(<(AibyCgwaCwS8&u|3Iq;tdwn7#!kzk zw*vm#rj^=h^|Nm)!#vGS6OSIJ)eCkeW5gia5euyXva);et*<~!iHdVXx_*rgHyB96 zPAkp6v$(S%xeXRtDB=bQKO$rsQn*cx@(OVs-e8myfMSuHYP%69_l*IgortSW?sph0 zBn;T21^h_mxMbJPSe!t59xD2}FKe18Sw+H3}je%+7l%1 zBqOtOtzodIEW@6*O#8ZJWlW@4T@okr1>fJ8haun)zQv7#q`0ZsF!oAuPBY4eGX*Bb zzma*iLky-sN>^ak=~U{LBaY;Dft~N5ejIn4oY>}W7kTd~dG9ESKMIPu0@-$hab%Jb zSK(@7+|}ln+3Ivk82WyJZEon|eTm|c%5&+||En^szm4k!gRhjR3(&IpdBz6Vx$uYt z50E@=DIRNW`VI2cpLX)?Z2Od7mW|H{6yR&1lNMNheZI}@rV}RfeU#Kw(~^T-yoS9QacxTN4A@H;PXMA869GyKXjPd zfB8CfK_KQef66L&U1Xf#4CynuZlP#HrAei zUw)z$26WQdG3K^e)=Gt!ERtMkD>n2hwJnYs=!SY@KdaafN^ z&8Pt@^*UygqF?o+E$>vzJUa*Py;6knkKng8U`Wggt6^D-e79VF<2NF&$|fxQ*b{6M zqm3BEhE8`}Mhr&bV!uel&CvJfM&wlglB=pO3 zkLI6Y-tdA$ASNeF5c&3&RzuN?uST%H*^qQ`0*cd1#r3OD@uK6f+}19=Z){ZArAdQ_ zuvd>xWe(BO2fx`kh%Z0HjmLp@J0epI!FDgZ1iA21BW{93&lcZ=u{5y2&mcIb%`uJ0a4f0Zy<*(UbW{v%2L8?_N6`$JA~oyfgS7#oCuK}>q9f^1Q1;B^HA0&RCqdpk z^%SV5>!WA?pD=O6v;zfwQqC%<>~^xO-LMB`0^-6CCF}kM-&M&f zJ3;zRs+$!@z^4d4-2uN&@Go?DJO3LFBe{7Q$qbRqUPf|@NN%?YAhCByj5|73o$p=3 z-MftWoR}imEbx|@mM$UCF&(L{2tl^=LyJ58!p{rx<;YP-6ur$LjwPZoZiu@fRQs9z ze1;vc!2%H13=_21GUBl8=vtq2@J9pYUrONjb$oj>`&Q1UJ@%JkKF!h<#tytN*OB7mEEbaO{gu7d;`Yi$0it8S mp?9&=_)#%sNhT+XtE?sBj>pMi+5z|<00030{{sNu + * @author Patrick + * @copyright 2011 Amiado Group AG, All rights reserved. + */ +html, body { + margin: 0; + padding: 0; + font-family: Arial, Helvetica, sans-serif; +} + +#candy { + margin: 0; + padding: 0; + position: absolute; + top: 0; + bottom: 0; + right: 0; + width: 100%; + background-color: #bdb7a1; + color: #333; + overflow: hidden; + border: 1px solid #bdb7a1; + border-top-right-radius: 10px; + box-sizing: border-box; +} +#candy.poppedOut { + border-top-left-radius: 0 !important; +} + +#chatPopin { + position: absolute; + bottom: 5px; + right: 5px; + font-size: 24px; + height: 18px !important; + width: 22px; + color: #666; +} +#chatPopin:hover { + color: #333; + text-decoration: none; +} + +a { + color: #333; + text-decoration: none; +} +ul { + list-style: none; + padding: 0; + margin: 0; +} + +#chat-tabs { + margin: 0 0 0 28px; + padding: 0; + overflow: auto; + border-left: 1px solid #bdb7a1; +} + +#chat-pane.collapsed-message-pane #chat-tabs { + height: 100%; + width: 29px; + border-left: 0; + margin: 30px 0 0 0; +} + +#chat-tabs #chat-expand-arrow { + position: absolute; + top: 0; + left: 0; + width: 27px; + height: 15px; + padding: 5px 0; + cursor: pointer; +} +#chat-tabs #chat-expand-arrow em { + font-size: 18px; + padding: 0 10px; +} + +#chat-tabs li { + margin: 0; + float: left; + position: relative; + white-space: nowrap; +} + +#chat-tabs li a { + background-color: #e9e6df; + padding: 6px 20px 4px 1px; + display: inline-block; + color: #999; + height: 19px; + font-size: 22px; + -webkit-font-smoothing: antialiased; +} + +#chat-tabs li a.label { + border-right: 1px solid #bdb7a1; + border-bottom: 1px solid #bdb7a1; +} + +#chat-pane.collapsed-message-pane #chat-tabs li a.label { + border-bottom: 0; + margin-bottom: 1px; +} + +#chat-tabs li.active a { + color: #333; + font-weight: bold; +} + +#chat-tabs li.active a.label { + border-bottom: 1px solid white; + background-color: white; +} + +#chat-tabs li a.transition { + display: none; /* JRBL */ +} + +#chat-tabs li a.close { + background-color: transparent; + position: absolute; + top: 2px; + right: 7px; + height: auto; + padding: 0; + margin: 0; + color: #999; +} +#chat-tabs li a.close:hover, #chat-tabs li.active a.close:hover { + color: #333; +} + +#chat-tabs li .unread { + color: white; + background-color: #8C1515; + padding: 1px 2px; + font-weight: normal; + font-size: 10px; + position: absolute; + top: 12px; + right: 30px; + border-radius: 5px; +} + +#chat-tabs li.offline a.label { + text-decoration: line-through; +} + +#chat-toolbar { + /* + position: fixed; + bottom: 0; + right: 0; + font-size: 11px; + color: #666; + width: 200px; + height: 24px; + padding-top: 7px; + border-top: 1px solid #e9e6df; + background-color: #d3cec0; + */ + display: none !important; +} +#chat-toolbar li { + width: 16px; + height: 16px; + margin-left: 5px; + float: left; + display: inline-block; + cursor: pointer; + background-position: top left; + background-repeat: no-repeat; +} +#chat-toolbar #emoticons-icon { + background-image: url(img/action/emoticons.png); +} +#chat-toolbar .context { + background-image: url(img/action/settings.png); + display: none; +} +.role-moderator #chat-toolbar .context, .affiliation-owner #chat-toolbar .context { + display: inline-block; +} +#chat-sound-control { + background-image: url(img/action/sound-off.png); +} +#chat-sound-control.checked { + background-image: url(img/action/sound-on.png); +} +#chat-autoscroll-control { + background-image: url(img/action/autoscroll-off.png); +} +#chat-autoscroll-control.checked { + background-image: url(img/action/autoscroll-on.png); +} +#chat-statusmessage-control { + background: url(img/action/statusmessage-off.png); +} +#chat-statusmessage-control.checked { + background: url(img/action/statusmessage-on.png); +} +#chat-toolbar .usercount { + background-image: url(img/action/usercount.png); + cursor: default; + padding-left: 20px; + width: auto; + margin-right: 5px; + float: right; +} +.usercount span { + display: inline-block; + padding: 1px 3px; + background-color: #ccc; + font-weight: bold; + border-radius: 3px; +} + +.room-pane { + /* display: none; */ +} +.roster-pane { + position: absolute; + overflow: auto; + top: 30px; + right: 0; + bottom: 30px; + width: 198px; + margin: 0; +} +.roster-pane .user { + cursor: pointer; + width: 90%; + padding: 6px 5%; + font-size: 14px; + margin: 0; + /* display: none; */ + color: #666; + float: left; + clear: both; + height: 17px; + background-color: #d3cec0; + border-bottom: 1px solid #bdb7a1; +} +.roster-pane .user.me { + font-weight: bold; + cursor: default; +} +.roster-pane .user:hover, +.roster-pane .user.me:hover { + background-color: #e9e6df; +} +.roster-pane .user.status-ignored { + cursor: default; + color: #999; + opacity: .5 !important; +} +.roster-pane .label { + float: left; + width: 125px; + overflow: hidden; + white-space: nowrap; +} +.roster-pane li { + width: 16px; + height: 16px; + float: right; + display: block; + margin-left: 3px; + background-repeat: no-repeat; + background-position: center; +} +.roster-pane li.role { + cursor: default; + display: none; +} +.roster-pane li.role-moderator { + background-image: url(img/roster/role-moderator.png); + display: block; +} +.roster-pane li.affiliation-owner { + background-image: url(img/roster/affiliation-owner.png); + display: block; +} +.roster-pane li.ignore { + background-image: url(img/roster/ignore.png); + display: none; +} +.roster-pane .status-ignored li.ignore { + display: block; +} +.roster-pane .me li.context { + display: none; +} +.roster-pane li.context { + background-image: url(img/action/menu.png); + cursor: pointer; +} +.roster-pane li.context:hover { + background-color: #ccc; + border-radius: 4px; +} + +.message-pane-wrapper { + clear: both; + overflow: auto; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + height: auto; + width: auto; + margin: 30px 199px 32px 0; + background-color: white; + font-size: 13px; +} +.message-pane { + margin: 0; + padding: 5px 10px 2px 10px; +} +.message-pane dt { + width: 55px; + float: left; + color: #888; + font-size: 10px; + text-align: right; + padding-top: 4px; +} + +.message-pane dd { + overflow: auto; + padding: 2px 0 1px 100px; + margin: 0 0 2px 0; + white-space: -o-pre-wrap; /* Opera */ + word-wrap: break-word; /* Internet Explorer 5.5+ */ +} + +.message-pane dd .label { + font-weight: bold; + white-space: nowrap; + display: block; + margin-left: -90px; + width: 90px; + float: left; + overflow: hidden; +} + +.message-pane .subject { + color: #a00; + font-weight: bold; +} + +.message-pane .adminmessage { + color: #a00; + font-weight: bold; +} + +.message-pane .infomessage { + color: #888; + font-style: italic; + padding-left: 5px; +} + +.message-pane .emoticon { + vertical-align: text-bottom; + height: 15px; + width: 15px; +} + +.message-form-wrapper { + position: absolute; + bottom: 1px; + left: 0; + right: 0; + width: auto; + margin-right: 199px; + background-color: #e9e6df; + height: 30px; +} + +.message-form { + position: absolute; + bottom: 1px; + left: 0; + right: 0; + margin: 0 199px 0 0; + padding: 0; + height: 30px; +} + +.message-form input { + border: 0 none; + font-size: 14px; + width: 100%; + height: 100%; + display: block; + outline-width: 0; + padding: 0 50px 0 5px; + background-color: transparent; +} +.message-form input:focus { + background-color: white; +} + +.message-form input.submit { + cursor: pointer; + background-color: #333; + color: #e9e6df; + position: absolute; + bottom: 0; + right: 0; + margin: 3px; + padding: 5px; + width: 40px; + font-size: 12px; + line-height: 12px; + height: 24px; + font-weight: bold; +} + +#tooltip { + position: absolute; + z-index: 10; + display: none; + margin: 18px -18px 2px -2px; + color: white; + font-size: 11px; + padding: 5px 0; + background: url(img/tooltip-arrows.gif) no-repeat left bottom; +} + +#tooltip div { + background-color: black; + padding: 2px 5px; + zoom: 1; +} + +#context-menu { + position: absolute; + z-index: 10; + display: none; + padding: 15px 10px; + margin: 8px -28px -8px -12px; + background: url(img/context-arrows.gif) no-repeat left bottom; +} + +#context-menu ul { + background-color: black; + color: white; + font-size: 12px; + padding: 2px; + zoom: 1; +} + +#context-menu li { + padding: 3px 5px 3px 20px; + line-height: 12px; + cursor: pointer; + margin-bottom: 2px; + background: 1px no-repeat; + white-space: nowrap; +} + +#context-menu li:hover { + background-color: #666; +} + +#context-menu li:last-child { + margin-bottom: 0; +} + +#context-menu .private { + background-image: url(img/action/private.png); +} + +#context-menu .ignore { + background-image: url(img/action/ignore.png); +} + +#context-menu .unignore { + background-image: url(img/action/unignore.png); +} + +#context-menu .kick { + background-image: url(img/action/kick.png); +} + +#context-menu .ban { + background-image: url(img/action/ban.png); +} + +#context-menu .subject { + background-image: url(img/action/subject.png); +} + +#context-menu .emoticons { + padding-left: 5px; + width: 85px; + white-space: normal; +} + +#context-menu .emoticons:hover { + background-color: transparent; +} + +#context-menu .emoticons img { + cursor: pointer; + margin: 3px; + height: 15px; + width: 15px; +} + +#chat-modal { + background: url(img/modal-bg.png); + width: 300px; + padding: 20px 5px; + color: white; + font-size: 16px; + position: absolute; + left: 50%; + top: 50%; + margin-left: -155px; + margin-top: -45px; + text-align: center; + display: none; + z-index: 100; + border-radius: 5px; +} + +#chat-modal-overlay { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + z-index: 90; + background-image: url(img/overlay.png); + border-top-left-radius: 8px; +} + +#chat-modal.modal-login { + display: block; + margin-top: -100px; +} + +#chat-modal-spinner { + display: none; + margin-left: 15px; +} + +#chat-modal form { + margin: 15px 0; +} + +#chat-modal label, #chat-modal input, #chat-modal select { + display: block; + float: left; + line-height: 26px; + font-size: 16px; + margin: 5px 0; +} + +#chat-modal input, #chat-modal select { + padding: 2px; + line-height: 16px; + width: 150px; +} + +#chat-modal label { + text-align: right; + padding-right: 1em; + clear: both; + width: 100px; +} + +#chat-modal input.button { + float: none; + display: block; + margin: 5px auto; + clear: both; + position: relative; + top: 10px; + /* width: 200px; */ + width: 120px; /* JRBL */ +} + +#chat-modal .close { + position: absolute; + right: 0; + display: none; + padding: 0 5px; + margin: -17px 3px 0 0; + color: white; + border-radius: 3px; +} + +#chat-modal .close:hover { + background-color: #333; +} diff --git a/lms/static/candy_res/img/action/autoscroll-off.png b/lms/static/candy_res/img/action/autoscroll-off.png new file mode 100644 index 0000000000000000000000000000000000000000..a0b8aa69405f27c8f0eea086ab7ddf3c5f874c39 GIT binary patch literal 442 zcmV;r0Y(0aP)4~ za^wg@WMm{GZUY#ha*rN?48btq_pe_dH3C592L>cpGvRYyYilcO2h;qQFEI!`1-e8~kl~G!3&VRR zW~^St8V0;KZ!icQJq%{Qv$13Ntfo$UI#B-dnStlr83qPEK8Ej(&J28J<_vs5@rMr| z5@moU&(_C4cmc#ue*rbWwYGt4!lxNqI$(MH@Zs;rPo7{XymRLcLtK13B(Y#k2N1zON0u+g k>0bscIhT>r>}92|mcnpvV#YSn9~mIY+n8wrrC>EMaFd{myGX z)}!i9!MBo*OSpahv%R51B}wz>5eZ%H4^4lv_Lfg-ynXSsmdKI;Vst0Jz>!WdHyG literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/action/ban.png b/lms/static/candy_res/img/action/ban.png new file mode 100644 index 0000000000000000000000000000000000000000..b335cb11c4d1a397b307883adcfe1e00c4cf8e6a GIT binary patch literal 796 zcmV+%1LOROP)h5&w{Y-QlBkdy7eSyz8|k(w=syt3MbOGZFmTy2f|dnI3kj6Sz)H!` z%1hM3FiovS8#Qs98Rxs5^PSs#9S4da6*};44(Iv3@B5rb3BwQ$Is?0$0ilY#Q^EELL=6SfS*Ly93GR<|lPYvfPXoM^)HN1)!-B@N5Zi(e|Ge`rl{XLurIiz-D}wHL_^0`U`~aor)ql1d5Pg z6owrVjF7Of7j1n54cD~V+Wq~W=b7nXZIaQU<}$-D^Wh8g9iyuJPn7-MaF0z&l@1k7 zi;z(T6-GoDP~qCfbDCc}Z`LYsk4>`*H%xtJgQ?D-@mf%i7IOIIQNnlKSrOW6T6Aa~ zzzv!ft#0lKwzQ#%D*U(CNVtT$BA5z-ik%o65YF5q{4ms7cV2r-06S=ERhf?MznhZa zGrpJw`vq|!>WixoQA~L~vGVh0X>qPIVC$n@kA zgAe9Vv7m|wv0n;2S!@A_bF_Ik?_SL^JGp@R5EaWzcA1Gc%VZkUm>_}pGHl31$w(sf z+uZ2Ept_z!a-O424U9dTV*JG{cuTUO^>-YP_8i z`v#vMN1_$fg^2rkMu;VMV$!FWzJD?AEISTe*2Mszrd1b3X#X~G3v?T?bR#BKi;99M zL3}eC>Q11E<1D@G!$!0px~z-qtUQ0moD|RXC4}Mrzlg<+1Y8PEBfUp0jJpx4B>@E+cy3`^(Gw`Mf+2&yxZm<$to~Vpgvg&QKNR z_f#1(r6svZt%iF?s+n<8X?B&!h3g9Dbb8_=MX}!;HiQSAh`bp^WMl~Z-44teO7W_Y zV4thSL{h;rJY7!l3%5J4H1!tIzB`Dv+YxO(haWeausGZYkI8^hWj6mzo=L0{%;yxzh{5!Htr?51 zvG|W62MzC8BZ76hRpCyO2zOn<%e)K>NHge!-~)Ap33OdWw6hsLYbCxGNt0%wk_2z7 zfyYvXheSG)5HRK1VB~%mq7Dmurw#bi@hEcOr3&G1ZiF*$M=&9nB#VNf&Q^r$4G5kp zTURh&s)E0%5&hyVD}sp<72~zmAY`Y(9aqO6CXF%=zFHGzO-A&I(pE}v70YQxCPJ{Y z4L+?5-crdLn3ZRPEs!A4ehEY3ZRpL~w9>@aMN+{F4dI@v&>(QDHQum!mG~E^$OS8l z!7?%Uwib*ROP67Hw`ika)gX-(8Ia`-u_IEhxG7U<13kSsMW+$lbb2dUMm5p6pa}cjgA+U$^mJ^AjD?&bdi)8~y+Q002ovPDHLkV1g8IMc@Dc literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/action/kick.png b/lms/static/candy_res/img/action/kick.png new file mode 100644 index 0000000000000000000000000000000000000000..bce1c9768634180771b1fafebf331ef3db780208 GIT binary patch literal 859 zcmV-h1ElpF8FWQhbW?9;ba!ELWdKlNX>N2bPDNB8 zb~7$DE;u(kfL#Cp0@z7JK~y+Tos(-zlVKQu{Q*Tm5%eJ<%97}eboNxNk%yKwYTC4w zH8Dy}T}#{6YO7q!bfz|U%IT)1n3+~qmf6WlJE+6R&P4}=`U3@5?-uydm!KZFK3>;- zKMy=%VgGRgu056k$F3hwPpiNGlRo6heJRKcw;tiDEtTF=s*%xv(P+eAFrd@vC@LyK ztJR{>XvoXUBjDM~kSB*%7mx9Eu!+yl&e2h(;Qq-JF`(D$sjjXL;cz%YIGxTAgYF!@ z^fwAL@xI5-o7*<7ly0KivOx?4r`oISq}Vo$$}X3SAkQ4T`ShfLx80?@x?vHpa=Bz9 zo#q5F5L{VamW1SZRA_2Ybi3Ul`l{4??5z#S!&glLW-eKixolW124of~Sq&<7`qGF# zyo42oNR|{XBvLbvhvh0h^mrKVF!8)y&+|3|?G_oWg)4psq$gqqGV8Ze^|*xcUJE4; z%oN`@V!CsH1-Y}idoq=GckF_wW2mKoZ+FvaD^8#(KXN2&jWyb<_NPb&!WpB=XF;Ffo2WwJ2D`UK!n@xYcnz!v+X+0vvn-wm;LG-?5$SadrZ(GaU>n#nBn zbiy;H(Qc6N;z}`3>oV!{Y@_c?2CXIut{qdwfJ&tzM!TH2eNlM5UTSM=L)6vPaiK6u zxc^~6+|ENsDvzA$v>aNET{T$@D3wZTYHEnhUpXoUWuMPSW5HqqEqX!TLQkcf2UQBr z8{?=@P7nhMh2oE?K{1Hm@5i4rpMj?R3<~r&Di}MF#v89X?19G{X#NF=y3!#P{9h!$NGm&{VG9bZSYA)3<7W5mGUzNIr`$|T{w%PP|*ZZA(A lUtFdb>nm4I_$dUw{Q{y_0qVHs(>?$I002ovPDHLkV1jYJiE#h` literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/action/menu.png b/lms/static/candy_res/img/action/menu.png new file mode 100644 index 0000000000000000000000000000000000000000..be4540c436f6ac2a09e7f99cc7277c2579eadc78 GIT binary patch literal 1229 zcmeAS@N?(olHy`uVBq!ia0vp^oIuRV!3-o9^qNHiDaPU;cPEB*=VV?2IV|apzK#qG z8~eHcB(gFvFf#=Bgt!95?dkO-!8JjERGZgNx4o8zwI5pnrkJT^AW7B+k01lLkoCLBu@bfRhnJSkT}{ z5cwz-C?AEk;`Q7|NyO0V@Jst%?>X<@)8|?6hNplZoH}p}R=_wBd4A);huvr*D{8ta zv>weaRZy(zA{a{x)NMK$oUyoq;&Q_jA9Yid>V_!Q3{lkDazCTg+2GL8fKO$yVv7pZ zw#ZdlB3o|B^q?JFdAt+nq80!As0d}1Z|KFR(*lEh^G}{es&~_I}wY;n4d5jAs0d}gj@(+ z%6*K*29I(Myv%_UPWDD&5qS@s3~ZAJGDH vT*SlN0ce3)r#d%-F%SaNZe6;L@E^Vb!Ji3~dec0&00000NkvXXu0mjflI-*P literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/action/settings.png b/lms/static/candy_res/img/action/settings.png new file mode 100755 index 0000000000000000000000000000000000000000..327fdf496396160bef8fdc76c4bbd6d506e17f2f GIT binary patch literal 744 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 KMNUMnLSTZ{UFO&T literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/action/sound-on.png b/lms/static/candy_res/img/action/sound-on.png new file mode 100644 index 0000000000000000000000000000000000000000..b4351604a72146d2d6a6d9664a31f5f8887f145e GIT binary patch literal 544 zcmV+*0^j|KP)6Q4q)Hxhv>F5XFOD zyhwp~5$aO^Le~z_(G3HsjKxC)60s79D6v$KKcGLMi2j4UdGIC)86tTT5&ZqLnWcRg zm=_N{7-rsk^ZmS;_jcKQK8JON3Dz7d0m)?YAdyIXt;!LXEv8Z_VK$pxO{Y^Cy>)S~ zP$)>_@%Swq4mUU;lgaEDcR?OSPfn-P=WsZnR4Q?CE|s1hd3}1o-8+5toFXHj|sl{TU z_?-i&&vLn}mrZ1k#{={NbSlAOvDibq-HxE>l`(cpP|Q;~04KW$;f57`5X9wjf$_X} z@;&D5<4Zxa*(|2h={s6y%l-vPk_248#_$}?n$PE%u@{X-kFkOmRaG1O!}-fXq0k{( iV^t3O_rLL5fB^u?I7KURR%+k?0000;g_qz*)!Uw0*DfRXB;qLAZ$z&48$H%z2xj}zd7hVyg6N6#;htp{#NP?4R z417M{qf)7~(c9Z=ZfR*jEEWT!Znat&eI%k$nAXm1_LT%V~|CWUn)~}CPO}w6D0uA_U`QLz}VObX?>l8+o_crj|VEU zjgF3TW~?-c*o4Dj2JP) zl!zA)6l}D~3s`Em?4?Yhi=C~d53rSX$*wCZR^kIl^a0euLX9ZwZt#MNuJ=o34*0Xl zCQULhaORxv`)1C}Io$Dhg#WP*zi;GnIm>RhyEq&U*X#BA4A+23e&EqpTt9|Fq4rxm z_qKveCKE)`om#EdSu7TiNF;23xm-e{(ST;N`GVnXG#bsn6G*4i!PRQ@#qDf>g!`G#b@Mx%jFrvp^F*=(lr zSC3E+Q2KUt-yMtyT-IRtxYNu-$IKWHK=kT4Ms}^?IOE zslefI=rO(|E)&4u%4)U3U@%}{Dx19BZZkn5lP?wvhVghjl=Mmvz`LJIrBWD=$BalK zlM#?I(Y?uMvl#;unPTb-Hc-F{6=3i*pU*)qm$POO zn6iC>gs+P|q(C6R(>S}`E}J=tD3wYu8jXyc z&1PRkBGFAEk;uP$%8^JUh#Bu(E|<>h^|A(%Q8UZsGFA41nQxQHWd4tTfh-&jTX63J t{Tj2MF%ZYG&xCuANdEh0`WO2tzyND}g0&6oyTSkf002ovPDHLkV1h8tDD(gT literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/action/subject.png b/lms/static/candy_res/img/action/subject.png new file mode 100644 index 0000000000000000000000000000000000000000..7bc9233ea63c89d52a99494dd0f0735a29a3ec3b GIT binary patch literal 413 zcmV;O0b>4%P)i2vikyMR~)n*keF9=!Gc_n*K2@qsNT?}H4v4a974 z1ArVJApZ0B-@pGKzWw|E^3%Wn&p!V9|K$C@{}12&`+x7vzyG&i{r!LE6~yrB1;;^# zm?0Y=moxPMSn>r>N00000NkvXX Hu0mjf$^yWL literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/action/unignore.png b/lms/static/candy_res/img/action/unignore.png new file mode 100644 index 0000000000000000000000000000000000000000..89c8129a490b329f3165f32fa0781701aab417ea GIT binary patch literal 781 zcmV+o1M>WdP)4-QibtN)VXQDpczE`xXAkUjh%RI>;okxb7K@0kpyQ1k_Y(|Oe7$m(^ zNYX>mI||sUbmn+c3<&FnE=4u#()KBS^SH8e)Qs5i!#lY=$-1gbH6VluzU=m=EP78&5vQ z-?+fFP-G2l&l_QzYealK$;1Rl?FkzXR&Jv@fBPNjCr#AYRyJ7UJQ0v#?)7Ott=>3`#-pV!7>9}>Q1jL)H6h&gkP@3nI=+F3nA~M>u#(n* z8T!#8oEw&-mED4!h4s!N@Jo3S7N&Q6%6l3}nlcd~X@>;uelvPsSkXIgg~e+^T1zSf z3SNj(5%jK~i8@b;C(G`SI(6vnfMgxg){D+Lwutc1Si0swhN#FwOv#}l83ts6rCW;r!9Q9l zl<41549yYiq6clJ;(J(YgF_14nmKFB@QK(mo6I~sr{BJxJ$rsp0HSt^ntND0Z;o48 z>O2Ckm9}n?$F`*>$L{;{zT>f+bCm7tpaqw^4q@%k z&cHHt3=3xZmt6rQ_dtDM#)Xwp66-Thu=<9?(zFvpy0gAr0U4Z3smE5f@pZNr!NoqT zEjSPuCQzMw(H;?yvf{+e;!7(;4hv)+d%cjKFiBL%egy0aeCof8z<>rLEjMsF|CBRH z86WcxAYvS6H;Yq)jY1Z-rrjWiu~m;clLmJlDAE7UhMJ*jBxp}s&nQkrZvqDXxsiv3 zSJ78>4W2GFIu$$+Ic&5Pq{1?zhIy(24enCZy35e>z6~XgVx$x%k(+>tPw)9SL~R?4 zs${`1bqjTFC3F)dxIIw>)!QP7$vk+;^#2c5r{lsjtwKYnfnn+j{~{GK;|I8rvPFU z5NbS#W7m)ofjNER&&ggR6fXi0xd4%4143#8JZlhXW+2TN#8b=5@L&-EUlY^cTT=>w zb_~+jfcRCYfdj}H0J49#sP#gtxE~%YBJiQ3AjMgoQJKuMITA}Iz|zizG7pw|7R*XF j=$D`QjOCK>V3B}dL4UFUkhgq600000NkvXXu0mjf1x-zB literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/context-arrows.gif b/lms/static/candy_res/img/context-arrows.gif new file mode 100644 index 0000000000000000000000000000000000000000..d02df8c43f555bcc568701dbbd52ebdfc5053a27 GIT binary patch literal 91 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 literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Aww.png b/lms/static/candy_res/img/emoticons/Aww.png new file mode 100755 index 0000000000000000000000000000000000000000..35128639878e612a94e417b5b9e5cb533345c068 GIT binary patch literal 3352 zcmV+z4d?QSP)KLZ*U+IBfRsybQWXdwQbLP>6pAqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uhf59&ghTmgWD0l;*T zI709Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-rV&neh&#Q1i z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_IfqH8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4 zfg=2N-7=cNnjjOr{yriy6mMFgG#l znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U zt5vFIcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya? z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0 z?2xS?_ve_-kiKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$ z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4 z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i& z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01 z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3| zawq-H%e&ckC+@AhPrP6BKT#_XdT7&;F71j}Joy zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot2z=0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F} z0006*NklFg4ROogLi4lTipk&@JemkhC3s zGt#?J5=GES2$aM_x0LmVm49v%wL=S;W6){gpZ(tF>F`@?Suec2@bdllywCGms!I8a z#iGN&FwgZ}hKZEbDs=)TmA909w|KG81q6T` ikOW@ZcKmfY_SXQ((t8+z2~yDj0000KLZ*U+IBfRsybQWXdwQbLP>6pAqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uhf59&ghTmgWD0l;*T zI709Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-rV&neh&#Q1i z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_IfqH8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4 zfg=2N-7=cNnjjOr{yriy6mMFgG#l znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U zt5vFIcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya? z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0 z?2xS?_ve_-kiKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$ z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4 z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i& z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01 z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3| zawq-H%e&ckC+@AhPrP6BKT#_XdT7&;F71j}Joy zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot2z=0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F} z0007YNklGt%bysNx>pP4YW{}Pysn8b^(s zzy1lDC@~rnPezO!D2X=(+-g}aq-F7772AfO9;&kRJL929>N1zfyu3GgnY^*7DwP$B zMTdc5pa%#53&1Rpu&j8Zl56`dj>V#GVASXHo%4Eqcsw5J4%U&+<;dl7EUzrHy0ZEd zxN2GP^*wPc7HtM*LZNV7I2=Y%P(@WyD0fw+Uc6vtW~KlHEGwS3ix@lb?Cj8x_f&uX zE)f*n6RDP$H6CsR8~d}_Y*QpM@W>tt1uu4Xc3wYo`V5LPJw45nu`ya(T5!2seEs&7 z(L19UV>r}MPeVfk8yg$`vB!@yRlue8jt)eHwNIZ&zI{vJSb&Md1fs%ZVv??|E>fu! zpFihNRs4QGz~w5SuioQ9MOa*1WN>hhP$-1c;beJf8HdwBFcc&b3A2=0LV?;^H$aaA za97tj49*!LLOk&0X#~l z)5AxO9zj*JW`yZ71L+T`TP6WqByimTei(9l^#45C6NodI}gi$-tQfmARY40LxN z`@^=VqEenk1!GFgzMEryZsDnA#m`q+R@?v=rk+m~UcG!>wl4-Ff`}ku*ePwZwY^F5 zO_KS!g#vKxzu9hTZaUY}>cd@I%RZ-r;`btje1X-K_hdhO{5RWweboqX1?U6Zz&elw g9#~dx(LLRsaA107*qoM6N<$f|yfDdjJ3c literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Blushing.png b/lms/static/candy_res/img/emoticons/Blushing.png new file mode 100755 index 0000000000000000000000000000000000000000..ab03ee8c5dbc3e33c04758e04833b9ac26000618 GIT binary patch literal 3403 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 literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Childish.png b/lms/static/candy_res/img/emoticons/Childish.png new file mode 100755 index 0000000000000000000000000000000000000000..1a31c5068eecc6ec4e9eef75f7eea1edf039545c GIT binary patch literal 3411 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 literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Creepy.png b/lms/static/candy_res/img/emoticons/Creepy.png new file mode 100755 index 0000000000000000000000000000000000000000..561505895f4d9c5ba20adad5687a12b8f9691359 GIT binary patch literal 3417 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 M07*qoM6N<$f;S>^egFUf literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Cthulhu.png b/lms/static/candy_res/img/emoticons/Cthulhu.png new file mode 100755 index 0000000000000000000000000000000000000000..fafc4b3eb8ef33898d36ee7d7d5d897b5938109e GIT binary patch literal 775 zcmV+i1Ni)jP)3LBXkZp5`~YJ~1YGD!FhPllXaXx%*2bMXDK+60jHw!TG)gg21C&q< zR63m!p);L__Z!+~{F0NKnYm}qz2`d?UDshgBO}8XsRRhO2wuV#VU{p8I{GAL?zNc7 zM11b?cph|jUxv@;L#3w@*=!cS5(z}3QHY{APCs~9CNMI?@bIH&q0nnRpU>;6s_KfO z=!HT-mt{$pq`WTXL_HXMrL(>g$51B4R#J=y0{w6}9P~jmK59mj22DGzNBJX!f+6H` zxi*?$)mj`N(ACui`w2T#O+ALnpp>gZ;A}Q@bhe}D3AiG;$9KVp&5aE#EH2V6z}0J4 z(Aanuo0~~2Eq#E6US7X(6HQG`h;pBLiF2={T+7CNH$9Dh8ol#U2WDnw47Erkg2917 zw0E>49G-@=!U?M8XlHl4)gQ=jRrTy|bbKtJS)1CWqJ7e!}nf zV`6dwRn=ACc^(#v1$-}$7ca-*KIukp50Bm7yJV_!&b;$fZ24QDrR6;O`fh_=y+$gB zM~C1XR`l?G=;SzCV{2<0b#-Tg|Al01?CE4{TkAl5eLWkieE&w1#Y&*v(rF~t5^siv z?%$!;QfX{RlJq#f5{FVy;B-2X-b-VBeI5Jze-K~!jtaLEsZ2SbzxIMhJAYFhR<7f|36TFaUG4ZVu`&00;m8002ovPDHLk FV1l&}T>St5 literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Cute.png b/lms/static/candy_res/img/emoticons/Cute.png new file mode 100755 index 0000000000000000000000000000000000000000..a883ac321baafb635c6c1962351aef546666e84e GIT binary patch literal 3369 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 KMNUMnLSTY)_fPZy literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Devil.png b/lms/static/candy_res/img/emoticons/Devil.png new file mode 100755 index 0000000000000000000000000000000000000000..afc5c2cde5f1c81a28ea7ffa98e9580b98eaa348 GIT binary patch literal 3478 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 Ef?8yRuK)l5 literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Gah.png b/lms/static/candy_res/img/emoticons/Gah.png new file mode 100755 index 0000000000000000000000000000000000000000..b03ee1b16308f6e5435dd19f5decda8bda418517 GIT binary patch literal 3415 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 literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Gah_2.png b/lms/static/candy_res/img/emoticons/Gah_2.png new file mode 100755 index 0000000000000000000000000000000000000000..b682458ecec97465b493a32ebd1f9d7aadc95b45 GIT binary patch literal 3401 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 literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Happy_2.png b/lms/static/candy_res/img/emoticons/Happy_2.png new file mode 100755 index 0000000000000000000000000000000000000000..13326863b561a163e9adebb1216af5ecd17c91d6 GIT binary patch literal 3433 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 literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Huh.png b/lms/static/candy_res/img/emoticons/Huh.png new file mode 100755 index 0000000000000000000000000000000000000000..241f50f4f7f920c34c8527db952d2100d890e0da GIT binary patch literal 3417 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 literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Huh_2.png b/lms/static/candy_res/img/emoticons/Huh_2.png new file mode 100755 index 0000000000000000000000000000000000000000..a1a54e4b1a26bb8c6f5b1f3c3a8e0d24e55893e9 GIT binary patch literal 3429 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 Hu0mjf#Q$b- literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Laughing.png b/lms/static/candy_res/img/emoticons/Laughing.png new file mode 100755 index 0000000000000000000000000000000000000000..edefc957a6d2bd34ef94fb9802f438e55ec6ee7c GIT binary patch literal 3450 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 literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Malicious.png b/lms/static/candy_res/img/emoticons/Malicious.png new file mode 100755 index 0000000000000000000000000000000000000000..23f25792f1b43060839601610c345530888172ab GIT binary patch literal 751 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 RJ`ex^002ovPDHLkV1oKoYB~S_ literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Smiling.png b/lms/static/candy_res/img/emoticons/Smiling.png new file mode 100755 index 0000000000000000000000000000000000000000..725eef526d704d45f1bff18a035d666fc08f1445 GIT binary patch literal 3390 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 literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Speechless.png b/lms/static/candy_res/img/emoticons/Speechless.png new file mode 100755 index 0000000000000000000000000000000000000000..4fc424602ec5f925806a773663508b9fc3cfd35f GIT binary patch literal 3352 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 literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Stupid.png b/lms/static/candy_res/img/emoticons/Stupid.png new file mode 100755 index 0000000000000000000000000000000000000000..3fcea491a5ab1d10a5853c3e65d256eeeb092b82 GIT binary patch literal 3422 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 Ao&W#< literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Sunglasses.png b/lms/static/candy_res/img/emoticons/Sunglasses.png new file mode 100755 index 0000000000000000000000000000000000000000..cad8379742b4c580096013907243a56bf1ed5887 GIT binary patch literal 3433 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 M07*qoM6N<$g7k?-Y5)KL literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Thumb_Down.png b/lms/static/candy_res/img/emoticons/Thumb_Down.png new file mode 100755 index 0000000000000000000000000000000000000000..4f70696dd8b10a16342e3969778d9eff27912a6f GIT binary patch literal 572 zcmV-C0>k}@P)JP0000PbVXQnQ*UN; zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz-bqA3RCwBKQB7+TVGw?@yJYtu1a<>$ zJs4;N4Va|u!P;Xz)N?(lAP8ci(33a8^ic5!crnmc{Rv`0YzZ1e+6v0XlwOQWl71jb zwp58_Hy<{;&a7S0=)m(1GtWFT?>le6bzQK{&!6yUgyhh#DVSy67&O+qY~ zIg{(WA}`evhu}p6A%IEtsasf^9LE%hfZPj9L6x zJX0xG25Pk$^7%ZDz8@iaHVR3SuvOebwNiaVYkd{)Kknr7E|MD|+`4|_+QiW5Au#`R z`5Btc=6Mp7yhJ}zsr!dtYWwctw*xo(_O(m#W1kcHx52v(%0ZO?yJcf@;~l5dKL3w^ z#x}d1ZhB*V6EB{xpxtgiq};a8OZdOt(?95Y;>xK20000< KMNUMnLSTXlgbM5c literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Thumb_Up.png b/lms/static/candy_res/img/emoticons/Thumb_Up.png new file mode 100755 index 0000000000000000000000000000000000000000..2ca0e0d087c7ade7a361215301e0f8f706690208 GIT binary patch literal 530 zcmV+t0`2{YP)JP0000PbVXQnQ*UN; zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzv`IukRCwBKQcX(&Q4~FsL*{BqGZ5Tl zi>UB3C|qRVwjWR=Z3BNmyKGUJ+kzlkxQ+S;u5uIfK@pjSK?^5{7M3YEexNhcy+ae0 zJvejcy*uZfbMGBN*LD0|8yg9ULY#2Axw&=Ko$JH@D>XM4Nx9wbOCoRo%CJH=5{Y#w zncUVjP16-c(RX%|I-PmVe2)TIs5YC;vQk%I9k3!AjpEy0uO*V7CaluyQ>jz}sUR2% zL6W36osa(zXu}6Nm&3r5 zUf&?gGUVb*nw=2gMlgRqB3US`efPUYXb{c_8Tv3|*77C50I#jx U5b25Rd;kCd07*qoM6N<$f+<<(%m4rY literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Tired.png b/lms/static/candy_res/img/emoticons/Tired.png new file mode 100755 index 0000000000000000000000000000000000000000..13f7d1207a113402d0cec35df8b7a2458ad11efd GIT binary patch literal 3338 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 UHHFj3_y7O^07*qoM6N<$g416#&Hw-a literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Tongue_Out.png b/lms/static/candy_res/img/emoticons/Tongue_Out.png new file mode 100755 index 0000000000000000000000000000000000000000..3d154f90810c28f70ce53df3a0c92adf1eb2aa88 GIT binary patch literal 3403 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,HK1WwBVi{nu{)^Q*2IV{RE^x-HA{*|z=D7_+L% h)A8}~=Rg_wH2}%HXA1+;WwihR002ovPDHLkV1nLbVM_o2 literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Tongue_Out_Laughing.png b/lms/static/candy_res/img/emoticons/Tongue_Out_Laughing.png new file mode 100755 index 0000000000000000000000000000000000000000..fba5d751480e7bc6c41ebfa9ec95df203dacc7bd GIT binary patch literal 3468 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 RP3iyu002ovPDHLkV1fX+Q4s(D literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Tongue_Out_Up.png b/lms/static/candy_res/img/emoticons/Tongue_Out_Up.png new file mode 100755 index 0000000000000000000000000000000000000000..46328fbbeea90dc0ae964db14c4616dd41d402ca GIT binary patch literal 3362 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 literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Tongue_Out_Up_Left.png b/lms/static/candy_res/img/emoticons/Tongue_Out_Up_Left.png new file mode 100755 index 0000000000000000000000000000000000000000..b67b69fb7a25805d0ea12c2708a6d5f2aeb2ac48 GIT binary patch literal 704 zcmV;x0zdtUP)HAjJc8BE|H%`@R}#2JZ1pwkcZ zp1*Nwy*p2@ts#@i$n1y`luVkYnrQL)&cuZ8*7$fg5JrMi$Z$s*xI94H!CovDz0v6H zWr@J4U@$lux*39YF1WLEZxRh-VUq9Yep*GPQbDy^g(R6UJu}S}MxpQnbO+GaXMrqpw~*sAFX!|5 zzXc*GiUO`YPs literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Tongue_Out_Winking.png b/lms/static/candy_res/img/emoticons/Tongue_Out_Winking.png new file mode 100755 index 0000000000000000000000000000000000000000..2a22cf6a9fcec5ccad18247dea6ea0b033f412a8 GIT binary patch literal 3416 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 U2pygf5&!@I07*qoM6N<$f+Ivk>;M1& literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Uncertain_2.png b/lms/static/candy_res/img/emoticons/Uncertain_2.png new file mode 100755 index 0000000000000000000000000000000000000000..a7f5370d2c8c17e42caca23accdb5df652d00ea5 GIT binary patch literal 3378 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 IM6N<$f*F@bumAu6 literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/emoticons/Unhappy.png b/lms/static/candy_res/img/emoticons/Unhappy.png new file mode 100755 index 0000000000000000000000000000000000000000..79fc0c00ff6b2d4e01efee3b5b72e789de0ba78c GIT binary patch literal 3408 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

Px# literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/modal-spinner.gif b/lms/static/candy_res/img/modal-spinner.gif new file mode 100644 index 0000000000000000000000000000000000000000..f8593d57bf287bd214de334a19c3125a05e61acb GIT binary patch literal 723 zcmZ?wbhEHb6ky(-^ErQN@O|JScy`}gm6b8`y}479Scx_I%TpP!$NjZIEYPF-DHOiYZu zy*&dADE{a6a}5c0b_{Se(lcOY1d1#ElX5OfO)N=GQ7F$W$xuklO03AqPfXFv%uCB> zQ2fcl$qiJc12O^RNCsA21%OAgH;Z&01NnVn)NaLjR`7OP#jie4tXFkxhH~QlVZ^6d17ccJ+_M3Sq zlv_(=n%^M-Y(|*^jatItDj34l``9E&#q;7Tn||T*rIpXEXYnp)KG9;Aw88zCX#bgs z#g;5mm8-9H@6wtZq=?NVBS?5$+Z6H4C2;Z%k4s5Gp{^&l#6Gf$dpqqmW7^^Pz!%Dl zy(gZXU`?Lqwesp$DXe}ohZv($7~8U9Q_`}`o2`k{GafJ6mOCq!PvOnqrtOJ>5193) z`>OQMFky<o8GM|l2HxX!u!B<{qOBE1=G2|bsd>i b*edfYPM5)i%{g-Y608A4vKiK(BmxEiJAMGe literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/overlay.png b/lms/static/candy_res/img/overlay.png new file mode 100644 index 0000000000000000000000000000000000000000..0593fcf0b6335276db20dfeab86791c89d968c36 GIT binary patch literal 109 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k8}bl$r9IylHmNblJdl&R0hYC{G?O` z&)mfH)S%SFl*+=BsWw1G{GKk3Ar-fh5)u+-a0(mdKI;Vst E05l97fdBvi literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/roster/affiliation-owner.png b/lms/static/candy_res/img/roster/affiliation-owner.png new file mode 100644 index 0000000000000000000000000000000000000000..b88c8578956ceec4ff17f81995b8652f6aa2b58d GIT binary patch literal 670 zcmV;P0%84$P)rx?szq&Dw38OK zY!^{rCAFy_2z8TV&4=Ube7+y|oYO*02OOyb5BD7I^ZdAQt`ZS+tMaFrb6^=AxbXHx zH;=|4CCm%L{PZwSS3v3G^sH+#W3JcR_xs(&`Tqt8^J9}d0vU#im5^f#04JL4qMaI^seoYDXwB>7;oyw=|M z1!ayym?6XvqV3ae_f95{py8ukt2TxB^!VIzRRh4#rNu~y^X+P>L{SXo3_|Qqm>9wY zz(9!5s#OBElpmj4DRyjO`0`RiEIkUg%7D)8y}}Ye3}prow;JG>UQOIs{kfZSJ9bYz zskMPbH9)1H6FDf)1=ZKVfe+;jf`a(O{!9meiN~~d0iA$0qX=t0D6Ydx4#RO76h@#R z9_k7Z;$fv6G>QeZ{Yu0n&xL4%!?l}UPj4!j&Vs@?dl=y8#_IQ`5I-5a_T$dJtJ_~5 z4&186>klZh{hfbaC4}Mrzlg<+1Y8PEBfUp0jJpx4B>@E+cy3`^(Gw`Mf+2&yxZm<$to~Vpgvg&QKNR z_f#1(r6svZt%iF?s+n<8X?B&!h3g9Dbb8_=MX}!;HiQSAh`bp^WMl~Z-44teO7W_Y zV4thSL{h;rJY7!l3%5J4H1!tIzB`Dv+YxO(haWeausGZYkI8^hWj6mzo=L0{%;yxzh{5!Htr?51 zvG|W62MzC8BZ76hRpCyO2zOn<%e)K>NHge!-~)Ap33OdWw6hsLYbCxGNt0%wk_2z7 zfyYvXheSG)5HRK1VB~%mq7Dmurw#bi@hEcOr3&G1ZiF*$M=&9nB#VNf&Q^r$4G5kp zTURh&s)E0%5&hyVD}sp<72~zmAY`Y(9aqO6CXF%=zFHGzO-A&I(pE}v70YQxCPJ{Y z4L+?5-crdLn3ZRPEs!A4ehEY3ZRpL~w9>@aMN+{F4dI@v&>(QDHQum!mG~E^$OS8l z!7?%Uwib*ROP67Hw`ika)gX-(8Ia`-u_IEhxG7U<13kSsMW+$lbb2dUMm5p6pa}cjgA+U$^mJ^AjD?&bdi)8~y+Q002ovPDHLkV1g8IMc@Dc literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/roster/role-moderator.png b/lms/static/candy_res/img/roster/role-moderator.png new file mode 100644 index 0000000000000000000000000000000000000000..0d064d114012d7f70a27397bea8f71d1641d14b6 GIT binary patch literal 594 zcmV-Y0pF8FWQhbW?9;ba!ELWdKlNX>N2bPDNB8 zb~7$DE;pxyoy-6L0nbT9K~y+Tom0C?Ye5uEWo2b^E5E`&v9Yo72mAsH38V>&MDwXqY zw=0v$L=ixZq|<5bTY^@rwTrM$;8#Q80^rSW@L(_ye3yQ|FU4X};_>)PG#b5*L?U`P z96k+&Li?shqcIx}hccZ`WwBVOIiJskdmxo^xhx=+cDt?Kk%we583B=Kn)awv!S4;b zESF0KWN=U=*jJsi*{md!Nh(l;Tmyl?%IEX#DFSTnpzw~;XS11-P2Vg=fRak36o^W= z`u+Yjz&}1^g2G*NdSLRsUQdAwg@TfsNF>yxe1KoQUhn?5fuhqHJJSY>adb+0EEfB) z;qiEM!{bDkD|)9DDj*prEs%t(%jFsw5+K2%&366m^q%M-w* zkzg?R2ESJrmu|OvgVdD@yWRd`NPx?+#^qS!d@sHhIUJ5to6YuQwOW4}5}@nxpYFTG gVmUIK%^&~8I~Pu>lX<}Gvj6}907*qoM6N<$f}|}6rT_o{ literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/img/tab-transitions.png b/lms/static/candy_res/img/tab-transitions.png new file mode 100644 index 0000000000000000000000000000000000000000..1a361e4d47b674ae611990d65a424b296b2fdaf0 GIT binary patch literal 490 zcmeAS@N?(olHy`uVBq!ia0vp^DL~A~!2%?kE*&-lQcOwS?k)@rt9q4IX@@AD88h!AeF(4&1<<)S{fk zp^%(WnwMRyP@YjEB<5wG8q|kKzxu41CwM=eZy(C`;TT9o{heHwDj}4 P)RXFLA)Ok5j11NQH#`&m literal 0 HcmV?d00001 diff --git a/lms/static/candy_res/notify.mp3 b/lms/static/candy_res/notify.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..c00d997622f3e12d55d01e33ed1ba44c38c1184d GIT binary patch literal 1095 zcmezWdqN5W0T7Xymkw0I55&w23@q*pWrV;DLf}6k0JO`}$I;i-SkKa|h$#=`LLF@e z1`!7pc0WG`1_zKko^SiN6eKaM!T;X{zzAY&@;sKqyoJ#_xHkUq zJrtzY%c{ZqSa|2mip`~hMlfr|?2b*e~Um_GV2M-Yd;lV{32h%bRgQ zD@i+*k)h^lz?Dg!Dh#>%53ipiDkZDEv{0vuF;iFfViSwO%#|_`nqp$}ZKSwDr~Lo1 zu;b6w*Z(j7-fy+F{`c}1-Rx%9|Ns57_xh>X6Yl^6gOOWh2tFtE8?m6EvJz~I=V zc$iCpq4(70fd8M6aOE{leqFCpj(?ig>TW(;_rFkmZ~VV~)~d?;x3-(B zc)#BM`H$(*aBZ)3n?9EESp5^{*O{?l)7`HZ&Tum}h&28_w8L{xpKV=z!vrD8Kdc-_ zkH$Y*mM xqGz}Y2l)S6AVrY>0|P@QI|Bm;0|T$Z0|rK52rdC84Fv`UlLlZu!l9KUVE`GIgjoOp literal 0 HcmV?d00001 diff --git a/lms/static/js/candy.min.js b/lms/static/js/candy.min.js new file mode 100644 index 0000000000..91923ccf68 --- /dev/null +++ b/lms/static/js/candy.min.js @@ -0,0 +1 @@ +var Candy=(function(a,b){a.about={name:"Candy",version:"1.0.9"};a.init=function(c,d){a.View.init(b("#candy"),d.view);a.Core.init(c,d.core)};return a}(Candy||{},jQuery));Candy.Core=(function(l,e,f){var d=null,j=null,a=null,g={},c=false,k={autojoin:true,debug:false},b=function(m,n){e.addNamespace(m,n)},h=function(){b("PRIVATE","jabber:iq:private");b("BOOKMARKS","storage:bookmarks");b("PRIVACY","jabber:iq:privacy");b("DELAY","jabber:x:delay")},i=function(){l.addHandler(l.Event.Jabber.Version,e.NS.VERSION,"iq");l.addHandler(l.Event.Jabber.Presence,null,"presence");l.addHandler(l.Event.Jabber.Message,null,"message");l.addHandler(l.Event.Jabber.Bookmarks,e.NS.PRIVATE,"iq");l.addHandler(l.Event.Jabber.Room.Disco,e.NS.DISCO_INFO,"iq");l.addHandler(l.Event.Jabber.PrivacyList,e.NS.PRIVACY,"iq","result");l.addHandler(l.Event.Jabber.PrivacyListError,e.NS.PRIVACY,"iq","error")};l.init=function(m,n){j=m;f.extend(true,k,n);if(k.debug){l.log=function(p){try{if(typeof window.console!==undefined&&typeof window.console.log!==undefined){console.log(p)}}catch(o){}};l.log("[Init] Debugging enabled")}h();d=new e.Connection(j);d.rawInput=l.rawInput.bind(l);d.rawOutput=l.rawOutput.bind(l);window.onbeforeunload=l.onWindowUnload;if(f.browser.mozilla){f(document).keydown(function(o){if(o.which===27){o.preventDefault()}})}};l.connect=function(o,n,m){d.reset();i();c=!c?o&&o.indexOf("@")<0:true;if(o&&n){d.connect(_getEscapedJidFromJid(o)+"/"+Candy.about.name,n,Candy.Core.Event.Strophe.Connect);a=new l.ChatUser(o,e.getNodeFromJid(o))}else{if(o&&m){d.connect(_getEscapedJidFromJid(o)+"/"+Candy.about.name,null,Candy.Core.Event.Strophe.Connect);a=new l.ChatUser(null,m)}else{if(o){Candy.Core.Event.Login(o)}else{Candy.Core.Event.Login()}}}};_getEscapedJidFromJid=function(m){var n=e.getNodeFromJid(m),o=e.getDomainFromJid(m);return n?e.escapeNode(n)+"@"+o:o};l.attach=function(n,m,o){a=new l.ChatUser(n,e.getNodeFromJid(n));i();d.attach(n,m,o,Candy.Core.Event.Strophe.Connect)};l.disconnect=function(){if(d.connected){f.each(l.getRooms(),function(){Candy.Core.Action.Jabber.Room.Leave(this.getJid())});d.disconnect()}};l.addHandler=function(q,p,n,o,s,r,m){return d.addHandler(q,p,n,o,s,r,m)};l.getUser=function(){return a};l.setUser=function(m){a=m};l.getConnection=function(){return d};l.getRooms=function(){return g};l.isAnonymousConnection=function(){return c};l.getOptions=function(){return k};l.getRoom=function(m){if(g[m]){return g[m]}return null};l.onWindowUnload=function(){d.sync=true;l.disconnect();d.flush()};l.rawInput=function(m){this.log("RECV: "+m)};l.rawOutput=function(m){this.log("SENT: "+m)};l.log=function(){};return l}(Candy.Core||{},Strophe,jQuery));Candy.View=(function(i,b){var d={container:null,roomJid:null},h={language:"en",resources:"res/",messages:{limit:2000,remove:500},crop:{message:{nickname:15,body:1000},roster:{nickname:15}}},a=function(j){b.i18n.setDictionary(i.Translation[j])},g=function(){Candy.Core.Event.addObserver(Candy.Core.Event.KEYS.CHAT,i.Observer.Chat);Candy.Core.Event.addObserver(Candy.Core.Event.KEYS.PRESENCE,i.Observer.Presence);Candy.Core.Event.addObserver(Candy.Core.Event.KEYS.PRESENCE_ERROR,i.Observer.PresenceError);Candy.Core.Event.addObserver(Candy.Core.Event.KEYS.MESSAGE,i.Observer.Message);Candy.Core.Event.addObserver(Candy.Core.Event.KEYS.LOGIN,i.Observer.Login)},e=function(){if(b.browser.msie&&!b.browser.version.match("^9")){b(document).focusin(Candy.View.Pane.Window.onFocus).focusout(Candy.View.Pane.Window.onBlur)}else{b(window).focus(Candy.View.Pane.Window.onFocus).blur(Candy.View.Pane.Window.onBlur)}b(window).resize(Candy.View.Pane.Chat.fitTabs)},f=function(){b("#emoticons-icon").click(function(j){i.Pane.Chat.Context.showEmoticonsMenu(j.currentTarget);j.stopPropagation()});b("#chat-autoscroll-control").click(Candy.View.Pane.Chat.Toolbar.onAutoscrollControlClick);b("#chat-sound-control").click(Candy.View.Pane.Chat.Toolbar.onSoundControlClick);if(Candy.Util.cookieExists("candy-nosound")){b("#chat-sound-control").click()}b("#chat-statusmessage-control").click(Candy.View.Pane.Chat.Toolbar.onStatusMessageControlClick);if(Candy.Util.cookieExists("candy-nostatusmessages")){b("#chat-statusmessage-control").click()}},c=function(){b("body").delegate("li[data-tooltip]","mouseenter",Candy.View.Pane.Chat.Tooltip.show)};i.init=function(j,k){b.extend(true,h,k);a(h.language);Candy.Util.Parser.setEmoticonPath(this.getOptions().resources+"img/emoticons/");d.container=j;d.container.html(Mustache.to_html(Candy.View.Template.Chat.pane,{tooltipEmoticons:b.i18n._("tooltipEmoticons"),tooltipSound:b.i18n._("tooltipSound"),tooltipAutoscroll:b.i18n._("tooltipAutoscroll"),tooltipStatusmessage:b.i18n._("tooltipStatusmessage"),tooltipAdministration:b.i18n._("tooltipAdministration"),tooltipUsercount:b.i18n._("tooltipUsercount"),resourcesPath:this.getOptions().resources},{tabs:Candy.View.Template.Chat.tabs,rooms:Candy.View.Template.Chat.rooms,modal:Candy.View.Template.Chat.modal,toolbar:Candy.View.Template.Chat.toolbar,soundcontrol:Candy.View.Template.Chat.soundcontrol}));e();f();g();c()};i.getCurrent=function(){return d};i.getOptions=function(){return h};return i}(Candy.View||{},jQuery));Candy.Util=(function(a,b){a.jidToId=function(c){return MD5.hexdigest(c)};a.escapeJid=function(c){var d=Strophe.escapeNode(Strophe.getNodeFromJid(c)),f=Strophe.getDomainFromJid(c),e=Strophe.getResourceFromJid(c);c=d+"@"+f;if(e){c+="/"+Strophe.escapeNode(e)}return c};a.unescapeJid=function(c){var d=Strophe.unescapeNode(Strophe.getNodeFromJid(c)),f=Strophe.getDomainFromJid(c),e=Strophe.getResourceFromJid(c);c=d+"@"+f;if(e){c+="/"+Strophe.unescapeNode(e)}return c};a.crop=function(d,c){if(d.length>c){d=d.substr(0,c-3)+"..."}return d};a.setCookie=function(c,e,d){var f=new Date();f.setDate(new Date().getDate()+d);document.cookie=c+"="+e+";expires="+f.toUTCString()+";path=/"};a.cookieExists=function(c){return document.cookie.indexOf(c)>-1};a.getCookie=function(c){if(document.cookie){var d=new RegExp(escape(c)+"=([^;]*)","gm"),e=d.exec(document.cookie);if(e){return e[1]}}};a.deleteCookie=function(c){document.cookie=c+"=;expires=Thu, 01-Jan-70 00:00:01 GMT;path=/"};a.getPosLeftAccordingToWindowBounds=function(e,h){var d=b(document).width(),c=e.outerWidth(),f=c-e.outerWidth(true),g="left";if(h+c>=d){h-=c-f;g="right"}return{px:h,backgroundPositionAlignment:g}};a.getPosTopAccordingToWindowBounds=function(d,h){var g=b(document).height(),c=d.outerHeight(),e=c-d.outerHeight(true),f="top";if(h+c>=g){h-=c-e;f="bottom"}return{px:h,backgroundPositionAlignment:f}};a.localizedTime=function(d){if(d===undefined){return undefined}var c=a.iso8601toDate(d);if(c.toDateString()===new Date().toDateString()){return c.format(b.i18n._("timeFormat"))}else{return c.format(b.i18n._("dateFormat"))}};a.iso8601toDate=function(c){var e=Date.parse(c),d=0;if(isNaN(e)){var f=/^(\d{4}|[+\-]\d{6})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{3,}))?)?(?:(Z)|([+\-])(\d{2})(?::?(\d{2}))?))?/.exec(c);if(f){if(f[8]!=="Z"){d=+f[10]*60+(+f[11]);if(f[9]==="+"){d=-d}}return new Date(+f[1],+f[2]-1,+f[3],+f[4],+f[5]+d,+f[6],f[7]?+f[7].substr(0,3):0)}else{e=Date.parse(c.replace(/^(\d{4})(\d{2})(\d{2})/,"$1-$2-$3")+"Z")}}return new Date(e)};a.isEmptyObject=function(c){var d;for(d in c){if(c.hasOwnProperty(d)){return false}}return true};a.forceRedraw=function(c){c.css({display:"none"});setTimeout(function(){this.css({display:"block"})}.bind(c),1)};a.Parser={_emoticonPath:"",setEmoticonPath:function(c){this._emoticonPath=c},emoticons:[{plain:":)",regex:/((\s):-?\)|:-?\)(\s|$))/gm,image:"Smiling.png"},{plain:";)",regex:/((\s);-?\)|;-?\)(\s|$))/gm,image:"Winking.png"},{plain:":D",regex:/((\s):-?D|:-?D(\s|$))/gm,image:"Grinning.png"},{plain:";D",regex:/((\s);-?D|;-?D(\s|$))/gm,image:"Grinning_Winking.png"},{plain:":(",regex:/((\s):-?\(|:-?\((\s|$))/gm,image:"Unhappy.png"},{plain:"^^",regex:/((\s)\^\^|\^\^(\s|$))/gm,image:"Happy_3.png"},{plain:":P",regex:/((\s):-?P|:-?P(\s|$))/igm,image:"Tongue_Out.png"},{plain:";P",regex:/((\s);-?P|;-?P(\s|$))/igm,image:"Tongue_Out_Winking.png"},{plain:":S",regex:/((\s):-?S|:-?S(\s|$))/igm,image:"Confused.png"},{plain:":/",regex:/((\s):-?\/|:-?\/(\s|$))/gm,image:"Uncertain.png"},{plain:"8)",regex:/((\s)8-?\)|8-?\)(\s|$))/gm,image:"Sunglasses.png"},{plain:"$)",regex:/((\s)\$-?\)|\$-?\)(\s|$))/gm,image:"Greedy.png"},{plain:"oO",regex:/((\s)oO|oO(\s|$))/gm,image:"Huh.png"},{plain:":x",regex:/((\s):x|:x(\s|$))/gm,image:"Lips_Sealed.png"},{plain:":666:",regex:/((\s):666:|:666:(\s|$))/gm,image:"Devil.png"},{plain:"<3",regex:/((\s)<3|<3(\s|$))/gm,image:"Heart.png"}],emotify:function(d){var c;for(c=this.emoticons.length-1;c>=0;c--){d=d.replace(this.emoticons[c].regex,'$2$1$3')}return d},linkify:function(c){c=c.replace(/(^|[^\/])(www\.[^\.]+\.[\S]+(\b|$))/gi,"$1http://$2");return c.replace(/(\b(https?|ftp|file):\/\/[\-A-Z0-9+&@#\/%?=~_|!:,.;]*[\-A-Z0-9+&@#\/%=~_|])/ig,'$1')},escape:function(c){return b("

").text(c).html()},all:function(c){if(c){c=this.escape(c);c=this.linkify(c);c=this.emotify(c)}return c}};return a}(Candy.Util||{},jQuery));Candy.Util.Observable=(function(a){var b={};a.addObserver=function(c,d){if(b[c]===undefined){b[c]=[]}b[c].push(d)};a.deleteObserver=function(c,d){delete b[c][d]};a.clearObservers=function(c){if(c!==undefined){b[c]=[]}else{b={}}};a.notifyObservers=function(f,c){var d=b[f],e;for(e=d.length-1;e>=0;e--){d[e].update(a,c)}};return a}(Candy.Util.Observable||{}));Candy.Core.Action=(function(a,c,b){a.Jabber={Version:function(d){Candy.Core.getConnection().send($iq({type:"result",to:d.attr("from"),from:d.attr("to"),id:d.attr("id")}).c("query",{name:Candy.about.name,version:Candy.about.version,os:navigator.userAgent}))},Roster:function(){Candy.Core.getConnection().send($iq({type:"get",xmlns:c.NS.CLIENT}).c("query",{xmlns:c.NS.ROSTER}).tree())},Presence:function(d){Candy.Core.getConnection().send($pres(d).tree())},Services:function(){Candy.Core.getConnection().send($iq({type:"get",xmlns:c.NS.CLIENT}).c("query",{xmlns:c.NS.DISCO_ITEMS}).tree())},Autojoin:function(){if(Candy.Core.getOptions().autojoin===true){Candy.Core.getConnection().send($iq({type:"get",xmlns:c.NS.CLIENT}).c("query",{xmlns:c.NS.PRIVATE}).c("storage",{xmlns:c.NS.BOOKMARKS}).tree())}else{if(b.isArray(Candy.Core.getOptions().autojoin)){b.each(Candy.Core.getOptions().autojoin,function(){a.Jabber.Room.Join(this.valueOf())})}}},ResetIgnoreList:function(){Candy.Core.getConnection().send($iq({type:"set",from:Candy.Core.getUser().getJid(),id:"set1"}).c("query",{xmlns:c.NS.PRIVACY}).c("list",{name:"ignore"}).c("item",{action:"allow",order:"0"}).tree())},RemoveIgnoreList:function(){Candy.Core.getConnection().send($iq({type:"set",from:Candy.Core.getUser().getJid(),id:"remove1"}).c("query",{xmlns:c.NS.PRIVACY}).c("list",{name:"ignore"}).tree())},GetIgnoreList:function(){Candy.Core.getConnection().send($iq({type:"get",from:Candy.Core.getUser().getJid(),id:"get1"}).c("query",{xmlns:c.NS.PRIVACY}).c("list",{name:"ignore"}).tree())},SetIgnoreListActive:function(){Candy.Core.getConnection().send($iq({type:"set",from:Candy.Core.getUser().getJid(),id:"set2"}).c("query",{xmlns:c.NS.PRIVACY}).c("active",{name:"ignore"}).tree())},GetJidIfAnonymous:function(){if(!Candy.Core.getUser().getJid()){Candy.Core.log("[Jabber] Anonymous login");Candy.Core.getUser().data.jid=Candy.Core.getConnection().jid}},Room:{Join:function(d,e){a.Jabber.Room.Disco(d);Candy.Core.getConnection().muc.join(d,Candy.Core.getUser().getNick(),null,null,e)},Leave:function(d){Candy.Core.getConnection().muc.leave(d,Candy.Core.getRoom(d).getUser().getNick(),function(){})},Disco:function(d){Candy.Core.getConnection().send($iq({type:"get",from:Candy.Core.getUser().getJid(),to:d,id:"disco3"}).c("query",{xmlns:c.NS.DISCO_INFO}).tree())},Message:function(d,f,e){f=b.trim(f);if(f===""){return false}Candy.Core.getConnection().muc.message(Candy.Util.escapeJid(d),undefined,f,e);return true},IgnoreUnignore:function(d){Candy.Core.getUser().addToOrRemoveFromPrivacyList("ignore",d);Candy.Core.Action.Jabber.Room.UpdatePrivacyList()},UpdatePrivacyList:function(){var d=Candy.Core.getUser(),f=$iq({type:"set",from:d.getJid(),id:"edit1"}).c("query",{xmlns:"jabber:iq:privacy"}).c("list",{name:"ignore"}),e=d.getPrivacyList("ignore");if(e.length>0){b.each(e,function(g,h){f.c("item",{type:"jid",value:Candy.Util.escapeJid(h),action:"deny",order:g}).c("message").up().up()})}else{f.c("item",{action:"allow",order:"0"})}Candy.Core.getConnection().send(f.tree())},Admin:{UserAction:function(d,i,g,h){var f,e={nick:c.escapeNode(c.getResourceFromJid(i))};switch(g){case"kick":f="kick1";e.role="none";break;case"ban":f="ban1";e.affiliation="outcast";break;default:return false}Candy.Core.getConnection().send($iq({type:"set",from:Candy.Core.getUser().getJid(),to:d,id:f}).c("query",{xmlns:c.NS.MUC_ADMIN}).c("item",e).c("reason").t(h).tree());return true},SetSubject:function(d,e){Candy.Core.getConnection().muc.setTopic(d,e)}}}};return a}(Candy.Core.Action||{},Strophe,jQuery));Candy.Core.ChatRoom=function(a){this.room={jid:a,name:null};this.user=null;this.roster=new Candy.Core.ChatRoster();this.setUser=function(b){this.user=b};this.getUser=function(){return this.user};this.getJid=function(){return this.room.jid};this.setName=function(b){this.room.name=b};this.getName=function(){return this.room.name};this.setRoster=function(b){this.roster=b};this.getRoster=function(){return this.roster}};Candy.Core.ChatRoster=function(){this.items={};this.add=function(a){this.items[a.getJid()]=a};this.remove=function(a){delete this.items[a]};this.get=function(a){return this.items[a]};this.getAll=function(){return this.items}};Candy.Core.ChatUser=function(b,a,c,d){this.ROLE_MODERATOR="moderator";this.AFFILIATION_OWNER="owner";this.data={jid:b,nick:Strophe.unescapeNode(a),affiliation:c,role:d,privacyLists:{},customData:{}};this.getJid=function(){if(this.data.jid){return Candy.Util.unescapeJid(this.data.jid)}return};this.getEscapedJid=function(){return Candy.Util.escapeJid(this.data.jid)};this.getNick=function(){return Strophe.unescapeNode(this.data.nick)};this.getRole=function(){return this.data.role};this.getAffiliation=function(){return this.data.affiliation};this.isModerator=function(){return this.getRole()===this.ROLE_MODERATOR||this.getAffiliation()===this.AFFILIATION_OWNER};this.addToOrRemoveFromPrivacyList=function(g,f){if(!this.data.privacyLists[g]){this.data.privacyLists[g]=[]}var e=-1;if((e=this.data.privacyLists[g].indexOf(f))!==-1){this.data.privacyLists[g].splice(e,1)}else{this.data.privacyLists[g].push(f)}return this.data.privacyLists[g]};this.getPrivacyList=function(e){if(!this.data.privacyLists[e]){this.data.privacyLists[e]=[]}return this.data.privacyLists[e]};this.isInPrivacyList=function(f,e){if(!this.data.privacyLists[f]){return false}return this.data.privacyLists[f].indexOf(e)!==-1};this.setCustomData=function(e){this.data.customData=e};this.getCustomData=function(){return this.data.customData}};Candy.Core.Event=(function(a,e,c,d){var b;for(b in d){if(d.hasOwnProperty(b)){a[b]=d[b]}}a.KEYS={CHAT:1,PRESENCE:2,MESSAGE:3,LOGIN:4,PRESENCE_ERROR:5};a.Strophe={Connect:function(f){switch(f){case e.Status.CONNECTED:Candy.Core.log("[Connection] Connected");Candy.Core.Action.Jabber.GetJidIfAnonymous();case e.Status.ATTACHED:Candy.Core.log("[Connection] Attached");Candy.Core.Action.Jabber.Presence();Candy.Core.Action.Jabber.Autojoin();Candy.Core.Action.Jabber.GetIgnoreList();break;case e.Status.DISCONNECTED:Candy.Core.log("[Connection] Disconnected");break;case e.Status.AUTHFAIL:Candy.Core.log("[Connection] Authentication failed");break;case e.Status.CONNECTING:Candy.Core.log("[Connection] Connecting");break;case e.Status.DISCONNECTING:Candy.Core.log("[Connection] Disconnecting");break;case e.Status.AUTHENTICATING:Candy.Core.log("[Connection] Authenticating");break;case e.Status.ERROR:case e.Status.CONNFAIL:Candy.Core.log("[Connection] Failed ("+f+")");break;default:Candy.Core.log("[Connection] What?!");break}a.notifyObservers(a.KEYS.CHAT,{type:"connection",status:f})}};a.Login=function(f){a.notifyObservers(a.KEYS.LOGIN,{presetJid:f})};a.Jabber={Version:function(f){Candy.Core.log("[Jabber] Version");Candy.Core.Action.Jabber.Version(c(f));return true},Presence:function(f){Candy.Core.log("[Jabber] Presence");f=c(f);if(f.children('x[xmlns^="'+e.NS.MUC+'"]').length>0){if(f.attr("type")==="error"){a.Jabber.Room.PresenceError(f)}else{a.Jabber.Room.Presence(f)}}return true},Bookmarks:function(f){Candy.Core.log("[Jabber] Bookmarks");c("conference",f).each(function(){var g=c(this);if(g.attr("autojoin")){Candy.Core.Action.Jabber.Room.Join(g.attr("jid"))}});return true},PrivacyList:function(g){Candy.Core.log("[Jabber] PrivacyList");var f=Candy.Core.getUser();c('list[name="ignore"] item',g).each(function(){var h=c(this);if(h.attr("action")==="deny"){f.addToOrRemoveFromPrivacyList("ignore",h.attr("value"))}});Candy.Core.Action.Jabber.SetIgnoreListActive();return false},PrivacyListError:function(f){Candy.Core.log("[Jabber] PrivacyListError");if(c('error[code="404"][type="cancel"] item-not-found',f)){Candy.Core.Action.Jabber.ResetIgnoreList();Candy.Core.Action.Jabber.SetIgnoreListActive()}return false},Message:function(i){Candy.Core.log("[Jabber] Message");var i=c(i),h=i.attr("from"),g=i.attr("type"),f=i.attr("to");if(h!==e.getDomainFromJid(h)&&(g==="groupchat"||g==="chat"||g==="error")){a.Jabber.Room.Message(i)}else{if(!f&&h===e.getDomainFromJid(h)){a.notifyObservers(a.KEYS.CHAT,{type:(g||"message"),message:i.children("body").text()})}else{if(f&&h===e.getDomainFromJid(h)){a.notifyObservers(a.KEYS.CHAT,{type:(g||"message"),subject:i.children("subject").text(),message:i.children("body").text()})}}}return true},Room:{Leave:function(f){Candy.Core.log("[Jabber:Room] Leave");var f=c(f),l=f.attr("from"),n=e.getBareJidFromJid(l);if(!Candy.Core.getRoom(n)){return false}var j=Candy.Core.getRoom(n).getName(),m=f.find("item"),k="leave",i,h;delete Candy.Core.getRooms()[n];if(m.attr("role")==="none"){if(f.find("status").attr("code")==="307"){k="kick"}else{if(f.find("status").attr("code")==="301"){k="ban"}}i=m.find("reason").text();h=m.find("actor").attr("jid")}var g=new Candy.Core.ChatUser(l,e.getResourceFromJid(l),m.attr("affiliation"),m.attr("role"));a.notifyObservers(a.KEYS.PRESENCE,{roomJid:n,roomName:j,type:k,reason:i,actor:h,user:g});return true},Disco:function(i){Candy.Core.log("[Jabber:Room] Disco");var i=c(i),g=e.getBareJidFromJid(i.attr("from"));if(!Candy.Core.getRooms()[g]){Candy.Core.getRooms()[g]=new Candy.Core.ChatRoom(g)}var f=i.find("identity").attr("name"),h=Candy.Core.getRoom(g);if(h.getName()===null){h.setName(f)}return true},Presence:function(h){Candy.Core.log("[Jabber:Room] Presence");var l=Candy.Util.unescapeJid(h.attr("from")),o=e.getBareJidFromJid(l),m=h.attr("type");if(e.getResourceFromJid(l)===Candy.Core.getUser().getNick()&&m==="unavailable"){a.Jabber.Room.Leave(h);return true}var g=Candy.Core.getRoom(o);if(!g){Candy.Core.getRooms()[o]=new Candy.Core.ChatRoom(o);g=Candy.Core.getRoom(o)}var k=g.getRoster(),i,j,n=h.find("item");if(m!=="unavailable"){var f=e.getResourceFromJid(l);j=new Candy.Core.ChatUser(l,f,n.attr("affiliation"),n.attr("role"));if(g.getUser()===null&&Candy.Core.getUser().getNick()===f){g.setUser(j)}k.add(j);i="join"}else{i="leave";if(n.attr("role")==="none"){if(h.find("status").attr("code")==="307"){i="kick"}else{if(h.find("status").attr("code")==="301"){i="ban"}}}j=k.get(l);k.remove(l)}a.notifyObservers(a.KEYS.PRESENCE,{roomJid:o,roomName:g.getName(),user:j,action:i,currentUser:Candy.Core.getUser()});return true},PresenceError:function(i){Candy.Core.log("[Jabber:Room] Presence Error");var j=Candy.Util.unescapeJid(i.attr("from")),g=e.getBareJidFromJid(j),h=Candy.Core.getRooms()[g],f=h.getName();delete h;a.notifyObservers(a.KEYS.PRESENCE_ERROR,{msg:i,type:i.children("error").children()[0].tagName.toLowerCase(),roomJid:g,roomName:f})},Message:function(h){Candy.Core.log("[Jabber:Room] Message");var o,n;if(h.children("subject").length>0){o=Candy.Util.unescapeJid(e.getBareJidFromJid(h.attr("from")));n={name:e.getNodeFromJid(o),body:h.children("subject").text(),type:"subject"}}else{if(h.attr("type")==="error"){var m=h.children("error");if(m.attr("code")==="500"&&m.children("text").length>0){o=h.attr("from");n={type:"info",body:m.children("text").text()}}}else{if(h.children("body").length>0){if(h.attr("type")==="chat"){o=Candy.Util.unescapeJid(h.attr("from"));var f=e.getBareJidFromJid(o),i=!Candy.Core.getRoom(f),g=i?e.getNodeFromJid(o):e.getResourceFromJid(o);n={name:g,body:h.children("body").text(),type:h.attr("type"),isNoConferenceRoomJid:i}}else{o=Candy.Util.unescapeJid(e.getBareJidFromJid(h.attr("from")));var j=e.getResourceFromJid(h.attr("from"));if(j){j=e.unescapeNode(j);n={name:j,body:h.children("body").text(),type:h.attr("type")}}else{n={name:"",body:h.children("body").text(),type:"info"}}}}else{return true}}}var k=h.children("delay")?h.children("delay"):h.children('x[xmlns="'+e.NS.DELAY+'"]'),l=k!==undefined?k.attr("stamp"):null;a.notifyObservers(a.KEYS.MESSAGE,{roomJid:o,message:n,timestamp:l});return true}}};return a}(Candy.Core.Event||{},Strophe,jQuery,Candy.Util.Observable));Candy.View.Event=(function(a,b){a.Chat={onAdminMessage:function(c){return},onDisconnect:function(){return},onAuthfail:function(){return}};a.Room={onAdd:function(c){return},onShow:function(c){return},onHide:function(c){return},onSubjectChange:function(c){return},onClose:function(c){return},onPresenceChange:function(c){return}};a.Roster={onUpdate:function(c){return},onContextMenu:function(c){return{}},afterContextMenu:function(c){return}};a.Message={beforeShow:function(c){return c.message},onShow:function(c){return},beforeSend:function(c){return c}};return a}(Candy.View.Event||{},jQuery));Candy.View.Observer=(function(a,b){a.Chat={update:function(e,d){if(d.type==="connection"){switch(d.status){case Strophe.Status.CONNECTING:case Strophe.Status.AUTHENTICATING:Candy.View.Pane.Chat.Modal.show(b.i18n._("statusConnecting"),false,true);break;case Strophe.Status.ATTACHED:case Strophe.Status.CONNECTED:Candy.View.Pane.Chat.Modal.show(b.i18n._("statusConnected"));Candy.View.Pane.Chat.Modal.hide();break;case Strophe.Status.DISCONNECTING:Candy.View.Pane.Chat.Modal.show(b.i18n._("statusDisconnecting"),false,true);break;case Strophe.Status.DISCONNECTED:var c=Candy.Core.isAnonymousConnection()?Strophe.getDomainFromJid(Candy.Core.getUser().getJid()):null;Candy.View.Pane.Chat.Modal.showLoginForm(b.i18n._("statusDisconnected"),c);Candy.View.Event.Chat.onDisconnect();break;case Strophe.Status.AUTHFAIL:Candy.View.Pane.Chat.Modal.showLoginForm(b.i18n._("statusAuthfail"));Candy.View.Event.Chat.onAuthfail();break;default:Candy.View.Pane.Chat.Modal.show(b.i18n._("status",d.status));break}}else{if(d.type==="message"){Candy.View.Pane.Chat.adminMessage((d.subject||""),d.message)}else{if(d.type==="chat"||d.type==="groupchat"){Candy.View.Pane.Chat.onInfoMessage(Candy.View.getCurrent().roomJid,(d.subject||""),d.message)}}}}};a.Presence={update:function(h,e){if(e.type==="leave"){var c=Candy.View.Pane.Room.getUser(e.roomJid);Candy.View.Pane.Room.close(e.roomJid);a.Presence.notifyPrivateChats(c,e.type)}else{if(e.type==="kick"||e.type==="ban"){var g=e.actor?Strophe.getNodeFromJid(e.actor):null,f,d=[e.roomName];if(g){d.push(g)}switch(e.type){case"kick":f=b.i18n._((g?"youHaveBeenKickedBy":"youHaveBeenKicked"),d);break;case"ban":f=b.i18n._((g?"youHaveBeenBannedBy":"youHaveBeenBanned"),d);break}Candy.View.Pane.Chat.Modal.show(Mustache.to_html(Candy.View.Template.Chat.Context.adminMessageReason,{reason:e.reason,_action:f,_reason:b.i18n._("reasonWas",[e.reason])}));setTimeout(function(){Candy.View.Pane.Chat.Modal.hide(function(){Candy.View.Pane.Room.close(e.roomJid);a.Presence.notifyPrivateChats(e.user,e.type)})},5000);Candy.View.Event.Room.onPresenceChange({type:e.type,reason:e.reason,roomJid:e.roomJid,user:e.user})}else{if(!Candy.View.Pane.Chat.rooms[e.roomJid]){Candy.View.Pane.Room.init(e.roomJid,e.roomName);Candy.View.Pane.Room.show(e.roomJid)}Candy.View.Pane.Roster.update(e.roomJid,e.user,e.action,e.currentUser);if(Candy.View.Pane.Chat.rooms[e.user.getJid()]){Candy.View.Pane.Roster.update(e.user.getJid(),e.user,e.action,e.currentUser);Candy.View.Pane.PrivateRoom.setStatus(e.user.getJid(),e.action)}}}},notifyPrivateChats:function(d,e){Candy.Core.log("[View:Observer] notify Private Chats");var c;for(c in Candy.View.Pane.Chat.rooms){if(Candy.View.Pane.Chat.rooms.hasOwnProperty(c)&&Candy.View.Pane.Room.getUser(c)&&d.getJid()===Candy.View.Pane.Room.getUser(c).getJid()){Candy.View.Pane.Roster.update(c,d,e,d);Candy.View.Pane.PrivateRoom.setStatus(c,e)}}}};a.PresenceError={update:function(e,c){switch(c.type){case"not-authorized":var d;if(c.msg.children("x").children("password").length>0){d=b.i18n._("passwordEnteredInvalid",[c.roomName])}Candy.View.Pane.Chat.Modal.showEnterPasswordForm(c.roomJid,c.roomName,d);break;case"conflict":Candy.View.Pane.Chat.Modal.showNicknameConflictForm(c.roomJid);break;case"registration-required":Candy.View.Pane.Chat.Modal.showError("errorMembersOnly",[c.roomName]);break;case"service-unavailable":Candy.View.Pane.Chat.Modal.showError("errorMaxOccupantsReached",[c.roomName]);break}}};a.Message={update:function(d,c){if(c.message.type==="subject"){if(!Candy.View.Pane.Chat.rooms[c.roomJid]){Candy.View.Pane.Room.init(c.roomJid,c.message.name);Candy.View.Pane.Room.show(c.roomJid)}Candy.View.Pane.Room.setSubject(c.roomJid,c.message.body)}else{if(c.message.type==="info"){Candy.View.Pane.Chat.infoMessage(c.roomJid,c.message.body)}else{if(c.message.type==="chat"&&!Candy.View.Pane.Chat.rooms[c.roomJid]){Candy.View.Pane.PrivateRoom.open(c.roomJid,c.message.name,false,c.message.isNoConferenceRoomJid)}Candy.View.Pane.Message.show(c.roomJid,c.message.name,c.message.body,c.timestamp)}}}};a.Login={update:function(d,c){Candy.View.Pane.Chat.Modal.showLoginForm(null,c.presetJid)}};return a}(Candy.View.Observer||{},jQuery));Candy.View.Pane=(function(a,b){a.Window={_hasFocus:true,_plainTitle:document.title,_unreadMessagesCount:0,autoscroll:true,hasFocus:function(){return a.Window._hasFocus},increaseUnreadMessages:function(){a.Window.renderUnreadMessages(++a.Window._unreadMessagesCount)},reduceUnreadMessages:function(c){a.Window._unreadMessagesCount-=c;if(a.Window._unreadMessagesCount<=0){a.Window.clearUnreadMessages()}else{a.Window.renderUnreadMessages(a.Window._unreadMessagesCount)}},clearUnreadMessages:function(){a.Window._unreadMessagesCount=0;document.title=a.Window._plainTitle},renderUnreadMessages:function(c){document.title=Candy.View.Template.Window.unreadmessages.replace("{{count}}",c).replace("{{title}}",a.Window._plainTitle)},onFocus:function(){a.Window._hasFocus=true;if(Candy.View.getCurrent().roomJid){a.Room.setFocusToForm(Candy.View.getCurrent().roomJid);a.Chat.clearUnreadMessages(Candy.View.getCurrent().roomJid)}},onBlur:function(){a.Window._hasFocus=false}};a.Chat={rooms:[],addTab:function(d,c,e){var h=Candy.Util.jidToId(d),f=Mustache.to_html(Candy.View.Template.Chat.tab,{roomJid:d,roomId:h,name:c||Strophe.getNodeFromJid(d),privateUserChat:function(){return e==="chat"},roomType:e}),g=b(f).appendTo("#chat-tabs");g.click(a.Chat.tabClick);b("a.close",g).click(a.Chat.tabClose);a.Chat.fitTabs()},getTab:function(c){return b("#chat-tabs").children('li[data-roomjid="'+c+'"]')},removeTab:function(c){a.Chat.getTab(c).remove();a.Chat.fitTabs()},setActiveTab:function(c){b("#chat-tabs").children().each(function(){var d=b(this);if(d.attr("data-roomjid")===c){d.addClass("active")}else{d.removeClass("active")}})},increaseUnreadMessages:function(d){var c=this.getTab(d).find(".unread");c.show().text(c.text()!==""?parseInt(c.text(),10)+1:1);if(a.Chat.rooms[d].type==="chat"){a.Window.increaseUnreadMessages()}},clearUnreadMessages:function(d){var c=a.Chat.getTab(d).find(".unread");a.Window.reduceUnreadMessages(c.text());c.hide().text("")},tabClick:function(d){var c=Candy.View.getCurrent().roomJid;a.Chat.rooms[c].scrollPosition=a.Room.getPane(c,".message-pane-wrapper").scrollTop();a.Room.show(b(this).attr("data-roomjid"));d.preventDefault()},tabClose:function(d){var c=b(this).parent().attr("data-roomjid");if(a.Chat.rooms[c].type==="chat"){a.Room.close(c)}else{Candy.Core.Action.Jabber.Room.Leave(c)}return false},allTabsClosed:function(){Candy.Core.disconnect();a.Chat.Toolbar.hide();return},fitTabs:function(){var g=b("#chat-tabs").innerWidth(),f=0,e=b("#chat-tabs").children();e.each(function(){f+=b(this).css({width:"auto",overflow:"visible"}).outerWidth(true)});if(f>g){var c=e.outerWidth(true)-e.width(),d=Math.floor((g)/e.length)-c;e.css({width:d,overflow:"hidden"})}},updateToolbar:function(c){b("#chat-toolbar").find(".context").click(function(d){a.Chat.Context.show(d.currentTarget,c);d.stopPropagation()});Candy.View.Pane.Chat.Toolbar.updateUsercount(Candy.View.Pane.Chat.rooms[c].usercount)},adminMessage:function(d,e){if(Candy.View.getCurrent().roomJid){var c=Mustache.to_html(Candy.View.Template.Chat.adminMessage,{subject:d,message:e,sender:b.i18n._("administratorMessageSubject"),time:Candy.Util.localizedTime(new Date().toGMTString())});b("#chat-rooms").children().each(function(){a.Room.appendToMessagePane(b(this).attr("data-roomjid"),c)});a.Room.scrollToBottom(Candy.View.getCurrent().roomJid);Candy.View.Event.Chat.onAdminMessage({subject:d,message:e})}},infoMessage:function(c,d,e){a.Chat.onInfoMessage(c,d,e)},onInfoMessage:function(c,e,f){if(Candy.View.getCurrent().roomJid){var d=Mustache.to_html(Candy.View.Template.Chat.infoMessage,{subject:e,message:b.i18n._(f),time:Candy.Util.localizedTime(new Date().toGMTString())});a.Room.appendToMessagePane(c,d);if(Candy.View.getCurrent().roomJid===c){a.Room.scrollToBottom(Candy.View.getCurrent().roomJid)}}},Toolbar:{show:function(){b("#chat-toolbar").show()},hide:function(){b("#chat-toolbar").hide()},playSound:function(){a.Chat.Toolbar.onPlaySound()},onPlaySound:function(){var c=document.getElementById("chat-sound-player");c.SetVariable("method:stop","");c.SetVariable("method:play","")},onSoundControlClick:function(){var c=b("#chat-sound-control");if(c.hasClass("checked")){a.Chat.Toolbar.playSound=function(){};Candy.Util.setCookie("candy-nosound","1",365)}else{a.Chat.Toolbar.playSound=function(){a.Chat.Toolbar.onPlaySound()};Candy.Util.deleteCookie("candy-nosound")}c.toggleClass("checked")},onAutoscrollControlClick:function(){var c=b("#chat-autoscroll-control");if(c.hasClass("checked")){a.Room.scrollToBottom=function(d){a.Room.onScrollToStoredPosition(d)};a.Window.autoscroll=false}else{a.Room.scrollToBottom=function(d){a.Room.onScrollToBottom(d)};a.Room.scrollToBottom(Candy.View.getCurrent().roomJid);a.Window.autoscroll=true}c.toggleClass("checked")},onStatusMessageControlClick:function(){var c=b("#chat-statusmessage-control");if(c.hasClass("checked")){a.Chat.infoMessage=function(){};Candy.Util.setCookie("candy-nostatusmessages","1",365)}else{a.Chat.infoMessage=function(d,e,f){a.Chat.onInfoMessage(d,e,f)};Candy.Util.deleteCookie("candy-nostatusmessages")}c.toggleClass("checked")},updateUsercount:function(c){b("#chat-usercount").text(c)}},Modal:{show:function(d,e,c){if(e){a.Chat.Modal.showCloseControl()}else{a.Chat.Modal.hideCloseControl()}if(c){a.Chat.Modal.showSpinner()}else{a.Chat.Modal.hideSpinner()}b("#chat-modal").stop(false,true);b("#chat-modal-body").html(d);b("#chat-modal").fadeIn("fast");b("#chat-modal-overlay").show()},hide:function(c){b("#chat-modal").fadeOut("fast",function(){b("#chat-modal-body").text("");b("#chat-modal-overlay").hide()});b(document).keydown(function(d){if(d.which===27){d.preventDefault()}});if(c){c()}},showSpinner:function(){b("#chat-modal-spinner").show()},hideSpinner:function(){b("#chat-modal-spinner").hide()},showCloseControl:function(){b("#admin-message-cancel").show().click(function(c){a.Chat.Modal.hide();c.preventDefault()});b(document).keydown(function(c){if(c.which===27){a.Chat.Modal.hide();c.preventDefault()}})},hideCloseControl:function(){b("#admin-message-cancel").hide().click(function(){})},showLoginForm:function(d,c){a.Chat.Modal.show((d?d:"")+Mustache.to_html(Candy.View.Template.Login.form,{_labelUsername:b.i18n._("labelUsername"),_labelPassword:b.i18n._("labelPassword"),_loginSubmit:b.i18n._("loginSubmit"),displayPassword:!Candy.Core.isAnonymousConnection(),displayUsername:Candy.Core.isAnonymousConnection()||!c,presetJid:c?c:false}));b("#login-form").children()[0].focus();b("#login-form").submit(function(g){var h=b("#username").val(),e=b("#password").val();if(!Candy.Core.isAnonymousConnection()){var f=Candy.Core.getUser()&&h.indexOf("@")<0?h+"@"+Strophe.getDomainFromJid(Candy.Core.getUser().getJid()):h;if(f.indexOf("@")<0&&!Candy.Core.getUser()){Candy.View.Pane.Chat.Modal.showLoginForm(b.i18n._("loginInvalid"))}else{Candy.Core.connect(f,e)}}else{Candy.Core.connect(c,null,h)}return false})},showEnterPasswordForm:function(d,c,e){a.Chat.Modal.show(Mustache.to_html(Candy.View.Template.PresenceError.enterPasswordForm,{roomName:c,_labelPassword:b.i18n._("labelPassword"),_label:(e?e:b.i18n._("enterRoomPassword",[c])),_joinSubmit:b.i18n._("enterRoomPasswordSubmit")}),true);b("#password").focus();b("#enter-password-form").submit(function(){var f=b("#password").val();a.Chat.Modal.hide(function(){Candy.Core.Action.Jabber.Room.Join(d,f)});return false})},showNicknameConflictForm:function(c){a.Chat.Modal.show(Mustache.to_html(Candy.View.Template.PresenceError.nicknameConflictForm,{_labelNickname:b.i18n._("labelUsername"),_label:b.i18n._("nicknameConflict"),_loginSubmit:b.i18n._("loginSubmit")}));b("#nickname").focus();b("#nickname-conflict-form").submit(function(){var d=b("#nickname").val();a.Chat.Modal.hide(function(){Candy.Core.getUser().data.nick=d;Candy.Core.Action.Jabber.Room.Join(c)});return false})},showError:function(d,c){a.Chat.Modal.show(Mustache.to_html(Candy.View.Template.PresenceError.displayError,{_error:b.i18n._(d,c)}),true)}},Tooltip:{show:function(g,f){var h=b("#tooltip"),i=b(g.currentTarget);if(!f){f=i.attr("data-tooltip")}if(h.length===0){var d=Mustache.to_html(Candy.View.Template.Chat.tooltip);b("#chat-pane").append(d);h=b("#tooltip")}b("#context-menu").hide();h.stop(false,true);h.children("div").html(f);var j=i.offset(),c=Candy.Util.getPosLeftAccordingToWindowBounds(h,j.left),e=Candy.Util.getPosTopAccordingToWindowBounds(h,j.top);h.css({left:c.px,top:e.px,backgroundPosition:c.backgroundPositionAlignment+" "+e.backgroundPositionAlignment}).fadeIn("fast");i.mouseleave(function(k){k.stopPropagation();b("#tooltip").stop(false,true).fadeOut("fast",function(){b(this).css({top:0,left:0})})})}},Context:{init:function(){if(b("#context-menu").length===0){var c=Mustache.to_html(Candy.View.Template.Chat.Context.menu);b("#chat-pane").append(c);b("#context-menu").mouseleave(function(){b(this).fadeOut("fast")})}},show:function(e,p,h){e=b(e);var f=a.Chat.rooms[p].id,d=b("#context-menu"),o=b("ul li",d);b("#tooltip").hide();if(!h){h=Candy.Core.getUser()}o.remove();var k=this.getMenuLinks(p,h,e),c,l=function(r,q){return function(s){s.data.callback(s,r,q);b("#context-menu").hide()}};for(c in k){if(k.hasOwnProperty(c)){var n=k[c],j=Mustache.to_html(Candy.View.Template.Chat.Context.menulinks,{roomId:f,"class":n["class"],id:c,label:n.label});b("ul",d).append(j);b("#context-menu-"+c).bind("click",n,l(p,h))}}if(c){var m=e.offset(),g=Candy.Util.getPosLeftAccordingToWindowBounds(d,m.left),i=Candy.Util.getPosTopAccordingToWindowBounds(d,m.top);d.css({left:g.px,top:i.px,backgroundPosition:g.backgroundPositionAlignment+" "+i.backgroundPositionAlignment});d.fadeIn("fast");Candy.View.Event.Roster.afterContextMenu({roomJid:p,user:h,element:d});return true}},getMenuLinks:function(d,c,e){var f=b.extend(this.initialMenuLinks(e),Candy.View.Event.Roster.onContextMenu({roomJid:d,user:c,elem:e})),g;for(g in f){if(f.hasOwnProperty(g)&&f[g].requiredPermission!==undefined&&!f[g].requiredPermission(c,a.Room.getUser(d),e)){delete f[g]}}return f},initialMenuLinks:function(){return{"private":{requiredPermission:function(c,d){return d.getNick()!==c.getNick()&&Candy.Core.getRoom(Candy.View.getCurrent().roomJid)&&!Candy.Core.getUser().isInPrivacyList("ignore",c.getJid())},"class":"private",label:b.i18n._("privateActionLabel"),callback:function(f,d,c){b("#user-"+Candy.Util.jidToId(d)+"-"+Candy.Util.jidToId(c.getJid())).click()}},ignore:{requiredPermission:function(c,d){return d.getNick()!==c.getNick()&&!Candy.Core.getUser().isInPrivacyList("ignore",c.getJid())},"class":"ignore",label:b.i18n._("ignoreActionLabel"),callback:function(f,d,c){Candy.View.Pane.Room.ignoreUser(d,c.getJid())}},unignore:{requiredPermission:function(c,d){return d.getNick()!==c.getNick()&&Candy.Core.getUser().isInPrivacyList("ignore",c.getJid())},"class":"unignore",label:b.i18n._("unignoreActionLabel"),callback:function(f,d,c){Candy.View.Pane.Room.unignoreUser(d,c.getJid())}},kick:{requiredPermission:function(c,d){return d.getNick()!==c.getNick()&&d.isModerator()&&!c.isModerator()},"class":"kick",label:b.i18n._("kickActionLabel"),callback:function(f,d,c){a.Chat.Modal.show(Mustache.to_html(Candy.View.Template.Chat.Context.contextModalForm,{_label:b.i18n._("reason"),_submit:b.i18n._("kickActionLabel")}),true);b("#context-modal-field").focus();b("#context-modal-form").submit(function(e){Candy.Core.Action.Jabber.Room.Admin.UserAction(d,c.getJid(),"kick",b("#context-modal-field").val());a.Chat.Modal.hide();return false})}},ban:{requiredPermission:function(c,d){return d.getNick()!==c.getNick()&&d.isModerator()&&!c.isModerator()},"class":"ban",label:b.i18n._("banActionLabel"),callback:function(f,d,c){a.Chat.Modal.show(Mustache.to_html(Candy.View.Template.Chat.Context.contextModalForm,{_label:b.i18n._("reason"),_submit:b.i18n._("banActionLabel")}),true);b("#context-modal-field").focus();b("#context-modal-form").submit(function(g){Candy.Core.Action.Jabber.Room.Admin.UserAction(d,c.getJid(),"ban",b("#context-modal-field").val());a.Chat.Modal.hide();return false})}},subject:{requiredPermission:function(c,d){return d.getNick()===c.getNick()&&d.isModerator()},"class":"subject",label:b.i18n._("setSubjectActionLabel"),callback:function(f,d,c){a.Chat.Modal.show(Mustache.to_html(Candy.View.Template.Chat.Context.contextModalForm,{_label:b.i18n._("subject"),_submit:b.i18n._("setSubjectActionLabel")}),true);b("#context-modal-field").focus();b("#context-modal-form").submit(function(g){Candy.Core.Action.Jabber.Room.Admin.SetSubject(d,b("#context-modal-field").val());a.Chat.Modal.hide();g.preventDefault()})}}}},showEmoticonsMenu:function(h){h=b(h);var k=h.offset(),j=b("#context-menu"),g=b("ul",j),e="",d;b("#tooltip").hide();for(d=Candy.Util.Parser.emoticons.length-1;d>=0;d--){e=''+Candy.Util.Parser.emoticons[d].plain+''+e}g.html('
  • '+e+"
  • ");g.find("img").click(function(){var i=Candy.View.Pane.Room.getPane(Candy.View.getCurrent().roomJid,".message-form").children(".field"),m=i.val(),l=b(this).attr("alt")+" ";i.val(m?m+" "+l:l).focus()});var c=Candy.Util.getPosLeftAccordingToWindowBounds(j,k.left),f=Candy.Util.getPosTopAccordingToWindowBounds(j,k.top);j.css({left:c.px,top:f.px,backgroundPosition:c.backgroundPositionAlignment+" "+f.backgroundPositionAlignment});j.fadeIn("fast");return true}}};a.Room={init:function(d,c,e){e=e||"groupchat";if(Candy.Util.isEmptyObject(a.Chat.rooms)){a.Chat.Toolbar.show()}var f=Candy.Util.jidToId(d);a.Chat.rooms[d]={id:f,usercount:0,name:c,type:e,messageCount:0,scrollPosition:-1};b("#chat-rooms").append(Mustache.to_html(Candy.View.Template.Room.pane,{roomId:f,roomJid:d,roomType:e,form:{_messageSubmit:b.i18n._("messageSubmit")},roster:{_userOnline:b.i18n._("userOnline")}},{roster:Candy.View.Template.Roster.pane,messages:Candy.View.Template.Message.pane,form:Candy.View.Template.Room.form}));a.Chat.addTab(d,c,e);a.Room.getPane(d,".message-form").submit(a.Message.submit);Candy.View.Event.Room.onAdd({roomJid:d,type:e,element:a.Room.getPane(d)});return f},show:function(c){var d=a.Chat.rooms[c].id;b(".room-pane").each(function(){var e=b(this);if(e.attr("id")===("chat-room-"+d)){e.show();Candy.View.getCurrent().roomJid=c;a.Chat.updateToolbar(c);a.Chat.setActiveTab(c);a.Chat.clearUnreadMessages(c);a.Room.setFocusToForm(c);a.Room.scrollToBottom(c);Candy.View.Event.Room.onShow({roomJid:c,element:e})}else{e.hide();Candy.View.Event.Room.onHide({roomJid:c,element:e})}})},setSubject:function(c,e){var d=Mustache.to_html(Candy.View.Template.Room.subject,{subject:e,roomName:a.Chat.rooms[c].name,_roomSubject:b.i18n._("roomSubject"),time:Candy.Util.localizedTime(new Date().toGMTString())});a.Room.appendToMessagePane(c,d);a.Room.scrollToBottom(c);Candy.View.Event.Room.onSubjectChange({roomJid:c,element:a.Room.getPane(c),subject:e})},close:function(c){a.Chat.removeTab(c);a.Window.clearUnreadMessages();a.Room.getPane(c).remove();var d=b("#chat-rooms").children();if(Candy.View.getCurrent().roomJid===c){Candy.View.getCurrent().roomJid=null;if(d.length===0){a.Chat.allTabsClosed()}else{a.Room.show(d.last().attr("data-roomjid"))}}delete a.Chat.rooms[c];Candy.View.Event.Room.onClose({roomJid:c})},appendToMessagePane:function(c,d){a.Room.getPane(c,".message-pane").append(d);a.Chat.rooms[c].messageCount++;a.Room.sliceMessagePane(c)},sliceMessagePane:function(c){if(a.Window.autoscroll){var d=Candy.View.getOptions().messages;if(a.Chat.rooms[c].messageCount>d.limit){a.Room.getPane(c,".message-pane").children().slice(0,d.remove*2).remove();a.Chat.rooms[c].messageCount-=d.remove}}},scrollToBottom:function(c){a.Room.onScrollToBottom(c)},onScrollToBottom:function(c){var d=a.Room.getPane(c,".message-pane-wrapper");d.scrollTop(d.prop("scrollHeight"))},onScrollToStoredPosition:function(c){if(a.Chat.rooms[c].scrollPosition>-1){var d=a.Room.getPane(c,".message-pane-wrapper");d.scrollTop(a.Chat.rooms[c].scrollPosition);a.Chat.rooms[c].scrollPosition=-1}},setFocusToForm:function(c){var f=a.Room.getPane(c,".message-form");if(f){try{f.children(".field")[0].focus()}catch(d){}}},setUser:function(d,c){a.Chat.rooms[d].user=c;var f=a.Room.getPane(d),e=b("#chat-pane");f.attr("data-userjid",c.getJid());if(c.isModerator()){if(c.getRole()===c.ROLE_MODERATOR){e.addClass("role-moderator")}if(c.getAffiliation()===c.AFFILIATION_OWNER){e.addClass("affiliation-owner")}}else{e.removeClass("role-moderator affiliation-owner")}a.Chat.Context.init()},getUser:function(c){return a.Chat.rooms[c].user},ignoreUser:function(c,d){Candy.Core.Action.Jabber.Room.IgnoreUnignore(d);Candy.View.Pane.Room.addIgnoreIcon(c,d)},unignoreUser:function(c,d){Candy.Core.Action.Jabber.Room.IgnoreUnignore(d);Candy.View.Pane.Room.removeIgnoreIcon(c,d)},addIgnoreIcon:function(c,d){if(Candy.View.Pane.Chat.rooms[d]){b("#user-"+Candy.View.Pane.Chat.rooms[d].id+"-"+Candy.Util.jidToId(d)).addClass("status-ignored")}if(Candy.View.Pane.Chat.rooms[Strophe.getBareJidFromJid(c)]){b("#user-"+Candy.View.Pane.Chat.rooms[Strophe.getBareJidFromJid(c)].id+"-"+Candy.Util.jidToId(d)).addClass("status-ignored")}},removeIgnoreIcon:function(c,d){if(Candy.View.Pane.Chat.rooms[d]){b("#user-"+Candy.View.Pane.Chat.rooms[d].id+"-"+Candy.Util.jidToId(d)).removeClass("status-ignored")}if(Candy.View.Pane.Chat.rooms[Strophe.getBareJidFromJid(c)]){b("#user-"+Candy.View.Pane.Chat.rooms[Strophe.getBareJidFromJid(c)].id+"-"+Candy.Util.jidToId(d)).removeClass("status-ignored")}},getPane:function(c,d){if(a.Chat.rooms[c]){if(d){if(a.Chat.rooms[c]["pane-"+d]){return a.Chat.rooms[c]["pane-"+d]}else{a.Chat.rooms[c]["pane-"+d]=b("#chat-room-"+a.Chat.rooms[c].id).find(d);return a.Chat.rooms[c]["pane-"+d]}}else{return b("#chat-room-"+a.Chat.rooms[c].id)}}}};a.PrivateRoom={open:function(e,c,f,g){var d=g?Candy.Core.getUser():a.Room.getUser(Strophe.getBareJidFromJid(e));if(Candy.Core.getUser().isInPrivacyList("ignore",e)){return false}if(!a.Chat.rooms[e]){a.Room.init(e,c,"chat")}if(f){a.Room.show(e)}a.Roster.update(e,new Candy.Core.ChatUser(e,c),"join",d);a.Roster.update(e,d,"join",d);a.PrivateRoom.setStatus(e,"join");if(g){a.Chat.infoMessage(e,b.i18n._("presenceUnknownWarningSubject"),b.i18n._("presenceUnknownWarning"))}Candy.View.Event.Room.onAdd({roomJid:e,type:"chat",element:a.Room.getPane(e)})},setStatus:function(d,c){var e=a.Room.getPane(d,".message-form");if(c==="join"){a.Chat.getTab(d).addClass("online").removeClass("offline");e.children(".field").removeAttr("disabled");e.children(".submit").removeAttr("disabled");a.Chat.getTab(d)}else{a.Chat.getTab(d).addClass("offline").removeClass("online");e.children(".field").attr("disabled",true);e.children(".submit").attr("disabled",true)}}};a.Roster={update:function(n,i,h,e){var f=a.Chat.rooms[n].id,k=Candy.Util.jidToId(i.getJid()),c=-1;if(h==="join"){c=1;var j=Mustache.to_html(Candy.View.Template.Roster.user,{roomId:f,userId:k,userJid:i.getJid(),nick:i.getNick(),displayNick:Candy.Util.crop(i.getNick(),Candy.View.getOptions().crop.roster.nickname),role:i.getRole(),affiliation:i.getAffiliation(),me:e!==undefined&&i.getNick()===e.getNick(),tooltipRole:b.i18n._("tooltipRole"),tooltipIgnored:b.i18n._("tooltipIgnored")}),m=b("#user-"+f+"-"+k);if(m.length<1){var d=false,l=a.Room.getPane(n,".roster-pane");if(l.children().length>0){var g=i.getNick().toUpperCase();l.children().each(function(){var o=b(this);if(o.attr("data-nick").toUpperCase()>g){o.before(j);d=true;return false}return true})}if(!d){l.append(j)}a.Roster.joinAnimation("user-"+f+"-"+k);if(e!==undefined&&i.getNick()!==e.getNick()&&a.Room.getUser(n)){if(a.Chat.rooms[n].type==="chat"){a.Chat.onInfoMessage(n,b.i18n._("userJoinedRoom",[i.getNick()]))}else{a.Chat.infoMessage(n,b.i18n._("userJoinedRoom",[i.getNick()]))}}}else{c=0;m.replaceWith(j);b("#user-"+f+"-"+k).css({opacity:1}).show()}if(e!==undefined&&e.getNick()===i.getNick()){a.Room.setUser(n,i)}else{b("#user-"+f+"-"+k).click(a.Roster.userClick)}b("#user-"+f+"-"+k+" .context").click(function(o){a.Chat.Context.show(o.currentTarget,n,i);o.stopPropagation()});if(e!==undefined&&e.isInPrivacyList("ignore",i.getJid())){Candy.View.Pane.Room.addIgnoreIcon(n,i.getJid())}}else{if(h==="leave"){a.Roster.leaveAnimation("user-"+f+"-"+k);if(a.Chat.rooms[n].type==="chat"){a.Chat.onInfoMessage(n,b.i18n._("userLeftRoom",[i.getNick()]))}else{a.Chat.infoMessage(n,b.i18n._("userLeftRoom",[i.getNick()]))}}else{if(h==="kick"){a.Roster.leaveAnimation("user-"+f+"-"+k);a.Chat.onInfoMessage(n,b.i18n._("userHasBeenKickedFromRoom",[i.getNick()]))}else{if(h==="ban"){a.Roster.leaveAnimation("user-"+f+"-"+k);a.Chat.onInfoMessage(n,b.i18n._("userHasBeenBannedFromRoom",[i.getNick()]))}}}}Candy.View.Pane.Chat.rooms[n].usercount+=c;if(n===Candy.View.getCurrent().roomJid){Candy.View.Pane.Chat.Toolbar.updateUsercount(Candy.View.Pane.Chat.rooms[n].usercount)}Candy.View.Event.Roster.onUpdate({roomJid:n,user:i,action:h,element:b("#user-"+f+"-"+k)})},userClick:function(){var c=b(this);a.PrivateRoom.open(c.attr("data-jid"),c.attr("data-nick"),true)},joinAnimation:function(c){b("#"+c).stop(true).slideDown("normal",function(){b(this).animate({opacity:1})})},leaveAnimation:function(c){b("#"+c).stop(true).attr("id","#"+c+"-leaving").animate({opacity:0},{complete:function(){b(this).slideUp("normal",function(){b(this).remove()})}})}};a.Message={submit:function(e){var c=Candy.View.Pane.Chat.rooms[Candy.View.getCurrent().roomJid].type,d=b(this).children(".field").val().substring(0,Candy.View.getOptions().crop.message.body);d=Candy.View.Event.Message.beforeSend(d);Candy.Core.Action.Jabber.Room.Message(Candy.View.getCurrent().roomJid,d,c);if(c==="chat"&&d){a.Message.show(Candy.View.getCurrent().roomJid,a.Room.getUser(Candy.View.getCurrent().roomJid).getNick(),d)}b(this).children(".field").val("").focus();e.preventDefault()},show:function(c,d,g,h){g=Candy.Util.Parser.all(g.substring(0,Candy.View.getOptions().crop.message.body));g=Candy.View.Event.Message.beforeShow({roomJid:c,nick:d,message:g});if(!g){return}var e=Mustache.to_html(Candy.View.Template.Message.item,{name:d,displayName:Candy.Util.crop(d,Candy.View.getOptions().crop.message.nickname),message:g,time:Candy.Util.localizedTime(h||new Date().toGMTString())});a.Room.appendToMessagePane(c,e);var f=a.Room.getPane(c,".message-pane").children().last();f.find("a.name").click(function(i){i.preventDefault();if(d!==a.Room.getUser(Candy.View.getCurrent().roomJid).getNick()&&Candy.Core.getRoom(c).getRoster().get(c+"/"+d)){Candy.View.Pane.PrivateRoom.open(c+"/"+d,d,true)}});if(Candy.View.getCurrent().roomJid!==c||!a.Window.hasFocus()){a.Chat.increaseUnreadMessages(c);if(Candy.View.Pane.Chat.rooms[c].type==="chat"&&!a.Window.hasFocus()){a.Chat.Toolbar.playSound()}}if(Candy.View.getCurrent().roomJid===c){a.Room.scrollToBottom(c)}Candy.View.Event.Message.onShow({roomJid:c,element:f,nick:d,message:g})}};return a}(Candy.View.Pane||{},jQuery));Candy.View.Template=(function(a){a.Window={unreadmessages:"({{count}}) {{title}}"};a.Chat={pane:'
    {{> tabs}}{{> toolbar}}{{> rooms}}
    {{> modal}}',rooms:'
    ',tabs:'
      ',tab:'
    • {{#privateUserChat}}@{{/privateUserChat}}{{name}}\u00D7
    • ',modal:'
      ',adminMessage:'
      {{time}}
      {{sender}}{{subject}} {{message}}
      ',infoMessage:'
      {{time}}
      {{subject}} {{message}}
      ',toolbar:'
      • {{> soundcontrol}}
      ',soundcontrol:' % endif +% if course.show_chat: + + + + ## These are being pulled from lms/static/js, and there's no vendor/ + ## directory there (yet) + + + + +% endif + % if timer_expiration_duration: @@ -148,6 +194,18 @@
      +% if course.show_chat: +
      +
      + Open Chat + Close Chat +
      +
      + ## The Candy.js plugin wants to render in an element with #candy +
      +
      +
      +% endif % if course.show_calculator:
      From a0ab47658a9c2fede036c9b31cee7591888540f7 Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Tue, 21 May 2013 11:07:16 -0700 Subject: [PATCH 06/77] Move Candy.js files to new vendor/ directory --- lms/static/js/{ => vendor}/candy.min.js | 0 .../js/{ => vendor}/candy_libs/dateformat/dateFormat.js | 0 lms/static/js/{ => vendor}/candy_libs/libs.bundle.js | 0 lms/static/js/{ => vendor}/candy_libs/libs.min.js | 0 lms/static/js/{ => vendor}/candy_ui.js | 0 lms/templates/courseware/courseware.html | 6 ++---- 6 files changed, 2 insertions(+), 4 deletions(-) rename lms/static/js/{ => vendor}/candy.min.js (100%) rename lms/static/js/{ => vendor}/candy_libs/dateformat/dateFormat.js (100%) rename lms/static/js/{ => vendor}/candy_libs/libs.bundle.js (100%) rename lms/static/js/{ => vendor}/candy_libs/libs.min.js (100%) rename lms/static/js/{ => vendor}/candy_ui.js (100%) diff --git a/lms/static/js/candy.min.js b/lms/static/js/vendor/candy.min.js similarity index 100% rename from lms/static/js/candy.min.js rename to lms/static/js/vendor/candy.min.js diff --git a/lms/static/js/candy_libs/dateformat/dateFormat.js b/lms/static/js/vendor/candy_libs/dateformat/dateFormat.js similarity index 100% rename from lms/static/js/candy_libs/dateformat/dateFormat.js rename to lms/static/js/vendor/candy_libs/dateformat/dateFormat.js diff --git a/lms/static/js/candy_libs/libs.bundle.js b/lms/static/js/vendor/candy_libs/libs.bundle.js similarity index 100% rename from lms/static/js/candy_libs/libs.bundle.js rename to lms/static/js/vendor/candy_libs/libs.bundle.js diff --git a/lms/static/js/candy_libs/libs.min.js b/lms/static/js/vendor/candy_libs/libs.min.js similarity index 100% rename from lms/static/js/candy_libs/libs.min.js rename to lms/static/js/vendor/candy_libs/libs.min.js diff --git a/lms/static/js/candy_ui.js b/lms/static/js/vendor/candy_ui.js similarity index 100% rename from lms/static/js/candy_ui.js rename to lms/static/js/vendor/candy_ui.js diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index c11a29aa20..23abbef25b 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -119,10 +119,8 @@ - ## These are being pulled from lms/static/js, and there's no vendor/ - ## directory there (yet) - - + + - - From f4ae0e0cae633f5a4b9e89e5a4cac178f8b24044 Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Tue, 21 May 2013 16:33:30 -0700 Subject: [PATCH 08/77] Test generation of chat settings Ensure that the chat connection settings are generated properly for the template context. --- lms/djangoapps/courseware/tests/test_views.py | 23 +++++++++++++ lms/djangoapps/courseware/views.py | 32 +++++++++++++++---- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index a5efe744a8..7d494df4cb 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -124,3 +124,26 @@ class ViewsTestCase(TestCase): self.assertContains(result, expected_end_text) else: self.assertNotContains(result, "Classes End") + + def test_chat_settings(self): + mock_user = MagicMock() + mock_user.username = "johndoe" + + mock_course = MagicMock() + mock_course.id = "a/b/c" + + # Stub this out in the case that it's not in the settings + domain = "jabber.edx.org" + settings.JABBER_DOMAIN = domain + + chat_settings = views.chat_settings(mock_course, mock_user) + + # Test the proper format of all chat settings + self.assertEquals(chat_settings['domain'], domain) + self.assertEquals(chat_settings['room'], "a-b-c_class") + self.assertEquals(chat_settings['username'], "johndoe@%s" % domain) + + # TODO: this needs to be changed once we figure out how to + # generate/store a real password. + self.assertEquals(chat_settings['password'], "johndoe@%s" % domain) + diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 360eb143a5..9dc9f77bbe 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -235,6 +235,30 @@ def update_timelimit_module(user, course_id, model_data_cache, timelimit_descrip return context +def chat_settings(course, user): + """ + Returns a dict containing the settings required to connect to a + Jabber chat server and room. + """ + return { + 'domain': settings.JABBER_DOMAIN, + + # Jabber doesn't like slashes, so replace with dashes + 'room': "{ID}_class".format(ID=course.id.replace('/', '-')), + + 'username': "{USER}@{DOMAIN}".format( + USER=user.username, DOMAIN=settings.JABBER_DOMAIN + ), + + # TODO: clearly this needs to be something other than the username + # should also be something that's not necessarily tied to a + # particular course + 'password': "{USER}@{DOMAIN}".format( + USER=user.username, DOMAIN=settings.JABBER_DOMAIN + ), + } + + @login_required @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @@ -300,13 +324,7 @@ def index(request, course_id, chapter=None, section=None, } if course.show_chat: - context['chat'] = { - 'domain': settings.JABBER_DOMAIN, - 'room': "{ID}_class".format(ID=course.id.replace('/', '-')), # Jabber doesn't like /s - 'username': "{USER}@{DOMAIN}".format(USER=user.username, DOMAIN=settings.JABBER_DOMAIN), - # TODO: clearly this needs to be something other than the username - 'password': "{USER}@{DOMAIN}".format(USER=user.username, DOMAIN=settings.JABBER_DOMAIN), - } + context['chat'] = chat_settings(course, user) chapter_descriptor = course.get_child_by(lambda m: m.url_name == chapter) if chapter_descriptor is not None: From 3aec9fdb773f79c024060465e5c5d54aeb7bf38d Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Thu, 30 May 2013 18:54:39 -0700 Subject: [PATCH 09/77] Add setting to enable/disable chat site-wide To protect against chat blowing things up, we include a way to enable and disable it on an entire site-wide basis. --- lms/envs/common.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lms/envs/common.py b/lms/envs/common.py index 0859dbc3f2..7dee15a8c8 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -144,6 +144,10 @@ MITX_FEATURES = { # Allow use of the hint managment instructor view. 'ENABLE_HINTER_INSTRUCTOR_VIEW': False, + + # Toggle to enable chat availability (configured on a per-course + # basis in Studio) + 'ENABLE_CHAT': False } # Used for A/B testing From 293bd30a59a9c33df4135ece2cb71734e752a563 Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Thu, 30 May 2013 18:56:00 -0700 Subject: [PATCH 10/77] Use chat settings when deciding whether to render Only render the chat widget if both the site has enabled it in the `MITX_FEATURES` and if the course has enabled it. In addition, fail gracefully with a log warning if the `JABBER_DOMAIN` is not set, and do not try to render the widget. However, do go ahead and render the rest of the courseware. --- lms/djangoapps/courseware/views.py | 23 +++++++++++++++++++---- lms/templates/courseware/courseware.html | 6 +++--- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 9dc9f77bbe..f1e1f7660c 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -240,21 +240,27 @@ def chat_settings(course, user): Returns a dict containing the settings required to connect to a Jabber chat server and room. """ + domain = getattr(settings, "JABBER_DOMAIN", None) + if domain is None: + log.warning('You must set JABBER_DOMAIN in the settings to ' + 'enable the chat widget') + return None + return { - 'domain': settings.JABBER_DOMAIN, + 'domain': domain, # Jabber doesn't like slashes, so replace with dashes 'room': "{ID}_class".format(ID=course.id.replace('/', '-')), 'username': "{USER}@{DOMAIN}".format( - USER=user.username, DOMAIN=settings.JABBER_DOMAIN + USER=user.username, DOMAIN=domain ), # TODO: clearly this needs to be something other than the username # should also be something that's not necessarily tied to a # particular course 'password': "{USER}@{DOMAIN}".format( - USER=user.username, DOMAIN=settings.JABBER_DOMAIN + USER=user.username, DOMAIN=domain ), } @@ -323,8 +329,17 @@ def index(request, course_id, chapter=None, section=None, 'xqa_server': settings.MITX_FEATURES.get('USE_XQA_SERVER', 'http://xqa:server@content-qa.mitx.mit.edu/xqa') } - if course.show_chat: + # Only show the chat if it's enabled by the course and in the + # settings. + show_chat = course.show_chat and settings.MITX_FEATURES['ENABLE_CHAT'] + if show_chat: context['chat'] = chat_settings(course, user) + # If we couldn't load the chat settings, then don't show + # the widget in the courseware. + if context['chat'] is None: + show_chat = False + + context['show_chat'] = show_chat chapter_descriptor = course.get_child_by(lambda m: m.url_name == chapter) if chapter_descriptor is not None: diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index 2fa3adec0c..e009e535e3 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -6,7 +6,7 @@ <%block name="headextra"> <%static:css group='course'/> <%include file="../discussion/_js_head_dependencies.html" /> - % if course.show_chat: + % if show_chat: ## It'd be better to have this in a place like lms/css/vendor/candy, ## but the candy_res/ folder contains images and other junk, and it @@ -115,7 +115,7 @@ % endif -% if course.show_chat: +% if show_chat: @@ -189,7 +189,7 @@
      -% if course.show_chat: +% if show_chat:
      Open Chat From 50c906732fb51707315ca298eb6c87209718a892 Mon Sep 17 00:00:00 2001 From: Joe Blaylock Date: Wed, 17 Jul 2013 12:28:58 -0700 Subject: [PATCH 11/77] Chat rebase, test update Rebase on today's master and update to unit test to include settings where needed. --- lms/djangoapps/courseware/tests/test_views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 7d494df4cb..07be74c98e 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -6,6 +6,7 @@ from django.http import Http404 from django.test.utils import override_settings from django.contrib.auth.models import User from django.test.client import RequestFactory +from django.conf import settings from student.models import CourseEnrollment from xmodule.modulestore.django import modulestore From a04539af5d10ef9277257dc41409103d501aa71b Mon Sep 17 00:00:00 2001 From: Adam Palay Date: Fri, 12 Jul 2013 15:03:47 -0400 Subject: [PATCH 12/77] change models to filter by course --- common/lib/xmodule/xmodule/foldit_module.py | 9 ++-- lms/djangoapps/foldit/models.py | 37 ++++++++----- lms/djangoapps/foldit/tests.py | 60 +++++++++++++++++---- 3 files changed, 80 insertions(+), 26 deletions(-) diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py index c4e9e2d35c..cadf6cef0b 100644 --- a/common/lib/xmodule/xmodule/foldit_module.py +++ b/common/lib/xmodule/xmodule/foldit_module.py @@ -91,15 +91,18 @@ class FolditModule(FolditFields, XModule): PuzzleComplete.completed_puzzles(self.system.anonymous_student_id), key=lambda d: (d['set'], d['subset'])) - def puzzle_leaders(self, n=10): + def puzzle_leaders(self, n=10, courses=None): """ Returns a list of n pairs (user, score) corresponding to the top scores; the pairs are in descending order of score. """ from foldit.models import Score - leaders = [(e['username'], e['score']) for e in Score.get_tops_n(10)] - leaders.sort(key=lambda x:-x[1]) + if courses is None: + courses = [self.location.course_id] + + leaders = [(leader['username'], leader['score']) for leader in Score.get_tops_n(10, course_list=courses)] + leaders.sort(key=lambda x: -x[1]) return leaders diff --git a/lms/djangoapps/foldit/models.py b/lms/djangoapps/foldit/models.py index c0ef553d7e..d413731647 100644 --- a/lms/djangoapps/foldit/models.py +++ b/lms/djangoapps/foldit/models.py @@ -6,6 +6,7 @@ from django.db import models log = logging.getLogger(__name__) + class Score(models.Model): """ This model stores the scores of different users on FoldIt problems. @@ -35,9 +36,8 @@ class Score(models.Model): """ return (-score) * 10 + 8000 * sum_of - @staticmethod - def get_tops_n(n, puzzles=['994559']): + def get_tops_n(n, puzzles=['994559'], course_list=None): """ Arguments: puzzles: a list of puzzle ids that we will use. If not specified, @@ -46,22 +46,34 @@ class Score(models.Model): Returns: - The top n sum of scores for puzzles in . Output is a list - of disctionaries, sorted by display_score: + The top n sum of scores for puzzles in , + filtered by course. If no courses is specified we default + the pool of students to all courses. Output is a list + of dictionaries, sorted by display_score: [ {username: 'a_user', score: 12000} ...] """ - if not(type(puzzles) == list): + + if not isinstance(puzzles, list): puzzles = [puzzles] - scores = Score.objects \ - .filter(puzzle_id__in=puzzles) \ - .annotate(total_score=models.Sum('best_score')) \ - .order_by('total_score')[:n] + if course_list is None: + scores = Score.objects \ + .filter(puzzle_id__in=puzzles) \ + .annotate(total_score=models.Sum('best_score')) \ + .order_by('total_score')[:n] + else: + scores = Score.objects \ + .filter(puzzle_id__in=puzzles) \ + .filter(user__courseenrollment__course_id__in=course_list) \ + .annotate(total_score=models.Sum('best_score')) \ + .order_by('total_score')[:n] num = len(puzzles) - return [{'username': s.user.username, - 'score': Score.display_score(s.total_score, num)} - for s in scores] + return [ + {'username': score.user.username, + 'score': Score.display_score(score.total_score, num)} + for score in scores + ] class PuzzleComplete(models.Model): @@ -94,7 +106,6 @@ class PuzzleComplete(models.Model): self.puzzle_set, self.puzzle_subset, self.created) - @staticmethod def completed_puzzles(anonymous_user_id): """ diff --git a/lms/djangoapps/foldit/tests.py b/lms/djangoapps/foldit/tests.py index 1f354083a9..c97cd2b59d 100644 --- a/lms/djangoapps/foldit/tests.py +++ b/lms/djangoapps/foldit/tests.py @@ -2,14 +2,14 @@ import json import logging from functools import partial -from django.contrib.auth.models import User from django.test import TestCase from django.test.client import RequestFactory from django.core.urlresolvers import reverse from foldit.views import foldit_ops, verify_code from foldit.models import PuzzleComplete, Score -from student.models import UserProfile, unique_id_for_user +from student.models import unique_id_for_user +from student.tests.factories import CourseEnrollmentFactory, UserFactory, UserProfileFactory from datetime import datetime, timedelta from pytz import UTC @@ -23,17 +23,25 @@ class FolditTestCase(TestCase): self.factory = RequestFactory() self.url = reverse('foldit_ops') - pwd = 'abc' - self.user = User.objects.create_user('testuser', 'test@test.com', pwd) - self.user2 = User.objects.create_user('testuser2', 'test2@test.com', pwd) - self.unique_user_id = unique_id_for_user(self.user) - self.unique_user_id2 = unique_id_for_user(self.user2) + self.course_id = 'course/id/1' + self.course_id2 = 'course/id/2' + + self.user = UserFactory.create() + self.user2 = UserFactory.create() + + self.course_enrollment = CourseEnrollmentFactory.create( + user=self.user, course_id=self.course_id + ) + self.course_enrollment2 = CourseEnrollmentFactory.create( + user=self.user2, course_id=self.course_id2 + ) + now = datetime.now(UTC) self.tomorrow = now + timedelta(days=1) self.yesterday = now - timedelta(days=1) - UserProfile.objects.create(user=self.user) - UserProfile.objects.create(user=self.user2) + self.user.profile + self.user2.profile def make_request(self, post_data, user=None): request = self.factory.post(self.url, post_data) @@ -150,6 +158,38 @@ class FolditTestCase(TestCase): delta=0.5 ) + def test_SetPlayerPuzzleScores_multiplecourses(self): + puzzle_id = "1" + + player1_score = 0.05 + player2_score = 0.06 + + course_list_1 = [self.course_id] + course_list_2 = [self.course_id2] + + response1 = self.make_puzzle_score_request( + puzzle_id, player1_score, self.user + ) + course_1_top_10 = Score.get_tops_n(10, puzzle_id, course_list_1) + course_2_top_10 = Score.get_tops_n(10, puzzle_id, course_list_2) + total_top_10 = Score.get_tops_n(10, puzzle_id) + + # player1 should now be in the top 10 of course 1 and not in course 2 + self.assertEqual(len(course_1_top_10), 1) + self.assertEqual(len(course_2_top_10), 0) + self.assertEqual(len(total_top_10), 1) + + response2 = self.make_puzzle_score_request( + puzzle_id, player2_score, self.user2 + ) + course_2_top_10 = Score.get_tops_n(10, puzzle_id, course_list_2) + total_top_10 = Score.get_tops_n(10, puzzle_id) + + # player2 should now be in the top 10 of course 2 and not in course 1 + self.assertEqual(len(course_1_top_10), 1) + self.assertEqual(len(course_2_top_10), 1) + self.assertEqual(len(total_top_10), 2) + def test_SetPlayerPuzzleScores_manyplayers(self): """ Check that when we send scores from multiple users, the correct order @@ -306,7 +346,7 @@ class FolditTestCase(TestCase): self.set_puzzle_complete_response([13, 14, 15, 53524])) is_complete = partial( - PuzzleComplete.is_level_complete, self.unique_user_id) + PuzzleComplete.is_level_complete, unique_id_for_user(self.user)) self.assertTrue(is_complete(1, 1)) self.assertTrue(is_complete(1, 3)) From 63c3bb0812bf4030e2856d7951b37afa682420a2 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Fri, 19 Jul 2013 11:12:23 -0400 Subject: [PATCH 13/77] Reproduce POST showing up as PUT error --- cms/djangoapps/contentstore/tests/test_course_updates.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/tests/test_course_updates.py b/cms/djangoapps/contentstore/tests/test_course_updates.py index 4f92806871..30114496c8 100644 --- a/cms/djangoapps/contentstore/tests/test_course_updates.py +++ b/cms/djangoapps/contentstore/tests/test_course_updates.py @@ -36,8 +36,11 @@ class CourseUpdateTest(CourseTestCase): 'provided_id': payload['id']}) content += '
      div

      p

      ' payload['content'] = content + # POST requests were coming in w/ these header values causing an error; so, repro error here resp = self.client.post(first_update_url, json.dumps(payload), - "application/json") + "application/json", + HTTP_X_HTTP_METHOD_OVERRIDE="PUT", + REQUEST_METHOD="POST") self.assertHTMLEqual(content, json.loads(resp.content)['content'], "iframe w/ div") From 541c98a3afdd9ceeec3031c629cd15027e1b24cf Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Fri, 19 Jul 2013 11:14:01 -0400 Subject: [PATCH 14/77] POST requests were claiming request.method == 'PUT' So, changed handlers to look for either value. --- cms/djangoapps/contentstore/views/assets.py | 5 +++-- cms/djangoapps/contentstore/views/component.py | 3 ++- cms/djangoapps/contentstore/views/course.py | 12 ++++++------ 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index d0b202da19..0bb9551ac9 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -13,7 +13,7 @@ from django_future.csrf import ensure_csrf_cookie from django.core.urlresolvers import reverse from django.core.servers.basehttp import FileWrapper from django.core.files.temp import NamedTemporaryFile -from django.views.decorators.http import require_POST +from django.views.decorators.http import require_POST, require_http_methods from mitxmako.shortcuts import render_to_response from cache_toolbox.core import del_cached_content @@ -249,6 +249,7 @@ def remove_asset(request, org, course, name): @ensure_csrf_cookie +@require_http_methods(("GET", "POST", "PUT")) @login_required def import_course(request, org, course, name): """ @@ -256,7 +257,7 @@ def import_course(request, org, course, name): """ location = get_location_and_verify_access(request, org, course, name) - if request.method == 'POST': + if request.method in ('POST', 'PUT'): filename = request.FILES['course-data'].name if not filename.endswith('.tar.gz'): diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 505a93903a..1be6ac2822 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -245,6 +245,7 @@ def edit_unit(request, location): @expect_json @login_required +@require_http_methods(("GET", "POST", "PUT")) @ensure_csrf_cookie def assignment_type_update(request, org, course, category, name): ''' @@ -256,7 +257,7 @@ def assignment_type_update(request, org, course, category, name): if request.method == 'GET': return JsonResponse(CourseGradingModel.get_section_grader_type(location)) - elif request.method == 'POST': # post or put, doesn't matter. + elif request.method in ('POST', 'PUT'): # post or put, doesn't matter. return JsonResponse(CourseGradingModel.update_section_grader_type(location, request.POST)) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 3791e6779a..02eb4c65b8 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -42,8 +42,7 @@ from .component import ( ADVANCED_COMPONENT_POLICY_KEY) from django_comment_common.utils import seed_permissions_roles -import datetime -from django.utils.timezone import UTC + from xmodule.html_module import AboutDescriptor __all__ = ['course_index', 'create_new_course', 'course_info', 'course_info_updates', 'get_course_settings', @@ -176,6 +175,7 @@ def course_info(request, org, course, name, provided_id=None): @expect_json +@require_http_methods(("GET", "POST", "PUT", "DELETE")) @login_required @ensure_csrf_cookie def course_info_updates(request, org, course, provided_id=None): @@ -206,7 +206,7 @@ def course_info_updates(request, org, course, provided_id=None): except: return HttpResponseBadRequest("Failed to delete", content_type="text/plain") - elif request.method == 'POST': + elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other try: return JsonResponse(update_course_updates(location, request.POST, provided_id)) except: @@ -300,7 +300,7 @@ def course_settings_updates(request, org, course, name, section): if request.method == 'GET': # Cannot just do a get w/o knowing the course name :-( return JsonResponse(manager.fetch(Location(['i4x', org, course, 'course', name])), encoder=CourseSettingsEncoder) - elif request.method == 'POST': # post or put, doesn't matter. + elif request.method in ('POST', 'PUT'): # post or put, doesn't matter. return JsonResponse(manager.update_from_json(request.POST), encoder=CourseSettingsEncoder) @@ -479,7 +479,7 @@ def textbook_index(request, org, course, name): if request.is_ajax(): if request.method == 'GET': return JsonResponse(course_module.pdf_textbooks) - elif request.method == 'POST': + elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other try: textbooks = validate_textbooks_json(request.body) except TextbookValidationError as err: @@ -580,7 +580,7 @@ def textbook_by_id(request, org, course, name, tid): if not textbook: return JsonResponse(status=404) return JsonResponse(textbook) - elif request.method in ('POST', 'PUT'): + elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other try: new_textbook = validate_textbook_json(request.body) except TextbookValidationError as err: From 2a3dd7323fe7aa7a53cffe2783735b9d2252358d Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Thu, 18 Jul 2013 09:43:46 -0400 Subject: [PATCH 15/77] CMS i18n changes from Tsinghua --- cms/static/js/base.js | 2 +- cms/templates/edit-static-page.html | 41 ----------------------------- cms/templates/editable_preview.html | 13 --------- cms/templates/overview.html | 2 +- 4 files changed, 2 insertions(+), 56 deletions(-) delete mode 100644 cms/templates/edit-static-page.html delete mode 100644 cms/templates/editable_preview.html diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 329624ef46..e9273e3864 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -728,7 +728,7 @@ function saveSetSectionScheduleDate(e) { var $thisSection = $('.courseware-section[data-id="' + id + '"]'); var html = _.template( '' + - '' + gettext("Will Release: ") + '' + + '' + gettext("Will Release:") + '' + gettext("<%= date %> at <%= time %> UTC") + '' + '' + diff --git a/cms/templates/edit-static-page.html b/cms/templates/edit-static-page.html deleted file mode 100644 index f1b2374b46..0000000000 --- a/cms/templates/edit-static-page.html +++ /dev/null @@ -1,41 +0,0 @@ -<%inherit file="base.html" /> -<%! from django.core.urlresolvers import reverse %> -<%block name="title">Editing Static Page -<%block name="bodyclass">is-signedin course pages edit-static-page - -<%block name="content"> - - \ No newline at end of file diff --git a/cms/templates/editable_preview.html b/cms/templates/editable_preview.html deleted file mode 100644 index 731fd9b1c8..0000000000 --- a/cms/templates/editable_preview.html +++ /dev/null @@ -1,13 +0,0 @@ -
      -${content} -
      - Edit - Delete -
      - -
      -
      Edit Video Component
      - - SaveCancel -
      -
      diff --git a/cms/templates/overview.html b/cms/templates/overview.html index 56836b00ad..3795e9d09b 100644 --- a/cms/templates/overview.html +++ b/cms/templates/overview.html @@ -1,4 +1,4 @@ -/<%! from django.utils.translation import ugettext as _ %> +<%! from django.utils.translation import ugettext as _ %> <%inherit file="base.html" /> <%! import logging From df7b917b40e0f7dd7ded0b37b7c6db15d31bac29 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Tue, 25 Jun 2013 11:46:50 -0400 Subject: [PATCH 16/77] Implement next generation modulestore A new modulestore backed by mongo that changes the data format to facilitate easy versioning, sharing content between courses, and fast lookup of course structure and Scope.settings data. Conflicts: cms/djangoapps/contentstore/tests/test_contentstore.py --- .../contentstore/tests/test_crud.py | 186 +++ cms/envs/dev.py | 4 + cms/envs/test.py | 4 + common/lib/xmodule/xmodule/course_module.py | 6 +- common/lib/xmodule/xmodule/error_module.py | 8 +- .../xmodule/xmodule/modulestore/exceptions.py | 15 + .../xmodule/modulestore/inheritance.py | 2 + .../xmodule/xmodule/modulestore/locator.py | 465 +++++++ .../xmodule/xmodule/modulestore/parsers.py | 111 ++ .../modulestore/split_mongo/__init__.py | 1 + .../split_mongo/caching_descriptor_system.py | 119 ++ .../split_mongo/definition_lazy_loader.py | 25 + .../xmodule/modulestore/split_mongo/split.py | 1240 +++++++++++++++++ .../split_mongo/split_mongo_kvs.py | 164 +++ .../modulestore/tests/persistent_factories.py | 96 ++ .../modulestore/tests/test_locators.py | 539 +++++++ .../tests/test_split_modulestore.py | 923 ++++++++++++ common/lib/xmodule/xmodule/x_module.py | 83 +- .../data/splitmongo_json/active_versions.json | 27 + .../data/splitmongo_json/definitions.json | 334 +++++ .../test/data/splitmongo_json/structures.json | 471 +++++++ docs/source/persistence.rst | 658 +++++++++ 22 files changed, 5448 insertions(+), 33 deletions(-) create mode 100644 cms/djangoapps/contentstore/tests/test_crud.py create mode 100644 common/lib/xmodule/xmodule/modulestore/locator.py create mode 100644 common/lib/xmodule/xmodule/modulestore/parsers.py create mode 100644 common/lib/xmodule/xmodule/modulestore/split_mongo/__init__.py create mode 100644 common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py create mode 100644 common/lib/xmodule/xmodule/modulestore/split_mongo/definition_lazy_loader.py create mode 100644 common/lib/xmodule/xmodule/modulestore/split_mongo/split.py create mode 100644 common/lib/xmodule/xmodule/modulestore/split_mongo/split_mongo_kvs.py create mode 100644 common/lib/xmodule/xmodule/modulestore/tests/persistent_factories.py create mode 100644 common/lib/xmodule/xmodule/modulestore/tests/test_locators.py create mode 100644 common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py create mode 100644 common/test/data/splitmongo_json/active_versions.json create mode 100644 common/test/data/splitmongo_json/definitions.json create mode 100644 common/test/data/splitmongo_json/structures.json create mode 100644 docs/source/persistence.rst diff --git a/cms/djangoapps/contentstore/tests/test_crud.py b/cms/djangoapps/contentstore/tests/test_crud.py new file mode 100644 index 0000000000..84643f7787 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_crud.py @@ -0,0 +1,186 @@ +''' +Created on May 7, 2013 + +@author: dmitchell +''' +import unittest +from xmodule import templates +from xmodule.modulestore.tests import persistent_factories +from xmodule.course_module import CourseDescriptor +from xmodule.modulestore.django import modulestore +from xmodule.seq_module import SequenceDescriptor +from xmodule.x_module import XModuleDescriptor +from xmodule.capa_module import CapaDescriptor +from xmodule.modulestore.locator import CourseLocator, BlockUsageLocator +from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.html_module import HtmlDescriptor + + +class TemplateTests(unittest.TestCase): + """ + Test finding and using the templates (boilerplates) for xblocks. + """ + + def test_get_templates(self): + found = templates.all_templates() + self.assertIsNotNone(found.get('course')) + self.assertIsNotNone(found.get('about')) + self.assertIsNotNone(found.get('html')) + self.assertIsNotNone(found.get('problem')) + self.assertEqual(len(found.get('course')), 0) + self.assertEqual(len(found.get('about')), 1) + self.assertGreaterEqual(len(found.get('html')), 2) + self.assertGreaterEqual(len(found.get('problem')), 10) + dropdown = None + for template in found['problem']: + self.assertIn('metadata', template) + self.assertIn('display_name', template['metadata']) + if template['metadata']['display_name'] == 'Dropdown': + dropdown = template + break + self.assertIsNotNone(dropdown) + self.assertIn('markdown', dropdown['metadata']) + self.assertIn('data', dropdown) + self.assertRegexpMatches(dropdown['metadata']['markdown'], r'^Dropdown.*') + self.assertRegexpMatches(dropdown['data'], r'\s*

      Dropdown.*') + + def test_get_some_templates(self): + self.assertEqual(len(SequenceDescriptor.templates()), 0) + self.assertGreater(len(HtmlDescriptor.templates()), 0) + self.assertIsNone(SequenceDescriptor.get_template('doesntexist.yaml')) + self.assertIsNone(HtmlDescriptor.get_template('doesntexist.yaml')) + self.assertIsNotNone(HtmlDescriptor.get_template('announcement.yaml')) + + def test_factories(self): + test_course = persistent_factories.PersistentCourseFactory.create(org='testx', prettyid='tempcourse', + display_name='fun test course', user_id='testbot') + self.assertIsInstance(test_course, CourseDescriptor) + self.assertEqual(test_course.display_name, 'fun test course') + index_info = modulestore('split').get_course_index_info(test_course.location) + self.assertEqual(index_info['org'], 'testx') + self.assertEqual(index_info['prettyid'], 'tempcourse') + + test_chapter = persistent_factories.ItemFactory.create(display_name='chapter 1', + parent_location=test_course.location) + self.assertIsInstance(test_chapter, SequenceDescriptor) + # refetch parent which should now point to child + test_course = modulestore('split').get_course(test_chapter.location) + self.assertIn(test_chapter.location.usage_id, test_course.children) + + def test_temporary_xblocks(self): + """ + Test using load_from_json to create non persisted xblocks + """ + test_course = persistent_factories.PersistentCourseFactory.create(org='testx', prettyid='tempcourse', + display_name='fun test course', user_id='testbot') + + test_chapter = XModuleDescriptor.load_from_json({'category': 'chapter', + 'metadata': {'display_name': 'chapter n'}}, + test_course.system, parent_xblock=test_course) + self.assertIsInstance(test_chapter, SequenceDescriptor) + self.assertEqual(test_chapter.display_name, 'chapter n') + self.assertIn(test_chapter, test_course.get_children()) + + # test w/ a definition (e.g., a problem) + test_def_content = 'boo' + test_problem = XModuleDescriptor.load_from_json({'category': 'problem', + 'definition': {'data': test_def_content}}, + test_course.system, parent_xblock=test_chapter) + self.assertIsInstance(test_problem, CapaDescriptor) + self.assertEqual(test_problem.data, test_def_content) + self.assertIn(test_problem, test_chapter.get_children()) + test_problem.display_name = 'test problem' + self.assertEqual(test_problem.display_name, 'test problem') + + def test_persist_dag(self): + """ + try saving temporary xblocks + """ + test_course = persistent_factories.PersistentCourseFactory.create(org='testx', prettyid='tempcourse', + display_name='fun test course', user_id='testbot') + test_chapter = XModuleDescriptor.load_from_json({'category': 'chapter', + 'metadata': {'display_name': 'chapter n'}}, + test_course.system, parent_xblock=test_course) + test_def_content = 'boo' + test_problem = XModuleDescriptor.load_from_json({'category': 'problem', + 'definition': {'data': test_def_content}}, + test_course.system, parent_xblock=test_chapter) + # better to pass in persisted parent over the subdag so + # subdag gets the parent pointer (otherwise 2 ops, persist dag, update parent children, + # persist parent + persisted_course = modulestore('split').persist_xblock_dag(test_course, 'testbot') + self.assertEqual(len(persisted_course.children), 1) + persisted_chapter = persisted_course.get_children()[0] + self.assertEqual(persisted_chapter.category, 'chapter') + self.assertEqual(persisted_chapter.display_name, 'chapter n') + self.assertEqual(len(persisted_chapter.children), 1) + persisted_problem = persisted_chapter.get_children()[0] + self.assertEqual(persisted_problem.category, 'problem') + self.assertEqual(persisted_problem.data, test_def_content) + + def test_delete_course(self): + test_course = persistent_factories.PersistentCourseFactory.create( + org='testx', + prettyid='edu.harvard.history.doomed', + display_name='doomed test course', + user_id='testbot') + persistent_factories.ItemFactory.create(display_name='chapter 1', + parent_location=test_course.location) + + id_locator = CourseLocator(course_id=test_course.location.course_id, revision='draft') + guid_locator = CourseLocator(version_guid=test_course.location.version_guid) + # verify it can be retireved by id + self.assertIsInstance(modulestore('split').get_course(id_locator), CourseDescriptor) + # and by guid + self.assertIsInstance(modulestore('split').get_course(guid_locator), CourseDescriptor) + modulestore('split').delete_course(id_locator.course_id) + # test can no longer retrieve by id + self.assertRaises(ItemNotFoundError, modulestore('split').get_course, id_locator) + # but can by guid + self.assertIsInstance(modulestore('split').get_course(guid_locator), CourseDescriptor) + + def test_block_generations(self): + """ + Test get_block_generations + """ + test_course = persistent_factories.PersistentCourseFactory.create( + org='testx', + prettyid='edu.harvard.history.hist101', + display_name='history test course', + user_id='testbot') + chapter = persistent_factories.ItemFactory.create(display_name='chapter 1', + parent_location=test_course.location, user_id='testbot') + sub = persistent_factories.ItemFactory.create(display_name='subsection 1', + parent_location=chapter.location, user_id='testbot', category='vertical') + first_problem = persistent_factories.ItemFactory.create(display_name='problem 1', + parent_location=sub.location, user_id='testbot', category='problem', data="") + first_problem.max_attempts = 3 + updated_problem = modulestore('split').update_item(first_problem, 'testbot') + updated_loc = modulestore('split').delete_item(updated_problem.location, 'testbot') + + second_problem = persistent_factories.ItemFactory.create(display_name='problem 2', + parent_location=BlockUsageLocator(updated_loc, usage_id=sub.location.usage_id), + user_id='testbot', category='problem', data="") + + # course root only updated 2x + version_history = modulestore('split').get_block_generations(test_course.location) + self.assertEqual(version_history.locator.version_guid, test_course.location.version_guid) + self.assertEqual(len(version_history.children), 1) + self.assertEqual(version_history.children[0].children, []) + self.assertEqual(version_history.children[0].locator.version_guid, chapter.location.version_guid) + + # sub changed on add, add problem, delete problem, add problem in strict linear seq + version_history = modulestore('split').get_block_generations(sub.location) + self.assertEqual(len(version_history.children), 1) + self.assertEqual(len(version_history.children[0].children), 1) + self.assertEqual(len(version_history.children[0].children[0].children), 1) + self.assertEqual(len(version_history.children[0].children[0].children[0].children), 0) + + # first and second problem may show as same usage_id; so, need to ensure their histories are right + version_history = modulestore('split').get_block_generations(updated_problem.location) + self.assertEqual(version_history.locator.version_guid, first_problem.location.version_guid) + self.assertEqual(len(version_history.children), 1) # updated max_attempts + self.assertEqual(len(version_history.children[0].children), 0) + + version_history = modulestore('split').get_block_generations(second_problem.location) + self.assertNotEqual(version_history.locator.version_guid, first_problem.location.version_guid) diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 4f8174ac2b..0b0a62f05d 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -33,6 +33,10 @@ MODULESTORE = { 'direct': { 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', 'OPTIONS': modulestore_options + }, + 'split': { + 'ENGINE': 'xmodule.modulestore.split_mongo.SplitMongoModuleStore', + 'OPTIONS': modulestore_options } } diff --git a/cms/envs/test.py b/cms/envs/test.py index 431a2c4184..efc7c5a7ef 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -63,6 +63,10 @@ MODULESTORE = { 'draft': { 'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore', 'OPTIONS': MODULESTORE_OPTIONS + }, + 'split': { + 'ENGINE': 'xmodule.modulestore.split_mongo.SplitMongoModuleStore', + 'OPTIONS': MODULESTORE_OPTIONS } } diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index d4aac5b0ae..0af04c63c6 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -15,6 +15,7 @@ import json from xblock.core import Scope, List, String, Dict, Boolean from .fields import Date +from xmodule.modulestore.locator import CourseLocator from django.utils.timezone import UTC from xmodule.util import date_utils @@ -373,7 +374,10 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): super(CourseDescriptor, self).__init__(*args, **kwargs) if self.wiki_slug is None: - self.wiki_slug = self.location.course + if isinstance(self.location, Location): + self.wiki_slug = self.location.course + elif isinstance(self.location, CourseLocator): + self.wiki_slug = self.location.course_id or self.display_name msg = None diff --git a/common/lib/xmodule/xmodule/error_module.py b/common/lib/xmodule/xmodule/error_module.py index db1f998187..e7483f485a 100644 --- a/common/lib/xmodule/xmodule/error_module.py +++ b/common/lib/xmodule/xmodule/error_module.py @@ -79,8 +79,10 @@ class ErrorDescriptor(ErrorFields, JSONEditingDescriptor): @classmethod def _construct(cls, system, contents, error_msg, location): - if location.name is None: - location = location._replace( + if isinstance(location, dict) and 'course' in location: + location = Location(location) + if isinstance(location, Location) and location.name is None: + location = location.replace( category='error', # Pick a unique url_name -- the sha1 hash of the contents. # NOTE: We could try to pull out the url_name of the errored descriptor, @@ -94,7 +96,7 @@ class ErrorDescriptor(ErrorFields, JSONEditingDescriptor): model_data = { 'error_msg': str(error_msg), 'contents': contents, - 'display_name': 'Error: ' + location.name, + 'display_name': 'Error: ' + location.url(), 'location': location, 'category': 'error' } diff --git a/common/lib/xmodule/xmodule/modulestore/exceptions.py b/common/lib/xmodule/xmodule/modulestore/exceptions.py index a63efc3e43..57ab810bb4 100644 --- a/common/lib/xmodule/xmodule/modulestore/exceptions.py +++ b/common/lib/xmodule/xmodule/modulestore/exceptions.py @@ -7,9 +7,15 @@ class ItemNotFoundError(Exception): pass +class ItemWriteConflictError(Exception): + pass + + class InsufficientSpecificationError(Exception): pass +class OverSpecificationError(Exception): + pass class InvalidLocationError(Exception): pass @@ -21,3 +27,12 @@ class NoPathToItem(Exception): class DuplicateItemError(Exception): pass + +class VersionConflictError(Exception): + """ + The caller asked for either draft or published head and gave a version which conflicted with it. + """ + def __init__(self, requestedLocation, currentHead): + super(VersionConflictError, self).__init__() + self.requestedLocation = requestedLocation + self.currentHead = currentHead diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py index a816aa9776..1314c72094 100644 --- a/common/lib/xmodule/xmodule/modulestore/inheritance.py +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -50,6 +50,8 @@ def inherit_metadata(descriptor, model_data): def own_metadata(module): + # IN SPLIT MONGO this is just ['metadata'] as it keeps ['_inherited_metadata'] separate! + # FIXME move into kvs? will that work for xml mongo? """ Return a dictionary that contains only non-inherited field keys, mapped to their values diff --git a/common/lib/xmodule/xmodule/modulestore/locator.py b/common/lib/xmodule/xmodule/modulestore/locator.py new file mode 100644 index 0000000000..928bc9f133 --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/locator.py @@ -0,0 +1,465 @@ +""" +Created on Mar 13, 2013 + +@author: dmitchell +""" +from __future__ import absolute_import +import logging +import inspect +from abc import ABCMeta, abstractmethod +from urllib import quote + +from bson.objectid import ObjectId +from bson.errors import InvalidId + +from xmodule.modulestore.exceptions import InsufficientSpecificationError, OverSpecificationError + +from .parsers import parse_url, parse_course_id, parse_block_ref + +log = logging.getLogger(__name__) + + +class Locator(object): + """ + A locator is like a URL, it refers to a course resource. + + Locator is an abstract base class: do not instantiate + """ + + __metaclass__ = ABCMeta + + @abstractmethod + def url(self): + """ + Return a string containing the URL for this location. Raises + InsufficientSpecificationError if the instance doesn't have a + complete enough specification to generate a url + """ + raise InsufficientSpecificationError() + + def quoted_url(self): + return quote(self.url(), '@;#') + + def __eq__(self, other): + return self.__dict__ == other.__dict__ + + def __repr__(self): + ''' + repr(self) returns something like this: CourseLocator("edu.mit.eecs.6002x") + ''' + classname = self.__class__.__name__ + if classname.find('.') != -1: + classname = classname.split['.'][-1] + return '%s("%s")' % (classname, unicode(self)) + + def __str__(self): + ''' + str(self) returns something like this: "edu.mit.eecs.6002x" + ''' + return unicode(self).encode('utf8') + + def __unicode__(self): + ''' + unicode(self) returns something like this: "edu.mit.eecs.6002x" + ''' + return self.url() + + @abstractmethod + def version(self): + """ + Returns the ObjectId referencing this specific location. + Raises InsufficientSpecificationError if the instance + doesn't have a complete enough specification. + """ + raise InsufficientSpecificationError() + + def set_property(self, property_name, new): + """ + Initialize property to new value. + If property has already been initialized to a different value, raise an exception. + """ + current = getattr(self, property_name) + if current and current != new: + raise OverSpecificationError('%s cannot be both %s and %s' % + (property_name, current, new)) + setattr(self, property_name, new) + + +class CourseLocator(Locator): + """ + Examples of valid CourseLocator specifications: + CourseLocator(version_guid=ObjectId('519665f6223ebd6980884f2b')) + CourseLocator(course_id='edu.mit.eecs.6002x') + CourseLocator(course_id='edu.mit.eecs.6002x;published') + CourseLocator(course_id='edu.mit.eecs.6002x', revision='published') + CourseLocator(url='edx://@519665f6223ebd6980884f2b') + CourseLocator(url='edx://edu.mit.eecs.6002x') + CourseLocator(url='edx://edu.mit.eecs.6002x;published') + + Should have at lease a specific course_id (id for the course as if it were a project w/ + versions) with optional 'revision' (must be 'draft', 'published', or None), + or version_guid (which points to a specific version). Can contain both in which case + the persistence layer may raise exceptions if the given version != the current such version + of the course. + """ + + # Default values + version_guid = None + course_id = None + revision = None + + def __unicode__(self): + """ + Return a string representing this location. + """ + if self.course_id: + result = self.course_id + if self.revision: + result += ';' + self.revision + return result + elif self.version_guid: + return '@' + str(self.version_guid) + else: + # raise InsufficientSpecificationError("missing course_id or version_guid") + return '' + + def url(self): + """ + Return a string containing the URL for this location. + """ + return 'edx://' + unicode(self) + + # -- unused args which are used via inspect + # pylint: disable= W0613 + def validate_args(self, url, version_guid, course_id, revision): + """ + Validate provided arguments. + """ + need_oneof = set(('url', 'version_guid', 'course_id')) + args, _, _, values = inspect.getargvalues(inspect.currentframe()) + provided_args = [a for a in args if a != 'self' and values[a] is not None] + if len(need_oneof.intersection(provided_args)) == 0: + raise InsufficientSpecificationError("Must provide one of these args: %s " % + list(need_oneof)) + + def is_fully_specified(self): + """ + Returns True if either version_guid is specified, or course_id+revision + are specified. + This should always return True, since this should be validated in the constructor. + """ + return self.version_guid is not None \ + or (self.course_id is not None and self.revision is not None) + + def set_course_id(self, new): + """ + Initialize course_id to new value. + If course_id has already been initialized to a different value, raise an exception. + """ + self.set_property('course_id', new) + + def set_revision(self, new): + """ + Initialize revision to new value. + If revision has already been initialized to a different value, raise an exception. + """ + self.set_property('revision', new) + + def set_version_guid(self, new): + """ + Initialize version_guid to new value. + If version_guid has already been initialized to a different value, raise an exception. + """ + self.set_property('version_guid', new) + + def as_course_locator(self): + """ + Returns a copy of itself (downcasting) as a CourseLocator. + The copy has the same CourseLocator fields as the original. + The copy does not include subclass information, such as + a usage_id (a property of BlockUsageLocator). + """ + return CourseLocator(course_id=self.course_id, + version_guid=self.version_guid, + revision=self.revision) + + def __init__(self, url=None, version_guid=None, course_id=None, revision=None): + """ + Construct a CourseLocator + Caller may provide url (but no other parameters). + Caller may provide version_guid (but no other parameters). + Caller may provide course_id (optionally provide revision). + + Resulting CourseLocator will have either a version_guid property + or a course_id (with optional revision) property, or both. + + version_guid must be an instance of bson.objectid.ObjectId or None + url, course_id, and revision must be strings or None + + """ + self.validate_args(url, version_guid, course_id, revision) + if url: + self.init_from_url(url) + if version_guid: + self.init_from_version_guid(version_guid) + if course_id or revision: + self.init_from_course_id(course_id, revision) + assert self.version_guid or self.course_id, \ + "Either version_guid or course_id should be set." + + @classmethod + def as_object_id(cls, value): + """ + Attempts to cast value as a bson.objectid.ObjectId. + If cast fails, raises ValueError + """ + if isinstance(value, ObjectId): + return value + try: + return ObjectId(value) + except InvalidId: + raise ValueError('"%s" is not a valid version_guid' % value) + + def init_from_url(self, url): + """ + url must be a string beginning with 'edx://' and containing + either a valid version_guid or course_id (with optional revision) + If a block ('#HW3') is present, it is ignored. + """ + if isinstance(url, Locator): + url = url.url() + assert isinstance(url, basestring), \ + '%s is not an instance of basestring' % url + parse = parse_url(url) + assert parse, 'Could not parse "%s" as a url' % url + if 'version_guid' in parse: + new_guid = parse['version_guid'] + self.set_version_guid(self.as_object_id(new_guid)) + else: + self.set_course_id(parse['id']) + self.set_revision(parse['revision']) + + def init_from_version_guid(self, version_guid): + """ + version_guid must be an instance of bson.objectid.ObjectId, + or able to be cast as one. + If it's a string, attempt to cast it as an ObjectId first. + """ + version_guid = self.as_object_id(version_guid) + + assert isinstance(version_guid, ObjectId), \ + '%s is not an instance of ObjectId' % version_guid + self.set_version_guid(version_guid) + + def init_from_course_id(self, course_id, explicit_revision=None): + """ + Course_id is a string like 'edu.mit.eecs.6002x' or 'edu.mit.eecs.6002x;published'. + + Revision (optional) is a string like 'published'. + It may be provided explicitly (explicit_revision) or embedded into course_id. + If revision is part of course_id ("...;published"), parse it out separately. + If revision is provided both ways, that's ok as long as they are the same value. + + If a block ('#HW3') is a part of course_id, it is ignored. + + """ + + if course_id: + if isinstance(course_id, CourseLocator): + course_id = course_id.course_id + assert course_id, "%s does not have a valid course_id" + + parse = parse_course_id(course_id) + assert parse, 'Could not parse "%s" as a course_id' % course_id + self.set_course_id(parse['id']) + rev = parse['revision'] + if rev: + self.set_revision(rev) + if explicit_revision: + self.set_revision(explicit_revision) + + def version(self): + """ + Returns the ObjectId referencing this specific location. + """ + return self.version_guid + + def html_id(self): + """ + Generate a discussion group id based on course + + To make compatible with old Location object functionality. I don't believe this behavior fits at this + place, but I have no way to override. If this is really needed, it should probably use the pretty_id to seed + the name although that's mutable. We should also clearly define the purpose and restrictions of this + (e.g., I'm assuming periods are fine). + """ + return self.course_id + + +class BlockUsageLocator(CourseLocator): + """ + Encodes a location. + + Locations address modules (aka blocks) which are definitions situated in a + course instance. Thus, a Location must identify the course and the occurrence of + the defined element in the course. Courses can be a version of an offering, the + current draft head, or the current production version. + + Locators can contain both a version and a course_id w/ revision. The split mongo functions + may raise errors if these conflict w/ the current db state (i.e., the course's revision != + the version_guid) + + Locations can express as urls as well as dictionaries. They consist of + course_identifier: course_guid | version_guid + block : guid + revision : 'draft' | 'published' (optional) + """ + + # Default value + usage_id = None + + def __init__(self, url=None, version_guid=None, course_id=None, + revision=None, usage_id=None): + """ + Construct a BlockUsageLocator + Caller may provide url, version_guid, or course_id, and optionally provide revision. + + The usage_id may be specified, either explictly or as part of + the url or course_id. If omitted, the locator is created but it + has not yet been initialized. + + Resulting BlockUsageLocator will have a usage_id property. + It will have either a version_guid property or a course_id (with optional revision) property, or both. + + version_guid must be an instance of bson.objectid.ObjectId or None + url, course_id, revision, and usage_id must be strings or None + + """ + self.validate_args(url, version_guid, course_id, revision) + if url: + self.init_block_ref_from_url(url) + if course_id: + self.init_block_ref_from_course_id(course_id) + if usage_id: + self.init_block_ref(usage_id) + CourseLocator.__init__(self, + url=url, + version_guid=version_guid, + course_id=course_id, + revision=revision) + + def is_initialized(self): + """ + Returns True if usage_id has been initialized, else returns False + """ + return self.usage_id is not None + + def version_agnostic(self): + """ + Returns a copy of itself. + If both version_guid and course_id are known, use a blank course_id in the copy. + + We don't care if the locator's version is not the current head; so, avoid version conflict + by reducing info. + + :param block_locator: + """ + if self.course_id and self.version_guid: + return BlockUsageLocator(version_guid=self.version_guid, + revision=self.revision, + usage_id=self.usage_id) + else: + return BlockUsageLocator(course_id=self.course_id, + revision=self.revision, + usage_id=self.usage_id) + + def set_usage_id(self, new): + """ + Initialize usage_id to new value. + If usage_id has already been initialized to a different value, raise an exception. + """ + self.set_property('usage_id', new) + + def init_block_ref(self, block_ref): + parse = parse_block_ref(block_ref) + assert parse, 'Could not parse "%s" as a block_ref' % block_ref + self.set_usage_id(parse['block']) + + def init_block_ref_from_url(self, url): + if isinstance(url, Locator): + url = url.url() + parse = parse_url(url) + assert parse, 'Could not parse "%s" as a url' % url + block = parse.get('block', None) + if block: + self.set_usage_id(block) + + def init_block_ref_from_course_id(self, course_id): + if isinstance(course_id, CourseLocator): + course_id = course_id.course_id + assert course_id, "%s does not have a valid course_id" + parse = parse_course_id(course_id) + assert parse, 'Could not parse "%s" as a course_id' % course_id + block = parse.get('block', None) + if block: + self.set_usage_id(block) + + def __unicode__(self): + """ + Return a string representing this location. + """ + rep = CourseLocator.__unicode__(self) + if self.usage_id is None: + # usage_id has not been initialized + return rep + '#NONE' + else: + return rep + '#' + self.usage_id + + +class DescriptionLocator(Locator): + """ + Container for how to locate a description + """ + + def __init__(self, definition_id): + self.definition_id = definition_id + + def __unicode__(self): + ''' + Return a string representing this location. + unicode(self) returns something like this: "@519665f6223ebd6980884f2b" + ''' + return '@' + str(self.definition_guid) + + def url(self): + """ + Return a string containing the URL for this location. + url(self) returns something like this: 'edx://@519665f6223ebd6980884f2b' + """ + return 'edx://' + unicode(self) + + def version(self): + """ + Returns the ObjectId referencing this specific location. + """ + return self.definition_guid + + +class VersionTree(object): + """ + Holds trees of Locators to represent version histories. + """ + def __init__(self, locator, tree_dict=None): + """ + :param locator: must be version specific (Course has version_guid or definition had id) + """ + assert isinstance(locator, Locator) and not inspect.isabstract(locator), \ + "locator must be a concrete subclass of Locator" + assert locator.version(), \ + "locator must be version specific (Course has version_guid or definition had id)" + self.locator = locator + if tree_dict is None: + self.children = [] + else: + self.children = [VersionTree(child, tree_dict) + for child in tree_dict.get(locator.version(), [])] diff --git a/common/lib/xmodule/xmodule/modulestore/parsers.py b/common/lib/xmodule/xmodule/modulestore/parsers.py new file mode 100644 index 0000000000..e79abb5ebc --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/parsers.py @@ -0,0 +1,111 @@ +import re + +URL_RE = re.compile(r'^edx://(.+)$', re.IGNORECASE) + +def parse_url(string): + """ + A url must begin with 'edx://' (case-insensitive match), + followed by either a version_guid or a course_id. + + Examples: + 'edx://@0123FFFF' + 'edx://edu.mit.eecs.6002x' + 'edx://edu.mit.eecs.6002x;published' + 'edx://edu.mit.eecs.6002x;published#HW3' + + This returns None if string cannot be parsed. + + If it can be parsed as a version_guid, returns a dict + with key 'version_guid' and the value, + + If it can be parsed as a course_id, returns a dict + with keys 'id' and 'revision' (value of 'revision' may be None), + + """ + match = URL_RE.match(string) + if not match: + return None + path = match.group(1) + if path[0] == '@': + return parse_guid(path[1:]) + return parse_course_id(path) + + +BLOCK_RE = re.compile(r'^\w+$', re.IGNORECASE) + +def parse_block_ref(string): + """ + A block_ref is a string of word_chars. + + matches one or more Unicode word characters; this includes most + characters that can be part of a word in any language, as well as numbers + and the underscore. (see definition of \w in python regular expressions, + at http://docs.python.org/dev/library/re.html) + + If string is a block_ref, returns a dict with key 'block_ref' and the value, + otherwise returns None. + """ + if len(string) > 0 and BLOCK_RE.match(string): + return {'block' : string} + return None + + +GUID_RE = re.compile(r'^(?P[A-F0-9]+)(#(?P\w+))?$', re.IGNORECASE) + +def parse_guid(string): + """ + A version_guid is a string of hex digits (0-F). + + If string is a version_guid, returns a dict with key 'version_guid' and the value, + otherwise returns None. + """ + m = GUID_RE.match(string) + if m is not None: + return m.groupdict() + else: + return None + + +COURSE_ID_RE = re.compile(r'^(?P(\w+)(\.\w+\w*)*)(;(?P\w+))?(#(?P\w+))?$', re.IGNORECASE) + +def parse_course_id(string): + """ + + A course_id has a main id component. + There may also be an optional revision (;published or ;draft). + There may also be an optional block (#HW3 or #Quiz2). + + Examples of valid course_ids: + + 'edu.mit.eecs.6002x' + 'edu.mit.eecs.6002x;published' + 'edu.mit.eecs.6002x#HW3' + 'edu.mit.eecs.6002x;published#HW3' + + + Syntax: + + course_id = main_id [; revision] [# block] + + main_id = name [. name]* + + revision = name + + block = name + + name = + + matches one or more Unicode word characters; this includes most + characters that can be part of a word in any language, as well as numbers + and the underscore. (see definition of \w in python regular expressions, + at http://docs.python.org/dev/library/re.html) + + If string is a course_id, returns a dict with keys 'id', 'revision', and 'block'. + Revision is optional: if missing returned_dict['revision'] is None. + Block is optional: if missing returned_dict['block'] is None. + Else returns None. + """ + match = COURSE_ID_RE.match(string) + if not match: + return None + return match.groupdict() diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/__init__.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/__init__.py new file mode 100644 index 0000000000..789973bd33 --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/__init__.py @@ -0,0 +1 @@ +from split import SplitMongoModuleStore \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py new file mode 100644 index 0000000000..1591757490 --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py @@ -0,0 +1,119 @@ +import sys +import logging +from xmodule.mako_module import MakoDescriptorSystem +from xmodule.x_module import XModuleDescriptor +from xmodule.modulestore.locator import BlockUsageLocator +from xmodule.error_module import ErrorDescriptor +from xmodule.errortracker import exc_info_to_str +from xblock.runtime import DbModel +from ..exceptions import ItemNotFoundError +from .split_mongo_kvs import SplitMongoKVS, SplitMongoKVSid + +log = logging.getLogger(__name__) + +# TODO should this be here or w/ x_module or ??? +class CachingDescriptorSystem(MakoDescriptorSystem): + """ + A system that has a cache of a course version's json that it will use to load modules + from, with a backup of calling to the underlying modulestore for more data. + + Computes the metadata inheritance upon creation. + """ + def __init__(self, modulestore, course_entry, module_data, lazy, + default_class, error_tracker, render_template): + """ + Computes the metadata inheritance and sets up the cache. + + modulestore: the module store that can be used to retrieve additional + modules + + module_data: a dict mapping Location -> json that was cached from the + underlying modulestore + + default_class: The default_class to use when loading an + XModuleDescriptor from the module_data + + resources_fs: a filesystem, as per MakoDescriptorSystem + + error_tracker: a function that logs errors for later display to users + + render_template: a function for rendering templates, as per + MakoDescriptorSystem + """ + # TODO find all references to resources_fs and make handle None + super(CachingDescriptorSystem, self).__init__( + self._load_item, None, error_tracker, render_template) + self.modulestore = modulestore + self.course_entry = course_entry + self.lazy = lazy + self.module_data = module_data + self.default_class = default_class + # TODO see if self.course_id is needed: is already in course_entry but could be > 1 value + # Compute inheritance + modulestore.inherit_metadata(course_entry.get('blocks', {}), + course_entry.get('blocks', {}) + .get(course_entry.get('root'))) + + def _load_item(self, usage_id, course_entry_override=None): + # TODO ensure all callers of system.load_item pass just the id + json_data = self.module_data.get(usage_id) + if json_data is None: + # deeper than initial descendant fetch or doesn't exist + self.modulestore.cache_items(self, [usage_id], lazy=self.lazy) + json_data = self.module_data.get(usage_id) + if json_data is None: + raise ItemNotFoundError + + class_ = XModuleDescriptor.load_class( + json_data.get('category'), + self.default_class + ) + return self.xblock_from_json(class_, usage_id, json_data, course_entry_override) + + def xblock_from_json(self, class_, usage_id, json_data, course_entry_override=None): + if course_entry_override is None: + course_entry_override = self.course_entry + # most likely a lazy loader but not the id directly + definition = json_data.get('definition', {}) + metadata = json_data.get('metadata', {}) + + block_locator = BlockUsageLocator( + version_guid=course_entry_override['_id'], + usage_id=usage_id, + course_id=course_entry_override.get('course_id'), + revision=course_entry_override.get('revision') + ) + + kvs = SplitMongoKVS( + definition, + json_data.get('children', []), + metadata, + json_data.get('_inherited_metadata'), + block_locator, + json_data.get('category')) + model_data = DbModel(kvs, class_, None, + SplitMongoKVSid( + # DbModel req's that these support .url() + block_locator, + self.modulestore.definition_locator(definition))) + + try: + module = class_(self, model_data) + except Exception: + log.warning("Failed to load descriptor", exc_info=True) + if usage_id is None: + usage_id = "MISSING" + return ErrorDescriptor.from_json( + json_data, + self, + BlockUsageLocator(version_guid=course_entry_override['_id'], + usage_id=usage_id), + error_msg=exc_info_to_str(sys.exc_info()) + ) + + module.edited_by = json_data.get('edited_by') + module.edited_on = json_data.get('edited_on') + module.previous_version = json_data.get('previous_version') + module.update_version = json_data.get('update_version') + module.definition_locator = self.modulestore.definition_locator(definition) + return module diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/definition_lazy_loader.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/definition_lazy_loader.py new file mode 100644 index 0000000000..94270822a1 --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/definition_lazy_loader.py @@ -0,0 +1,25 @@ +from xmodule.modulestore.locator import DescriptionLocator + +class DefinitionLazyLoader(object): + """ + A placeholder to put into an xblock in place of its definition which + when accessed knows how to get its content. Only useful if the containing + object doesn't force access during init but waits until client wants the + definition. Only works if the modulestore is a split mongo store. + """ + def __init__(self, modulestore, definition_id): + """ + Simple placeholder for yet-to-be-fetched data + :param modulestore: the pymongo db connection with the definitions + :param definition_locator: the id of the record in the above to fetch + """ + self.modulestore = modulestore + self.definition_locator = DescriptionLocator(definition_id) + + def fetch(self): + """ + Fetch the definition. Note, the caller should replace this lazy + loader pointer with the result so as not to fetch more than once + """ + return self.modulestore.definitions.find_one( + {'_id': self.definition_locator.definition_id}) diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py new file mode 100644 index 0000000000..6dd6fb480f --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py @@ -0,0 +1,1240 @@ +import threading +import datetime +import logging +import pymongo +import re +from importlib import import_module +from path import path + +from xmodule.errortracker import null_error_tracker +from xmodule.x_module import XModuleDescriptor +from xmodule.modulestore.locator import BlockUsageLocator, DescriptionLocator, CourseLocator, VersionTree +from xmodule.modulestore.exceptions import InsufficientSpecificationError, VersionConflictError +from xmodule.modulestore import inheritance + +from .. import ModuleStoreBase +from ..exceptions import ItemNotFoundError +from .definition_lazy_loader import DefinitionLazyLoader +from .caching_descriptor_system import CachingDescriptorSystem + +log = logging.getLogger(__name__) +#============================================================================== +# Documentation is at +# https://edx-wiki.atlassian.net/wiki/display/ENG/Mongostore+Data+Structure +# +# Known issue: +# Inheritance for cached kvs doesn't work on edits. Use case. +# 1) attribute foo is inheritable +# 2) g.children = [p], p.children = [a] +# 3) g.foo = 1 on load +# 4) if g.foo > 0, if p.foo > 0, if a.foo > 0 all eval True +# 5) p.foo = -1 +# 6) g.foo > 0, p.foo <= 0 all eval True BUT +# 7) BUG: a.foo > 0 still evals True but should be False +# 8) reread and everything works right +# 9) p.del(foo), p.foo > 0 is True! works +# 10) BUG: a.foo < 0! +# Local fix wont' permanently work b/c xblock may cache a.foo... +# +#============================================================================== + + +class SplitMongoModuleStore(ModuleStoreBase): + """ + A Mongodb backed ModuleStore supporting versions, inheritance, + and sharing. + """ + def __init__(self, host, db, collection, fs_root, render_template, + port=27017, default_class=None, + error_tracker=null_error_tracker, + user=None, password=None, + **kwargs): + + ModuleStoreBase.__init__(self) + + self.db = pymongo.database.Database(pymongo.MongoClient( + host=host, + port=port, + tz_aware=True, + **kwargs + ), db) + + # TODO add caching of structures to thread_cache to prevent repeated fetches (but not index b/c + # it changes w/o having a change in id) + self.course_index = self.db[collection + '.active_versions'] + self.structures = self.db[collection + '.structures'] + self.definitions = self.db[collection + '.definitions'] + + # ??? Code review question: those familiar w/ python threading. Should I instead + # use django cache? How should I expire entries? + # _add_cache could use a lru mechanism to control the cache size? + self.thread_cache = threading.local() + + if user is not None and password is not None: + self.db.authenticate(user, password) + + # every app has write access to the db (v having a flag to indicate r/o v write) + # Force mongo to report errors, at the expense of performance + # pymongo docs suck but explanation: + # http://api.mongodb.org/java/2.10.1/com/mongodb/WriteConcern.html + self.course_index.write_concern = {'w': 1} + self.structures.write_concern = {'w': 1} + self.definitions.write_concern = {'w': 1} + + if default_class is not None: + module_path, _, class_name = default_class.rpartition('.') + class_ = getattr(import_module(module_path), class_name) + self.default_class = class_ + else: + self.default_class = None + self.fs_root = path(fs_root) + self.error_tracker = error_tracker + self.render_template = render_template + + def cache_items(self, system, base_usage_ids, depth=0, lazy=True): + ''' + Handles caching of items once inheritance and any other one time + per course per fetch operations are done. + :param system: a CachingDescriptorSystem + :param base_usage_ids: list of usage_ids to fetch + :param depth: how deep below these to prefetch + :param lazy: whether to fetch definitions or use placeholders + ''' + new_module_data = {} + for usage_id in base_usage_ids: + new_module_data = self.descendants(system.course_entry['blocks'], + usage_id, + depth, + new_module_data) + + # remove any which were already in module_data (not sure if there's a better way) + for newkey in new_module_data.iterkeys(): + if newkey in system.module_data: + del new_module_data[newkey] + + if lazy: + for block in new_module_data.itervalues(): + block['definition'] = DefinitionLazyLoader(self, + block['definition']) + else: + # Load all descendants by id + descendent_definitions = self.definitions.find({ + '_id': {'$in': [block['definition'] + for block in new_module_data.itervalues()]}}) + # turn into a map + definitions = {definition['_id']: definition + for definition in descendent_definitions} + + for block in new_module_data.itervalues(): + if block['definition'] in definitions: + block['definition'] = definitions[block['definition']] + + system.module_data.update(new_module_data) + return system.module_data + + def _load_items(self, course_entry, usage_ids, depth=0, lazy=True): + ''' + Load & cache the given blocks from the course. Prefetch down to the + given depth. Load the definitions into each block if lazy is False; + otherwise, use the lazy definition placeholder. + ''' + system = self._get_cache(course_entry['_id']) + if system is None: + system = CachingDescriptorSystem( + self, + course_entry, + {}, + lazy, + self.default_class, + self.error_tracker, + self.render_template + ) + self._add_cache(course_entry['_id'], system) + self.cache_items(system, usage_ids, depth, lazy) + return [system.load_item(usage_id, course_entry) for usage_id in usage_ids] + + def _get_cache(self, course_version_guid): + """ + Find the descriptor cache for this course if it exists + :param course_version_guid: + """ + if not hasattr(self.thread_cache, 'course_cache'): + self.thread_cache.course_cache = {} + system = self.thread_cache.course_cache + return system.get(course_version_guid) + + def _add_cache(self, course_version_guid, system): + """ + Save this cache for subsequent access + :param course_version_guid: + :param system: + """ + if not hasattr(self.thread_cache, 'course_cache'): + self.thread_cache.course_cache = {} + self.thread_cache.course_cache[course_version_guid] = system + return system + + def _clear_cache(self): + """ + Should only be used by testing or something which implements transactional boundary semantics + """ + self.thread_cache.course_cache = {} + + def _lookup_course(self, course_locator): + ''' + Decode the locator into the right series of db access. Does not + return the CourseDescriptor! It returns the actual db json from + structures. + + Semantics: if course_id and revision given, then it will get that revision. If + also give a version_guid, it will see if the current head of that revision == that guid. If not + it raises VersionConflictError (the version now differs from what it was when you got your + reference) + + :param course_locator: any subclass of CourseLocator + ''' + # NOTE: if and when this uses cache, the update if changed logic will break if the cache + # holds the same objects as the descriptors! + if not course_locator.is_fully_specified(): + raise InsufficientSpecificationError('Not fully specified: %s' % course_locator) + + if course_locator.course_id is not None and course_locator.revision is not None: + # use the course_id + index = self.course_index.find_one({'_id': course_locator.course_id}) + if index is None: + raise ItemNotFoundError(course_locator) + if course_locator.revision not in index['versions']: + raise ItemNotFoundError(course_locator) + version_guid = index['versions'][course_locator.revision] + if course_locator.version_guid is not None and version_guid != course_locator.version_guid: + # This may be a bit too touchy but it's hard to infer intent + raise VersionConflictError(course_locator, CourseLocator(course_locator, version_guid=version_guid)) + else: + # TODO should this raise an exception if revision was provided? + version_guid = course_locator.version_guid + + # cast string to ObjectId if necessary + version_guid = course_locator.as_object_id(version_guid) + entry = self.structures.find_one({'_id': version_guid}) + + # b/c more than one course can use same structure, the 'course_id' is not intrinsic to structure + # and the one assoc'd w/ it by another fetch may not be the one relevant to this fetch; so, + # fake it by explicitly setting it in the in memory structure. + + if course_locator.course_id: + entry['course_id'] = course_locator.course_id + entry['revision'] = course_locator.revision + return entry + + def get_courses(self, revision, qualifiers=None): + ''' + Returns a list of course descriptors matching any given qualifiers. + + qualifiers should be a dict of keywords matching the db fields or any + legal query for mongo to use against the active_versions collection. + + Note, this is to find the current head of the named revision type + (e.g., 'draft'). To get specific versions via guid use get_course. + ''' + if qualifiers is None: + qualifiers = {} + qualifiers.update({"versions.{}".format(revision): {"$exists": True}}) + matching = self.course_index.find(qualifiers) + + # collect ids and then query for those + version_guids = [] + id_version_map = {} + for course_entry in matching: + version_guid = course_entry['versions'][revision] + version_guids.append(version_guid) + id_version_map[version_guid] = course_entry['_id'] + + course_entries = self.structures.find({'_id': {'$in': version_guids}}) + + # get the block for the course element (s/b the root) + result = [] + for entry in course_entries: + # structures are course agnostic but the caller wants to know course, so add it in here + entry['course_id'] = id_version_map[entry['_id']] + root = entry['root'] + result.extend(self._load_items(entry, [root], 0, lazy=True)) + return result + + def get_course(self, course_locator): + ''' + Gets the course descriptor for the course identified by the locator + which may or may not be a blockLocator. + + raises InsufficientSpecificationError + ''' + course_entry = self._lookup_course(course_locator) + root = course_entry['root'] + result = self._load_items(course_entry, [root], 0, lazy=True) + return result[0] + + def get_course_for_item(self, location): + ''' + Provided for backward compatibility. Is equivalent to calling get_course + :param location: + ''' + return self.get_course(location) + + def has_item(self, block_location): + """ + Returns True if location exists in its course. Returns false if + the course or the block w/in the course do not exist for the given version. + raises InsufficientSpecificationError if the locator does not id a block + """ + if block_location.usage_id is None: + raise InsufficientSpecificationError(block_location) + try: + course_structure = self._lookup_course(block_location) + except ItemNotFoundError: + # this error only occurs if the course does not exist + return False + + return course_structure['blocks'].get(block_location.usage_id) is not None + + def get_item(self, location, depth=0): + """ + depth (int): An argument that some module stores may use to prefetch + descendants of the queried modules for more efficient results later + in the request. The depth is counted in the number of + calls to get_children() to cache. None indicates to cache all + descendants. + raises InsufficientSpecificationError or ItemNotFoundError + """ + assert isinstance(location, BlockUsageLocator) + if not location.is_initialized(): + raise InsufficientSpecificationError("Not yet initialized: %s" % location) + course = self._lookup_course(location) + items = self._load_items(course, [location.usage_id], depth, lazy=True) + if len(items) == 0: + raise ItemNotFoundError(location) + return items[0] + + # TODO refactor this and get_courses to use a constructed query + def get_items(self, locator, qualifiers): + ''' + Get all of the modules in the given course matching the qualifiers. The + qualifiers should only be fields in the structures collection (sorry). + There will be a separate search method for searching through + definitions. + + Common qualifiers are category, definition (provide definition id), + metadata: {display_name ..}, children (return + block if its children includes the one given value). If you want + substring matching use {$regex: /acme.*corp/i} type syntax. + + Although these + look like mongo queries, it is all done in memory; so, you cannot + try arbitrary queries. + + :param locator: CourseLocator or BlockUsageLocator restricting search scope + :param qualifiers: a dict restricting which elements should match + ''' + # TODO extend to only search a subdag of the course? + course = self._lookup_course(locator) + items = [] + for usage_id, value in course['blocks'].iteritems(): + if self._block_matches(value, qualifiers): + items.append(usage_id) + + if len(items) > 0: + return self._load_items(course, items, 0, lazy=True) + else: + return [] + + # What's the use case for usage_id being separate? + def get_parent_locations(self, locator, usage_id=None): + ''' + Return the locations (Locators w/ usage_ids) for the parents of this location in this + course. Could use get_items(location, {'children': usage_id}) but this is slightly faster. + NOTE: does not actually ensure usage_id exists + If usage_id is None, then the locator must specify the usage_id + ''' + if usage_id is None: + usage_id = locator.usage_id + course = self._lookup_course(locator) + items = [] + for parent_id, value in course['blocks'].iteritems(): + for child_id in value['children']: + if usage_id == child_id: + locator = locator.as_course_locator() + items.append(BlockUsageLocator(url=locator, usage_id=parent_id)) + return items + + def get_course_index_info(self, course_locator): + """ + The index records the initial creation of the indexed course and tracks the current version + heads. This function is primarily for test verification but may serve some + more general purpose. + :param course_locator: must have a course_id set + :return {'org': , 'prettyid': , + versions: {'draft': the head draft version id, + 'published': the head published version id if any, + }, + 'edited_by': who created the course originally (named edited for consistency), + 'edited_on': when the course was originally created + } + """ + if course_locator.course_id is None: + return None + index = self.course_index.find_one({'_id': course_locator.course_id}) + return index + + # TODO figure out a way to make this info accessible from the course descriptor + def get_course_history_info(self, course_locator): + """ + Because xblocks doesn't give a means to separate the course structure's meta information from + the course xblock's, this method will get that info for the structure as a whole. + :param course_locator: + :return {'original_version': the version guid of the original version of this course, + 'previous_version': the version guid of the previous version, + 'edited_by': who made the last change, + 'edited_on': when the change was made + } + """ + course = self._lookup_course(course_locator) + return {'original_version': course['original_version'], + 'previous_version': course['previous_version'], + 'edited_by': course['edited_by'], + 'edited_on': course['edited_on'] + } + + def get_definition_history_info(self, definition_locator): + """ + Because xblocks doesn't give a means to separate the definition's meta information from + the usage xblock's, this method will get that info for the definition + :return {'original_version': the version guid of the original version of this course, + 'previous_version': the version guid of the previous version, + 'edited_by': who made the last change, + 'edited_on': when the change was made + } + """ + definition = self.definitions.find_one({'_id': definition_locator.definition_id}) + if definition is None: + return None + return {'original_version': definition['original_version'], + 'previous_version': definition['previous_version'], + 'edited_by': definition['edited_by'], + 'edited_on': definition['edited_on'] + } + + def get_course_successors(self, course_locator, version_history_depth=1): + ''' + Find the version_history_depth next versions of this course. Return as a VersionTree + Mostly makes sense when course_locator uses a version_guid, but because it finds all relevant + next versions, these do include those created for other courses. + :param course_locator: + ''' + if version_history_depth < 1: + return None + if course_locator.version_guid is None: + course = self._lookup_course(course_locator) + version_guid = course.version_guid + else: + version_guid = course_locator.version_guid + + # TODO if depth is significant, it may make sense to get all that have the same original_version + # and reconstruct the subtree from version_guid + next_entries = self.structures.find({'previous_version' : version_guid}) + # must only scan cursor's once + next_versions = [struct for struct in next_entries] + result = {version_guid: [CourseLocator(version_guid=struct['_id']) for struct in next_versions]} + depth = 1 + while depth < version_history_depth and len(next_versions) > 0: + depth += 1 + next_entries = self.structures.find({'previous_version': + {'$in': [struct['_id'] for struct in next_versions]}}) + next_versions = [struct for struct in next_entries] + for course_structure in next_versions: + result.setdefault(course_structure['previous_version'], []).append( + CourseLocator(version_guid=struct['_id'])) + return VersionTree(CourseLocator(course_locator, version_guid=version_guid), result) + + + def get_block_generations(self, block_locator): + ''' + Find the history of this block. Return as a VersionTree of each place the block changed (except + deletion). + + The block's history tracks its explicit changes; so, changes in descendants won't be reflected + as new iterations. + ''' + block_locator = block_locator.version_agnostic() + course_struct = self._lookup_course(block_locator) + usage_id = block_locator.usage_id + update_version_field = 'blocks.{}.update_version'.format(usage_id) + all_versions_with_block = self.structures.find({'original_version': course_struct['original_version'], + update_version_field: {'$exists': True}}) + # find (all) root versions and build map previous: [successors] + possible_roots = [] + result = {} + for version in all_versions_with_block: + if version['_id'] == version['blocks'][usage_id]['update_version']: + if version['blocks'][usage_id].get('previous_version') is None: + possible_roots.append(version['blocks'][usage_id]['update_version']) + else: + result.setdefault(version['blocks'][usage_id]['previous_version'], set()).add( + version['blocks'][usage_id]['update_version']) + # more than one possible_root means usage was added and deleted > 1x. + if len(possible_roots) > 1: + # find the history segment including block_locator's version + element_to_find = course_struct['blocks'][usage_id]['update_version'] + if element_to_find in possible_roots: + possible_roots = [element_to_find] + for possibility in possible_roots: + if self._find_local_root(element_to_find, possibility, result): + possible_roots = [possibility] + break + elif len(possible_roots) == 0: + return None + # convert the results value sets to locators + for k, versions in result.iteritems(): + result[k] = [BlockUsageLocator(version_guid=version, usage_id=usage_id) + for version in versions] + return VersionTree(BlockUsageLocator(version_guid=possible_roots[0], usage_id=usage_id), result) + + def get_definition_successors(self, definition_locator, version_history_depth=1): + ''' + Find the version_history_depth next versions of this definition. Return as a VersionTree + ''' + # TODO implement + pass + + def create_definition_from_data(self, new_def_data, category, user_id): + """ + Pull the definition fields out of descriptor and save to the db as a new definition + w/o a predecessor and return the new id. + + :param user_id: request.user object + """ + document = {"category" : category, + "data": new_def_data, + "edited_by": user_id, + "edited_on": datetime.datetime.utcnow(), + "previous_version": None, + "original_version": None} + new_id = self.definitions.insert(document) + definition_locator = DescriptionLocator(new_id) + document['original_version'] = new_id + self.definitions.update({'_id': new_id}, {'$set': {"original_version": new_id}}) + return definition_locator + + def update_definition_from_data(self, definition_locator, new_def_data, user_id): + """ + See if new_def_data differs from the persisted version. If so, update + the persisted version and return the new id. + + :param user_id: request.user + """ + def needs_saved(): + if isinstance(new_def_data, dict): + for key, value in new_def_data.iteritems(): + if key not in old_definition['data'] or value != old_definition['data'][key]: + return True + for key, value in old_definition['data'].iteritems(): + if key not in new_def_data: + return True + else: + return new_def_data != old_definition['data'] + + # if this looks in cache rather than fresh fetches, then it will probably not detect + # actual change b/c the descriptor and cache probably point to the same objects + old_definition = self.definitions.find_one({'_id': definition_locator.definition_id}) + if old_definition is None: + raise ItemNotFoundError(definition_locator.url()) + del old_definition['_id'] + + if needs_saved(): + old_definition['data'] = new_def_data + old_definition['edited_by'] = user_id + old_definition['edited_on'] = datetime.datetime.utcnow() + old_definition['previous_version'] = definition_locator.definition_id + new_id = self.definitions.insert(old_definition) + return DescriptionLocator(new_id), True + else: + return definition_locator, False + + def _generate_usage_id(self, course_blocks, category): + """ + Generate a somewhat readable block id unique w/in this course using the category + :param course_blocks: the current list of blocks. + :param category: + """ + # NOTE: a potential bug is that a block is deleted and another created which gets the old + # block's id. a possible fix is to cache the last serial in a dict in the structure + # {category: last_serial...} + # A potential confusion is if the name incorporates the parent's name, then if the child + # moves, its id won't change and will be confusing + serial = 1 + while category + str(serial) in course_blocks: + serial += 1 + return category + str(serial) + + def _generate_course_id(self, id_root): + """ + Generate a somewhat readable course id unique w/in this db using the id_root + :param course_blocks: the current list of blocks. + :param category: + """ + existing_uses = self.course_index.find({"_id": {"$regex": id_root}}) + if existing_uses.count() > 0: + max_found = 0 + matcher = re.compile(id_root + r'(\d+)') + for entry in existing_uses: + serial = re.search(matcher, entry['_id']) + if serial is not None and serial.groups > 0: + value = int(serial.group(1)) + if value > max_found: + max_found = value + return id_root + str(max_found + 1) + else: + return id_root + + # TODO I would love to write this to take a real descriptor and persist it BUT descriptors, kvs, and dbmodel + # all assume locators are set and unique! Having this take the model contents piecemeal breaks the separation + # of model from persistence layer + def create_item(self, course_or_parent_locator, category, user_id, definition_locator=None, new_def_data=None, + metadata=None, force=False): + """ + Add a descriptor to persistence as the last child of the optional parent_location or just as an element + of the course (if no parent provided). Return the resulting post saved version with populated locators. + + If the locator is a BlockUsageLocator, then it's assumed to be the parent. If it's a CourseLocator, then it's + merely the containing course. + + raises InsufficientSpecificationError if there is no course locator. + raises VersionConflictError if course_id and version_guid given and the current version head != version_guid + and force is not True. + force: fork the structure and don't update the course draftVersion if the above + + The incoming definition_locator should either be None to indicate this is a brand new definition or + a pointer to the existing definition to which this block should point or from which this was derived. + If new_def_data is None, then definition_locator must have a value meaning that this block points + to the existing definition. If new_def_data is not None and definition_location is not None, then + new_def_data is assumed to be a new payload for definition_location. + + Creates a new version of the course structure, creates and inserts the new block, makes the block point + to the definition which may be new or a new version of an existing or an existing. + Rules for course locator: + + * If the course locator specifies a course_id and either it doesn't + specify version_guid or the one it specifies == the current draft, it progresses the course to point + to the new draft and sets the active version to point to the new draft + * If the locator has a course_id but its version_guid != current draft, it raises VersionConflictError. + + NOTE: using a version_guid will end up creating a new version of the course. Your new item won't be in + the course id'd by version_guid but instead in one w/ a new version_guid. Ensure in this case that you get + the new version_guid from the locator in the returned object! + """ + # find course_index entry if applicable and structures entry + index_entry = self._get_index_if_valid(course_or_parent_locator, force) + structure = self._lookup_course(course_or_parent_locator) + + # persist the definition if persisted != passed + if (definition_locator is None or definition_locator.definition_id is None): + definition_locator = self.create_definition_from_data(new_def_data, category, user_id) + elif new_def_data is not None: + definition_locator, _ = self.update_definition_from_data(definition_locator, new_def_data, user_id) + + # copy the structure and modify the new one + new_structure = self._version_structure(structure, user_id) + # generate an id + new_usage_id = self._generate_usage_id(new_structure['blocks'], category) + update_version_keys = ['blocks.{}.update_version'.format(new_usage_id)] + if isinstance(course_or_parent_locator, BlockUsageLocator) and course_or_parent_locator.usage_id is not None: + parent = new_structure['blocks'][course_or_parent_locator.usage_id] + parent['children'].append(new_usage_id) + parent['edited_on'] = datetime.datetime.utcnow() + parent['edited_by'] = user_id + parent['previous_version'] = parent['update_version'] + update_version_keys.append('blocks.{}.update_version'.format(course_or_parent_locator.usage_id)) + new_structure['blocks'][new_usage_id] = { + "children": [], + "category": category, + "definition": definition_locator.definition_id, + "metadata": metadata if metadata else {}, + 'edited_on': datetime.datetime.utcnow(), + 'edited_by': user_id, + 'previous_version': None + } + new_id = self.structures.insert(new_structure) + update_version_payload = {key: new_id for key in update_version_keys} + self.structures.update({'_id': new_id}, + {'$set': update_version_payload}) + + # update the index entry if appropriate + if index_entry is not None: + self._update_head(index_entry, course_or_parent_locator.revision, new_id) + course_parent = course_or_parent_locator.as_course_locator() + else: + course_parent = None + + # fetch and return the new item--fetching is unnecessary but a good qc step + return self.get_item(BlockUsageLocator(course_id=course_parent, + usage_id=new_usage_id, + version_guid=new_id)) + + def create_course(self, org, prettyid, user_id, id_root=None, metadata=None, course_data=None, + master_version='draft', versions_dict=None, root_category='course'): + """ + Create a new entry in the active courses index which points to an existing or new structure. Returns + the course root of the resulting entry (the location has the course id) + + id_root: allows the caller to specify the course_id. It's a root in that, if it's already taken, + this method will append things to the root to make it unique. (defaults to org) + + metadata: if provided, will set the metadata of the root course object in the new draft course. If both + metadata and a starting version are provided, it will generate a successor version to the given version, + and update the metadata with any provided values (via update not setting). + + course_data: if provided, will update the data of the new course xblock definition to this. Like metadata, + if provided, this will cause a new version of any given version as well as a new version of the + definition (which will point to the existing one if given a version). If not provided and given + a draft_version, it will reuse the same definition as the draft course (obvious since it's reusing the draft + course). If not provided and no draft is given, it will be empty and get the field defaults (hopefully) when + loaded. + + master_version: the tag (key) for the version name in the dict which is the 'draft' version. Not the actual + version guid, but what to call it. + + versions_dict: the starting version ids where the keys are the tags such as 'draft' and 'published' + and the values are structure guids. If provided, the new course will reuse this version (unless you also + provide any overrides such as metadata, see above). if not provided, will create a mostly empty course + structure with just a category course root xblock. + """ + if metadata is None: + metadata = {} + # build from inside out: definition, structure, index entry + # if building a wholly new structure + if versions_dict is None or master_version not in versions_dict: + # create new definition and structure + if course_data is None: + course_data = {} + definition_entry = { + 'category': root_category, + 'data': course_data, + 'edited_by': user_id, + 'edited_on': datetime.datetime.utcnow(), + 'previous_version': None, + } + definition_id = self.definitions.insert(definition_entry) + definition_entry['original_version'] = definition_id + self.definitions.update({'_id': definition_id}, {'$set': {"original_version": definition_id}}) + + draft_structure = { + 'root': 'course', + 'previous_version': None, + 'edited_by': user_id, + 'edited_on': datetime.datetime.utcnow(), + 'blocks': { + 'course': { + 'children':[], + 'category': 'course', + 'definition': definition_id, + 'metadata': metadata, + 'edited_on': datetime.datetime.utcnow(), + 'edited_by': user_id, + 'previous_version': None}}} + new_id = self.structures.insert(draft_structure) + draft_structure['original_version'] = new_id + self.structures.update({'_id': new_id}, + {'$set': {"original_version": new_id, + 'blocks.course.update_version': new_id}}) + if versions_dict is None: + versions_dict = {master_version: new_id} + else: + versions_dict[master_version] = new_id + + else: + # just get the draft_version structure + draft_version = CourseLocator(version_guid=versions_dict[master_version]) + draft_structure = self._lookup_course(draft_version) + if course_data is not None or metadata: + draft_structure = self._version_structure(draft_structure, user_id) + root_block = draft_structure['blocks'][draft_structure['root']] + if metadata is not None: + root_block['metadata'].update(metadata) + if course_data is not None: + definition = self.definitions.find_one({'_id': root_block['definition']}) + definition['data'].update(course_data) + definition['previous_version'] = definition['_id'] + definition['edited_by'] = user_id + definition['edited_on'] = datetime.datetime.utcnow() + del definition['_id'] + root_block['definition'] = self.definitions.insert(definition) + root_block['edited_on'] = datetime.datetime.utcnow() + root_block['edited_by'] = user_id + root_block['previous_version'] = root_block.get('update_version') + # insert updates the '_id' in draft_structure + new_id = self.structures.insert(draft_structure) + versions_dict[master_version] = new_id + self.structures.update({'_id': new_id}, + {'$set': {'blocks.{}.update_version'.format(draft_structure['root']): new_id}}) + # create the index entry + if id_root is None: + id_root = org + new_id = self._generate_course_id(id_root) + + index_entry = { + '_id': new_id, + 'org': org, + 'prettyid': prettyid, + 'edited_by': user_id, + 'edited_on': datetime.datetime.utcnow(), + 'versions': versions_dict} + new_id = self.course_index.insert(index_entry) + return self.get_course(CourseLocator(course_id=new_id, revision=master_version)) + + def update_item(self, descriptor, user_id, force=False): + """ + Save the descriptor's definition, metadata, & children references (i.e., it doesn't descend the tree). + Return the new descriptor (updated location). + + raises ItemNotFoundError if the location does not exist. + + Creates a new course version. If the descriptor's location has a course_id, it moves the course head + pointer. If the version_guid of the descriptor points to a non-head version and there's been an intervening + change to this item, it raises a VersionConflictError unless force is True. In the force case, it forks + the course but leaves the head pointer where it is (this change will not be in the course head). + + The implementation tries to detect which, if any changes, actually need to be saved and thus won't version + the definition, structure, nor course if they didn't change. + """ + original_structure = self._lookup_course(descriptor.location) + index_entry = self._get_index_if_valid(descriptor.location, force) + + descriptor.definition_locator, is_updated = self.update_definition_from_data( + descriptor.definition_locator, descriptor.xblock_kvs.get_data(), user_id) + # check children + original_entry = original_structure['blocks'][descriptor.location.usage_id] + if (not is_updated and descriptor.has_children + and not self._xblock_lists_equal(original_entry['children'], descriptor.children)): + is_updated = True + # check metadata + if not is_updated: + is_updated = self._compare_metadata(descriptor.xblock_kvs.get_own_metadata(), original_entry['metadata']) + + # if updated, rev the structure + if is_updated: + new_structure = self._version_structure(original_structure, user_id) + block_data = new_structure['blocks'][descriptor.location.usage_id] + if descriptor.has_children: + block_data["children"] = [self._usage_id(child) for child in descriptor.children] + + block_data["definition"] = descriptor.definition_locator.definition_id + block_data["metadata"] = descriptor.xblock_kvs.get_own_metadata() + block_data['edited_on'] = datetime.datetime.utcnow() + block_data['edited_by'] = user_id + block_data['previous_version'] = block_data['update_version'] + new_id = self.structures.insert(new_structure) + self.structures.update({'_id': new_id}, + {'$set': {'blocks.{}.update_version'.format(descriptor.location.usage_id): new_id}}) + + # update the index entry if appropriate + if index_entry is not None: + self._update_head(index_entry, descriptor.location.revision, new_id) + + # fetch and return the new item--fetching is unnecessary but a good qc step + return self.get_item(BlockUsageLocator(descriptor.location, version_guid=new_id)) + else: + # nothing changed, just return the one sent in + return descriptor + + def persist_xblock_dag(self, xblock, user_id, force=False): + """ + create or update the xblock and all of its children. The xblock's location must specify a course. + If it doesn't specify a usage_id, then it's presumed to be new and need creation. This function + descends the children performing the same operation for any that are xblocks. Any children which + are usage_ids just update the children pointer. + + All updates go into the same course version (bulk updater). + + Updates the objects which came in w/ updated location and definition_location info. + + returns the post-persisted version of the incoming xblock. Note that its children will be ids not + objects. + + :param xblock: + :param user_id: + """ + # find course_index entry if applicable and structures entry + index_entry = self._get_index_if_valid(xblock.location, force) + structure = self._lookup_course(xblock.location) + new_structure = self._version_structure(structure, user_id) + + changed_blocks = self._persist_subdag(xblock, user_id, new_structure['blocks']) + + if changed_blocks: + new_id = self.structures.insert(new_structure) + update_command = {} + for usage_id in changed_blocks: + update_command['blocks.{}.update_version'.format(usage_id)] = new_id + self.structures.update({'_id': new_id}, {'$set': update_command}) + + # update the index entry if appropriate + if index_entry is not None: + self._update_head(index_entry, xblock.location.revision, new_id) + + # fetch and return the new item--fetching is unnecessary but a good qc step + return self.get_item(BlockUsageLocator(xblock.location, version_guid=new_id)) + else: + return xblock + + def _persist_subdag(self, xblock, user_id, structure_blocks): + # persist the definition if persisted != passed + new_def_data = xblock.xblock_kvs.get_data() + if (xblock.definition_locator is None or xblock.definition_locator.definition_id is None): + xblock.definition_locator = self.create_definition_from_data(new_def_data, + xblock.category, user_id) + is_updated = True + elif new_def_data is not None: + xblock.definition_locator, is_updated = self.update_definition_from_data(xblock.definition_locator, + new_def_data, user_id) + + if xblock.location.usage_id is None: + # generate an id + is_new = True + is_updated = True + usage_id = self._generate_usage_id(structure_blocks, xblock.category) + xblock.location.usage_id = usage_id + else: + is_new = False + usage_id = xblock.location.usage_id + if (not is_updated and xblock.has_children + and not self._xblock_lists_equal(structure_blocks[usage_id]['children'], xblock.children)): + is_updated = True + + children = [] + updated_blocks = [] + if xblock.has_children: + for child in xblock.children: + if isinstance(child, XModuleDescriptor): + updated_blocks += self._persist_subdag(child, user_id, structure_blocks) + children.append(child.location.usage_id) + else: + children.append(child) + + is_updated = is_updated or updated_blocks + metadata = xblock.xblock_kvs.get_own_metadata() + if not is_new and not is_updated: + is_updated = self._compare_metadata(metadata, structure_blocks[usage_id]['metadata']) + + if is_updated: + structure_blocks[usage_id] = { + "children": children, + "category": xblock.category, + "definition": xblock.definition_locator.definition_id, + "metadata": metadata if metadata else {}, + 'previous_version': structure_blocks.get(usage_id, {}).get('update_version'), + 'edited_by': user_id, + 'edited_on': datetime.datetime.utcnow() + } + updated_blocks.append(usage_id) + + return updated_blocks + + def _compare_metadata(self, metadata, original_metadata): + original_keys = original_metadata.keys() + if len(metadata) != len(original_keys): + return True + else: + new_keys = metadata.keys() + for key in original_keys: + if key not in new_keys or original_metadata[key] != metadata[key]: + return True + + # TODO change all callers to update_item + def update_children(self, course_id, location, children): + raise NotImplementedError() + + # TODO change all callers to update_item + def update_metadata(self, course_id, location, metadata): + raise NotImplementedError() + + def update_course_index(self, course_locator, new_values_dict, update_versions=False): + """ + Change the given course's index entry for the given fields. new_values_dict + should be a subset of the dict returned by get_course_index_info. + It cannot include '_id' (will raise IllegalArgument). + Provide update_versions=True if you intend this to replace the versions hash. + Note, this operation can be dangerous and break running courses. + + If the dict includes versions and not update_versions, it will raise an exception. + + If the dict includes edited_on or edited_by, it will raise an exception + + Does not return anything useful. + """ + # TODO how should this log the change? edited_on and edited_by for this entry + # has the semantic of who created the course and when; so, changing those will lose + # that information. + if '_id' in new_values_dict: + raise ValueError("Cannot override _id") + if 'edited_on' in new_values_dict or 'edited_by' in new_values_dict: + raise ValueError("Cannot set edited_on or edited_by") + if not update_versions and 'versions' in new_values_dict: + raise ValueError("Cannot override versions without setting update_versions") + self.course_index.update({'_id': course_locator.course_id}, + {'$set': new_values_dict}) + + def delete_item(self, usage_locator, user_id, force=False): + """ + Delete the tree rooted at block and any references w/in the course to the block + from a new version of the course structure. + + returns CourseLocator for new version + + raises ItemNotFoundError if the location does not exist. + raises ValueError if usage_locator points to the structure root + + Creates a new course version. If the descriptor's location has a course_id, it moves the course head + pointer. If the version_guid of the descriptor points to a non-head version and there's been an intervening + change to this item, it raises a VersionConflictError unless force is True. In the force case, it forks + the course but leaves the head pointer where it is (this change will not be in the course head). + """ + assert isinstance(usage_locator, BlockUsageLocator) and usage_locator.is_initialized() + original_structure = self._lookup_course(usage_locator) + if original_structure['root'] == usage_locator.usage_id: + raise ValueError("Cannot delete the root of a course") + index_entry = self._get_index_if_valid(usage_locator, force) + new_structure = self._version_structure(original_structure, user_id) + new_blocks = new_structure['blocks'] + parents = self.get_parent_locations(usage_locator) + update_version_keys = [] + for parent in parents: + parent_block = new_blocks[parent.usage_id] + parent_block['children'].remove(usage_locator.usage_id) + parent_block['edited_on'] = datetime.datetime.utcnow() + parent_block['edited_by'] = user_id + parent_block['previous_version'] = parent_block['update_version'] + update_version_keys.append('blocks.{}.update_version'.format(parent.usage_id)) + # remove subtree + def remove_subtree(usage_id): + for child in new_blocks[usage_id]['children']: + remove_subtree(child) + del new_blocks[usage_id] + remove_subtree(usage_locator.usage_id) + + # update index if appropriate and structures + new_id = self.structures.insert(new_structure) + if update_version_keys: + update_version_payload = {key: new_id for key in update_version_keys} + self.structures.update({'_id': new_id}, {'$set': update_version_payload}) + + result = CourseLocator(version_guid=new_id) + + # update the index entry if appropriate + if index_entry is not None: + self._update_head(index_entry, usage_locator.revision, new_id) + result.course_id = usage_locator.course_id + result.revision = usage_locator.revision + + return result + + def delete_course(self, course_id): + """ + Remove the given course from the course index. + + Only removes the course from the index. The data remains. You can use create_course + with a versions hash to restore the course; however, the edited_on and + edited_by won't reflect the originals, of course. + + :param course_id: uses course_id rather than locator to emphasize its global effect + """ + index = self.course_index.find_one({'_id': course_id}) + if index is None: + raise ItemNotFoundError(course_id) + # this is the only real delete in the system. should it do something else? + self.course_index.remove(index['_id']) + + # TODO remove all callers and then this + def get_errored_courses(self): + """ + This function doesn't make sense for the mongo modulestore, as structures + are loaded on demand, rather than up front + """ + return {} + + def inherit_metadata(self, block_map, block, inheriting_metadata=None): + """ + Updates block with any value + that exist in inheriting_metadata and don't appear in block['metadata'], + and then inherits block['metadata'] to all of the children in + block['children']. Filters by inheritance.INHERITABLE_METADATA + """ + if block is None: + return + + if inheriting_metadata is None: + inheriting_metadata = {} + + # the currently passed down values take precedence over any previously cached ones + # NOTE: this should show the values which all fields would have if inherited: i.e., + # not set to the locally defined value but to value set by nearest ancestor who sets it + block.setdefault('_inherited_metadata', {}).update(inheriting_metadata) + + # update the inheriting w/ what should pass to children + inheriting_metadata = block['_inherited_metadata'].copy() + for field in inheritance.INHERITABLE_METADATA: + if field in block['metadata']: + inheriting_metadata[field] = block['metadata'][field] + + for child in block.get('children', []): + self.inherit_metadata(block_map, block_map[child], inheriting_metadata) + + def descendants(self, block_map, usage_id, depth, descendent_map): + """ + adds block and its descendants out to depth to descendent_map + Depth specifies the number of levels of descendants to return + (0 => this usage only, 1 => this usage and its children, etc...) + A depth of None returns all descendants + """ + if usage_id not in block_map: + return descendent_map + + if usage_id not in descendent_map: + descendent_map[usage_id] = block_map[usage_id] + + if depth is None or depth > 0: + depth = depth - 1 if depth is not None else None + for child in block_map[usage_id].get('children', []): + descendent_map = self.descendants(block_map, child, depth, + descendent_map) + + return descendent_map + + def definition_locator(self, definition): + ''' + Pull the id out of the definition w/ correct semantics for its + representation + ''' + if isinstance(definition, DefinitionLazyLoader): + return definition.definition_locator + elif '_id' not in definition: + return None + else: + return DescriptionLocator(definition['_id']) + + def _block_matches(self, value, qualifiers): + ''' + Return True or False depending on whether the value (block contents) + matches the qualifiers as per get_items + :param value: + :param qualifiers: + ''' + for key, criteria in qualifiers.iteritems(): + if key in value: + target = value[key] + if not self._value_matches(target, criteria): + return False + elif criteria is not None: + return False + return True + + def _value_matches(self, target, criteria): + ''' helper for _block_matches ''' + if isinstance(target, list): + return any(self._value_matches(ele, criteria) + for ele in target) + elif isinstance(criteria, dict): + if '$regex' in criteria: + return re.search(criteria['$regex'], target) is not None + elif not isinstance(target, dict): + return False + else: + return (isinstance(target, dict) and + self._block_matches(target, criteria)) + else: + return criteria == target + + def _xblock_lists_equal(self, lista, listb): + """ + Do the 2 lists refer to the same xblocks in the same order (presumes they're from the + same course) + + :param lista: + :param listb: + """ + if len(lista) != len(listb): + return False + for idx in enumerate(lista): + if lista[idx] != listb[idx]: + itema = self._usage_id(lista[idx]) + if itema != self._usage_id(listb[idx]): + return False + return True + + def _usage_id(self, xblock_or_id): + """ + arg is either an xblock or an id. If an xblock, get the usage_id from its location. Otherwise, return itself. + :param xblock_or_id: + """ + if isinstance(xblock_or_id, XModuleDescriptor): + return xblock_or_id.location.usage_id + else: + return xblock_or_id + + def _get_index_if_valid(self, locator, force=False): + """ + If the locator identifies a course and points to its draft (or plausibly its draft), + then return the index entry. + + raises VersionConflictError if not the right version + + :param locator: + """ + if locator.course_id is None or locator.revision is None: + return None + else: + index_entry = self.course_index.find_one({'_id': locator.course_id}) + if (locator.version_guid is not None + and index_entry['versions'][locator.revision] != locator.version_guid + and not force): + raise VersionConflictError( + locator, + CourseLocator( + course_id=index_entry['_id'], + version_guid=index_entry['versions'][locator.revision], + revision=locator.revision)) + else: + return index_entry + + def _version_structure(self, structure, user_id): + """ + Copy the structure and update the history info (edited_by, edited_on, previous_version) + :param structure: + :param user_id: + """ + new_structure = structure.copy() + new_structure['blocks'] = new_structure['blocks'].copy() + del new_structure['_id'] + new_structure['previous_version'] = structure['_id'] + new_structure['edited_by'] = user_id + new_structure['edited_on'] = datetime.datetime.utcnow() + return new_structure + + def _find_local_root(self, element_to_find, possibility, tree): + if possibility not in tree: + return False + if element_to_find in tree[possibility]: + return True + for subtree in tree[possibility]: + if self._find_local_root(element_to_find, subtree, tree): + return True + return False + + + def _update_head(self, index_entry, revision, new_id): + """ + Update the active index for the given course's revision to point to new_id + + :param index_entry: + :param course_locator: + :param new_id: + """ + self.course_index.update( + {"_id": index_entry["_id"]}, + {"$set": {"versions.{}".format(revision): new_id}}) diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_mongo_kvs.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_mongo_kvs.py new file mode 100644 index 0000000000..9da5008aef --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_mongo_kvs.py @@ -0,0 +1,164 @@ +import copy +from xblock.core import Scope +from collections import namedtuple +from xblock.runtime import KeyValueStore, InvalidScopeError +from .definition_lazy_loader import DefinitionLazyLoader + +# id is a BlockUsageLocator, def_id is the definition's guid +SplitMongoKVSid = namedtuple('SplitMongoKVSid', 'id, def_id') + + +# TODO should this be here or w/ x_module or ??? +class SplitMongoKVS(KeyValueStore): + """ + A KeyValueStore that maps keyed data access to one of the 3 data areas + known to the MongoModuleStore (data, children, and metadata) + """ + def __init__(self, definition, children, metadata, _inherited_metadata, location, category): + """ + + :param definition: + :param children: + :param metadata: the locally defined value for each metadata field + :param _inherited_metadata: the value of each inheritable field from above this. + Note, metadata may override and disagree w/ this b/c this says what the value + should be if metadata is undefined for this field. + """ + # ensure kvs's don't share objects w/ others so that changes can't appear in separate ones + # the particular use case was that changes to kvs's were polluting caches. My thinking was + # that kvs's should be independent thus responsible for the isolation. + if isinstance(definition, DefinitionLazyLoader): + self._definition = definition + else: + self._definition = copy.copy(definition) + self._children = copy.copy(children) + self._metadata = copy.copy(metadata) + self._inherited_metadata = _inherited_metadata + self._location = location + self._category = category + + def get(self, key): + if key.scope == Scope.children: + return self._children + elif key.scope == Scope.parent: + return None + elif key.scope == Scope.settings: + if key.field_name in self._metadata: + return self._metadata[key.field_name] + elif key.field_name in self._inherited_metadata: + return self._inherited_metadata[key.field_name] + else: + raise KeyError() + elif key.scope == Scope.content: + if key.field_name == 'location': + return self._location + elif key.field_name == 'category': + return self._category + else: + if isinstance(self._definition, DefinitionLazyLoader): + self._definition = self._definition.fetch() + if (key.field_name == 'data' and + not isinstance(self._definition.get('data'), dict)): + return self._definition.get('data') + elif 'data' not in self._definition or key.field_name not in self._definition['data']: + raise KeyError() + else: + return self._definition['data'][key.field_name] + else: + raise InvalidScopeError(key.scope) + + def set(self, key, value): + # TODO cache db update implications & add method to invoke + if key.scope == Scope.children: + self._children = value + # TODO remove inheritance from any orphaned exchildren + # TODO add inheritance to any new children + elif key.scope == Scope.settings: + # TODO if inheritable, push down to children who don't override + self._metadata[key.field_name] = value + elif key.scope == Scope.content: + if key.field_name == 'location': + self._location = value + elif key.field_name == 'category': + self._category = value + else: + if isinstance(self._definition, DefinitionLazyLoader): + self._definition = self._definition.fetch() + if (key.field_name == 'data' and + not isinstance(self._definition.get('data'), dict)): + self._definition.get('data') + else: + self._definition.setdefault('data', {})[key.field_name] = value + else: + raise InvalidScopeError(key.scope) + + def delete(self, key): + # TODO cache db update implications & add method to invoke + if key.scope == Scope.children: + self._children = [] + elif key.scope == Scope.settings: + # TODO if inheritable, ensure _inherited_metadata has value from above and + # revert children to that value + if key.field_name in self._metadata: + del self._metadata[key.field_name] + elif key.scope == Scope.content: + # don't allow deletion of location nor category + if key.field_name == 'location': + pass + elif key.field_name == 'category': + pass + else: + if isinstance(self._definition, DefinitionLazyLoader): + self._definition = self._definition.fetch() + if (key.field_name == 'data' and + not isinstance(self._definition.get('data'), dict)): + self._definition.setdefault('data', None) + else: + try: + del self._definition['data'][key.field_name] + except KeyError: + pass + else: + raise InvalidScopeError(key.scope) + + def has(self, key): + if key.scope in (Scope.children, Scope.parent): + return True + elif key.scope == Scope.settings: + return key.field_name in self._metadata or key.field_name in self._inherited_metadata + elif key.scope == Scope.content: + if key.field_name == 'location': + return True + elif key.field_name == 'category': + return self._category is not None + else: + if isinstance(self._definition, DefinitionLazyLoader): + self._definition = self._definition.fetch() + if (key.field_name == 'data' and + not isinstance(self._definition.get('data'), dict)): + return self._definition.get('data') is not None + else: + return key.field_name in self._definition.get('data', {}) + else: + return False + + def get_data(self): + """ + Intended only for use by persistence layer to get the native definition['data'] rep + """ + if isinstance(self._definition, DefinitionLazyLoader): + self._definition = self._definition.fetch() + return self._definition.get('data') + + def get_own_metadata(self): + """ + Get the metadata explicitly set on this element. + """ + return self._metadata + + def get_inherited_metadata(self): + """ + Get the metadata set by the ancestors (which own metadata may override or not) + """ + return self._inherited_metadata + diff --git a/common/lib/xmodule/xmodule/modulestore/tests/persistent_factories.py b/common/lib/xmodule/xmodule/modulestore/tests/persistent_factories.py new file mode 100644 index 0000000000..5e46f5a318 --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/tests/persistent_factories.py @@ -0,0 +1,96 @@ +from xmodule.modulestore.django import modulestore +from xmodule.course_module import CourseDescriptor +from xmodule.x_module import XModuleDescriptor +import factory + + +# [dhm] I'm not sure why we're using factory_boy if we're not following its pattern. If anyone +# assumes they can call build, it will completely fail, for example. +# pylint: disable=W0232 +class PersistentCourseFactory(factory.Factory): + """ + Create a new course (not a new version of a course, but a whole new index entry). + + keywords: + * org: defaults to textX + * prettyid: defaults to 999 + * display_name + * user_id + * data (optional) the data payload to save in the course item + * metadata (optional) the metadata payload. If display_name is in the metadata, that takes + precedence over any display_name provided directly. + """ + FACTORY_FOR = CourseDescriptor + + org = 'testX' + prettyid = '999' + display_name = 'Robot Super Course' + user_id = "test_user" + data = None + metadata = None + master_version = 'draft' + + # pylint: disable=W0613 + @classmethod + def _create(cls, target_class, *args, **kwargs): + + org = kwargs.get('org') + prettyid = kwargs.get('prettyid') + display_name = kwargs.get('display_name') + user_id = kwargs.get('user_id') + data = kwargs.get('data') + metadata = kwargs.get('metadata', {}) + if metadata is None: + metadata = {} + if 'display_name' not in metadata: + metadata['display_name'] = display_name + + # Write the data to the mongo datastore + new_course = modulestore('split').create_course( + org, prettyid, user_id, metadata=metadata, course_data=data, id_root=prettyid, + master_version=kwargs.get('master_version')) + + return new_course + + @classmethod + def _build(cls, target_class, *args, **kwargs): + raise NotImplementedError() + + +class ItemFactory(factory.Factory): + FACTORY_FOR = XModuleDescriptor + + category = 'chapter' + user_id = 'test_user' + display_name = factory.LazyAttributeSequence(lambda o, n: "{} {}".format(o.category, n)) + + # pylint: disable=W0613 + @classmethod + def _create(cls, target_class, *args, **kwargs): + """ + Uses *kwargs*: + + *parent_location* (required): the location of the course & possibly parent + + *category* (defaults to 'chapter') + + *data* (optional): the data for the item + + definition_locator (optional): the DescriptorLocator for the definition this uses or branches + + *display_name* (optional): the display name of the item + + *metadata* (optional): dictionary of metadata attributes (display_name here takes + precedence over the above attr) + """ + metadata = kwargs.get('metadata', {}) + if 'display_name' not in metadata and 'display_name' in kwargs: + metadata['display_name'] = kwargs['display_name'] + + return modulestore('split').create_item(kwargs['parent_location'], kwargs['category'], + kwargs['user_id'], definition_locator=kwargs.get('definition_locator'), + new_def_data=kwargs.get('data'), metadata=metadata) + + @classmethod + def _build(cls, target_class, *args, **kwargs): + raise NotImplementedError() diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py b/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py new file mode 100644 index 0000000000..2626b6692d --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py @@ -0,0 +1,539 @@ +''' +Created on Mar 14, 2013 + +@author: dmitchell +''' +from unittest import TestCase +from nose.plugins.skip import SkipTest + +from bson.objectid import ObjectId +from xmodule.modulestore.locator import Locator, CourseLocator, BlockUsageLocator +from xmodule.modulestore.exceptions import InvalidLocationError, \ + InsufficientSpecificationError, OverSpecificationError + + +class LocatorTest(TestCase): + + def test_cant_instantiate_abstract_class(self): + self.assertRaises(TypeError, Locator) + + def test_course_constructor_overspecified(self): + self.assertRaises( + OverSpecificationError, + CourseLocator, + url='edx://edu.mit.eecs.6002x', + course_id='edu.harvard.history', + revision='published', + version_guid=ObjectId()) + self.assertRaises( + OverSpecificationError, + CourseLocator, + url='edx://edu.mit.eecs.6002x', + course_id='edu.harvard.history', + version_guid=ObjectId()) + self.assertRaises( + OverSpecificationError, + CourseLocator, + url='edx://edu.mit.eecs.6002x;published', + revision='draft') + self.assertRaises( + OverSpecificationError, + CourseLocator, + course_id='edu.mit.eecs.6002x;published', + revision='draft') + + def test_course_constructor_underspecified(self): + self.assertRaises(InsufficientSpecificationError, CourseLocator) + self.assertRaises(InsufficientSpecificationError, CourseLocator, revision='published') + + def test_course_constructor_bad_version_guid(self): + self.assertRaises(ValueError, CourseLocator, version_guid="012345") + self.assertRaises(InsufficientSpecificationError, CourseLocator, version_guid=None) + + def test_course_constructor_version_guid(self): + # generate a random location + test_id_1 = ObjectId() + test_id_1_loc = str(test_id_1) + testobj_1 = CourseLocator(version_guid=test_id_1) + self.check_course_locn_fields(testobj_1, 'version_guid', version_guid=test_id_1) + self.assertEqual(str(testobj_1.version_guid), test_id_1_loc) + self.assertEqual(str(testobj_1), '@' + test_id_1_loc) + self.assertEqual(testobj_1.url(), 'edx://@' + test_id_1_loc) + + # Test using a given string + test_id_2_loc = '519665f6223ebd6980884f2b' + test_id_2 = ObjectId(test_id_2_loc) + testobj_2 = CourseLocator(version_guid=test_id_2) + self.check_course_locn_fields(testobj_2, 'version_guid', version_guid=test_id_2) + self.assertEqual(str(testobj_2.version_guid), test_id_2_loc) + self.assertEqual(str(testobj_2), '@' + test_id_2_loc) + self.assertEqual(testobj_2.url(), 'edx://@' + test_id_2_loc) + + def test_course_constructor_bad_course_id(self): + """ + Test all sorts of badly-formed course_ids (and urls with those course_ids) + """ + for bad_id in ('edu.mit.', + ' edu.mit.eecs', + 'edu.mit.eecs ', + '@edu.mit.eecs', + '#edu.mit.eecs', + 'edu.mit.ee cs', + 'edu.mit.ee,cs', + 'edu.mit.ee/cs', + 'edu.mit.ee$cs', + 'edu.mit.ee&cs', + 'edu.mit.ee()cs', + ';this', + 'edu.mit.eecs;', + 'edu.mit.eecs;this;that', + 'edu.mit.eecs;this;', + 'edu.mit.eecs;this ', + 'edu.mit.eecs;th%is ', + ): + self.assertRaises(AssertionError, CourseLocator, course_id=bad_id) + self.assertRaises(AssertionError, CourseLocator, url='edx://' + bad_id) + + def test_course_constructor_bad_url(self): + for bad_url in ('edx://', + 'edx:/edu.mit.eecs', + 'http://edu.mit.eecs', + 'edu.mit.eecs', + 'edx//edu.mit.eecs'): + self.assertRaises(AssertionError, CourseLocator, url=bad_url) + + def test_course_constructor_redundant_001(self): + testurn = 'edu.mit.eecs.6002x' + testobj = CourseLocator(course_id=testurn, url='edx://' + testurn) + self.check_course_locn_fields(testobj, 'course_id', course_id=testurn) + + def test_course_constructor_redundant_002(self): + testurn = 'edu.mit.eecs.6002x;published' + expected_urn = 'edu.mit.eecs.6002x' + expected_rev = 'published' + testobj = CourseLocator(course_id=testurn, url='edx://' + testurn) + self.check_course_locn_fields(testobj, 'course_id', + course_id=expected_urn, + revision=expected_rev) + + def test_course_constructor_course_id_no_revision(self): + testurn = 'edu.mit.eecs.6002x' + testobj = CourseLocator(course_id=testurn) + self.check_course_locn_fields(testobj, 'course_id', course_id=testurn) + self.assertEqual(testobj.course_id, testurn) + self.assertEqual(str(testobj), testurn) + self.assertEqual(testobj.url(), 'edx://' + testurn) + + def test_course_constructor_course_id_with_revision(self): + testurn = 'edu.mit.eecs.6002x;published' + expected_id = 'edu.mit.eecs.6002x' + expected_revision = 'published' + testobj = CourseLocator(course_id=testurn) + self.check_course_locn_fields(testobj, 'course_id with revision', + course_id=expected_id, + revision=expected_revision, + ) + self.assertEqual(testobj.course_id, expected_id) + self.assertEqual(testobj.revision, expected_revision) + self.assertEqual(str(testobj), testurn) + self.assertEqual(testobj.url(), 'edx://' + testurn) + + def test_course_constructor_course_id_separate_revision(self): + test_id = 'edu.mit.eecs.6002x' + test_revision = 'published' + expected_urn = 'edu.mit.eecs.6002x;published' + testobj = CourseLocator(course_id=test_id, revision=test_revision) + self.check_course_locn_fields(testobj, 'course_id with separate revision', + course_id=test_id, + revision=test_revision, + ) + self.assertEqual(testobj.course_id, test_id) + self.assertEqual(testobj.revision, test_revision) + self.assertEqual(str(testobj), expected_urn) + self.assertEqual(testobj.url(), 'edx://' + expected_urn) + + def test_course_constructor_course_id_repeated_revision(self): + """ + The same revision appears in the course_id and the revision field. + """ + test_id = 'edu.mit.eecs.6002x;published' + test_revision = 'published' + expected_id = 'edu.mit.eecs.6002x' + expected_urn = 'edu.mit.eecs.6002x;published' + testobj = CourseLocator(course_id=test_id, revision=test_revision) + self.check_course_locn_fields(testobj, 'course_id with repeated revision', + course_id=expected_id, + revision=test_revision, + ) + self.assertEqual(testobj.course_id, expected_id) + self.assertEqual(testobj.revision, test_revision) + self.assertEqual(str(testobj), expected_urn) + self.assertEqual(testobj.url(), 'edx://' + expected_urn) + + def test_block_constructor(self): + testurn = 'edu.mit.eecs.6002x;published#HW3' + expected_id = 'edu.mit.eecs.6002x' + expected_revision = 'published' + expected_block_ref = 'HW3' + testobj = BlockUsageLocator(course_id=testurn) + self.check_block_locn_fields(testobj, 'test_block constructor', + course_id=expected_id, + revision=expected_revision, + block=expected_block_ref) + self.assertEqual(str(testobj), testurn) + self.assertEqual(testobj.url(), 'edx://' + testurn) + + # ------------------------------------------------------------ + # Disabled tests + + def test_course_urls(self): + ''' + Test constructor and property accessors. + ''' + raise SkipTest() + self.assertRaises(TypeError, CourseLocator, 'empty constructor') + + # url inits + testurn = 'edx://org/course/category/name' + self.assertRaises(InvalidLocationError, CourseLocator, url=testurn) + testurn = 'unknown/versionid/blockid' + self.assertRaises(InvalidLocationError, CourseLocator, url=testurn) + + testurn = 'cvx/versionid' + testobj = CourseLocator(testurn) + self.check_course_locn_fields(testobj, testurn, 'versionid') + self.assertEqual(testobj, CourseLocator(testobj), + 'initialization from another instance') + + testurn = 'cvx/versionid/' + testobj = CourseLocator(testurn) + self.check_course_locn_fields(testobj, testurn, 'versionid') + + testurn = 'cvx/versionid/blockid' + testobj = CourseLocator(testurn) + self.check_course_locn_fields(testobj, testurn, 'versionid') + + testurn = 'cvx/versionid/blockid/extraneousstuff?including=args' + testobj = CourseLocator(testurn) + self.check_course_locn_fields(testobj, testurn, 'versionid') + + testurn = 'cvx://versionid/blockid' + testobj = CourseLocator(testurn) + self.check_course_locn_fields(testobj, testurn, 'versionid') + + testurn = 'crx/courseid/blockid' + testobj = CourseLocator(testurn) + self.check_course_locn_fields(testobj, testurn, course_id='courseid') + + testurn = 'crx/courseid@revision/blockid' + testobj = CourseLocator(testurn) + self.check_course_locn_fields(testobj, testurn, course_id='courseid', + revision='revision') + self.assertEqual(testobj, CourseLocator(testobj), + 'run initialization from another instance') + + def test_course_keyword_setters(self): + raise SkipTest() + # arg list inits + testobj = CourseLocator(version_guid='versionid') + self.check_course_locn_fields(testobj, 'versionid arg', 'versionid') + + testobj = CourseLocator(course_id='courseid') + self.check_course_locn_fields(testobj, 'courseid arg', + course_id='courseid') + + testobj = CourseLocator(course_id='courseid', revision='rev') + self.check_course_locn_fields(testobj, 'rev arg', + course_id='courseid', + revision='rev') + # ignores garbage + testobj = CourseLocator(course_id='courseid', revision='rev', + potato='spud') + self.check_course_locn_fields(testobj, 'extra keyword arg', + course_id='courseid', + revision='rev') + + # url w/ keyword override + testurn = 'crx/courseid@revision/blockid' + testobj = CourseLocator(testurn, revision='rev') + self.check_course_locn_fields(testobj, 'rev override', + course_id='courseid', + revision='rev') + + def test_course_dict(self): + raise SkipTest() + # dict init w/ keyword overwrites + testobj = CourseLocator({"version_guid": 'versionid'}) + self.check_course_locn_fields(testobj, 'versionid dict', 'versionid') + + testobj = CourseLocator({"course_id": 'courseid'}) + self.check_course_locn_fields(testobj, 'courseid dict', + course_id='courseid') + + testobj = CourseLocator({"course_id": 'courseid', "revision": 'rev'}) + self.check_course_locn_fields(testobj, 'rev dict', + course_id='courseid', + revision='rev') + # ignores garbage + testobj = CourseLocator({"course_id": 'courseid', "revision": 'rev', + "potato": 'spud'}) + self.check_course_locn_fields(testobj, 'extra keyword dict', + course_id='courseid', + revision='rev') + testobj = CourseLocator({"course_id": 'courseid', "revision": 'rev'}, + revision='alt') + self.check_course_locn_fields(testobj, 'rev dict', + course_id='courseid', + revision='alt') + + # urn init w/ dict & keyword overwrites + testobj = CourseLocator('crx/notcourse@notthis', + {"course_id": 'courseid'}, + revision='alt') + self.check_course_locn_fields(testobj, 'rev dict', + course_id='courseid', + revision='alt') + + def test_url(self): + ''' + Ensure CourseLocator generates expected urls. + ''' + raise SkipTest() + + testobj = CourseLocator(version_guid='versionid') + self.assertEqual(testobj.url(), 'cvx/versionid', 'versionid') + self.assertEqual(testobj, CourseLocator(testobj.url()), + 'versionid conversion through url') + + testobj = CourseLocator(course_id='courseid') + self.assertEqual(testobj.url(), 'crx/courseid', 'courseid') + self.assertEqual(testobj, CourseLocator(testobj.url()), + 'courseid conversion through url') + + testobj = CourseLocator(course_id='courseid', revision='rev') + self.assertEqual(testobj.url(), 'crx/courseid@rev', 'rev') + self.assertEqual(testobj, CourseLocator(testobj.url()), + 'rev conversion through url') + + def test_html(self): + ''' + Ensure CourseLocator generates expected urls. + ''' + raise SkipTest() + testobj = CourseLocator(version_guid='versionid') + self.assertEqual(testobj.html_id(), 'cvx/versionid', 'versionid') + self.assertEqual(testobj, CourseLocator(testobj.html_id()), + 'versionid conversion through html_id') + + testobj = CourseLocator(course_id='courseid') + self.assertEqual(testobj.html_id(), 'crx/courseid', 'courseid') + self.assertEqual(testobj, CourseLocator(testobj.html_id()), + 'courseid conversion through html_id') + + testobj = CourseLocator(course_id='courseid', revision='rev') + self.assertEqual(testobj.html_id(), 'crx/courseid%40rev', 'rev') + self.assertEqual(testobj, CourseLocator(testobj.html_id()), + 'rev conversion through html_id') + + def test_block_locator(self): + ''' + Test constructor and property accessors. + ''' + raise SkipTest() + self.assertIsInstance(BlockUsageLocator(), BlockUsageLocator, + 'empty constructor') + + # url inits + testurn = 'edx://org/course/category/name' + self.assertRaises(InvalidLocationError, BlockUsageLocator, testurn) + testurn = 'unknown/versionid/blockid' + self.assertRaises(InvalidLocationError, BlockUsageLocator, testurn) + + testurn = 'cvx/versionid' + testobj = BlockUsageLocator(testurn) + self.check_block_locn_fields(testobj, testurn, 'versionid') + self.assertEqual(testobj, BlockUsageLocator(testobj), + 'initialization from another instance') + + testurn = 'cvx/versionid/' + testobj = BlockUsageLocator(testurn) + self.check_block_locn_fields(testobj, testurn, 'versionid') + + testurn = 'cvx/versionid/blockid' + testobj = BlockUsageLocator(testurn) + self.check_block_locn_fields(testobj, testurn, 'versionid', + block='blockid') + + testurn = 'cvx/versionid/blockid/extraneousstuff?including=args' + testobj = BlockUsageLocator(testurn) + self.check_block_locn_fields(testobj, testurn, 'versionid', + block='blockid') + + testurn = 'cvx://versionid/blockid' + testobj = BlockUsageLocator(testurn) + self.check_block_locn_fields(testobj, testurn, 'versionid', + block='blockid') + + testurn = 'crx/courseid/blockid' + testobj = BlockUsageLocator(testurn) + self.check_block_locn_fields(testobj, testurn, course_id='courseid', + block='blockid') + + testurn = 'crx/courseid@revision/blockid' + testobj = BlockUsageLocator(testurn) + self.check_block_locn_fields(testobj, testurn, course_id='courseid', + revision='revision', block='blockid') + self.assertEqual(testobj, BlockUsageLocator(testobj), + 'run initialization from another instance') + + def test_block_keyword_init(self): + # arg list inits + raise SkipTest() + testobj = BlockUsageLocator(version_guid='versionid') + self.check_block_locn_fields(testobj, 'versionid arg', 'versionid') + + testobj = BlockUsageLocator(version_guid='versionid', usage_id='myblock') + self.check_block_locn_fields(testobj, 'versionid arg', 'versionid', + block='myblock') + + testobj = BlockUsageLocator(course_id='courseid') + self.check_block_locn_fields(testobj, 'courseid arg', + course_id='courseid') + + testobj = BlockUsageLocator(course_id='courseid', revision='rev') + self.check_block_locn_fields(testobj, 'rev arg', + course_id='courseid', + revision='rev') + # ignores garbage + testobj = BlockUsageLocator(course_id='courseid', revision='rev', + usage_id='this_block', potato='spud') + self.check_block_locn_fields(testobj, 'extra keyword arg', + course_id='courseid', block='this_block', revision='rev') + + # url w/ keyword override + testurn = 'crx/courseid@revision/blockid' + testobj = BlockUsageLocator(testurn, revision='rev') + self.check_block_locn_fields(testobj, 'rev override', + course_id='courseid', block='blockid', + revision='rev') + + def test_block_keywords(self): + # dict init w/ keyword overwrites + raise SkipTest() + testobj = BlockUsageLocator({"version_guid": 'versionid', + 'usage_id': 'dictblock'}) + self.check_block_locn_fields(testobj, 'versionid dict', 'versionid', + block='dictblock') + + testobj = BlockUsageLocator({"course_id": 'courseid', + 'usage_id': 'dictblock'}) + self.check_block_locn_fields(testobj, 'courseid dict', + block='dictblock', course_id='courseid') + + testobj = BlockUsageLocator({"course_id": 'courseid', "revision": 'rev', + 'usage_id': 'dictblock'}) + self.check_block_locn_fields(testobj, 'rev dict', + course_id='courseid', block='dictblock', + revision='rev') + # ignores garbage + testobj = BlockUsageLocator({"course_id": 'courseid', "revision": 'rev', + 'usage_id': 'dictblock', "potato": 'spud'}) + self.check_block_locn_fields(testobj, 'extra keyword dict', + course_id='courseid', block='dictblock', + revision='rev') + testobj = BlockUsageLocator({"course_id": 'courseid', "revision": 'rev', + 'usage_id': 'dictblock'}, revision='alt', usage_id='anotherblock') + self.check_block_locn_fields(testobj, 'rev dict', + course_id='courseid', block='anotherblock', + revision='alt') + + # urn init w/ dict & keyword overwrites + testobj = BlockUsageLocator('crx/notcourse@notthis/northis', + {"course_id": 'courseid'}, revision='alt', usage_id='anotherblock') + self.check_block_locn_fields(testobj, 'rev dict', + course_id='courseid', block='anotherblock', + revision='alt') + + def test_ensure_fully_specd(self): + ''' + Test constructor and property accessors. + ''' + raise SkipTest() + self.assertRaises(InsufficientSpecificationError, + BlockUsageLocator.ensure_fully_specified, BlockUsageLocator()) + + # url inits + testurn = 'edx://org/course/category/name' + self.assertRaises(InvalidLocationError, + BlockUsageLocator.ensure_fully_specified, testurn) + testurn = 'unknown/versionid/blockid' + self.assertRaises(InvalidLocationError, + BlockUsageLocator.ensure_fully_specified, testurn) + + testurn = 'cvx/versionid' + self.assertRaises(InsufficientSpecificationError, + BlockUsageLocator.ensure_fully_specified, testurn) + + testurn = 'cvx/versionid/' + self.assertRaises(InsufficientSpecificationError, + BlockUsageLocator.ensure_fully_specified, testurn) + + testurn = 'cvx/versionid/blockid' + self.assertIsInstance(BlockUsageLocator.ensure_fully_specified(testurn), + BlockUsageLocator, testurn) + + testurn = 'cvx/versionid/blockid/extraneousstuff?including=args' + self.assertIsInstance(BlockUsageLocator.ensure_fully_specified(testurn), + BlockUsageLocator, testurn) + + testurn = 'cvx://versionid/blockid' + self.assertIsInstance(BlockUsageLocator.ensure_fully_specified(testurn), + BlockUsageLocator, testurn) + + testurn = 'crx/courseid/blockid' + self.assertIsInstance(BlockUsageLocator.ensure_fully_specified(testurn), + BlockUsageLocator, testurn) + + testurn = 'crx/courseid@revision/blockid' + self.assertIsInstance(BlockUsageLocator.ensure_fully_specified(testurn), + BlockUsageLocator, testurn) + + def test_ensure_fully_via_keyword(self): + # arg list inits + raise SkipTest() + testobj = BlockUsageLocator(version_guid='versionid') + self.assertRaises(InsufficientSpecificationError, + BlockUsageLocator.ensure_fully_specified, testobj) + + testurn = 'crx/courseid@revision/blockid' + testobj = BlockUsageLocator(version_guid='versionid', usage_id='myblock') + self.assertIsInstance(BlockUsageLocator.ensure_fully_specified(testurn), + BlockUsageLocator, testurn) + + testobj = BlockUsageLocator(course_id='courseid') + self.assertRaises(InsufficientSpecificationError, + BlockUsageLocator.ensure_fully_specified, testobj) + + testobj = BlockUsageLocator(course_id='courseid', revision='rev') + self.assertRaises(InsufficientSpecificationError, + BlockUsageLocator.ensure_fully_specified, testobj) + + testobj = BlockUsageLocator(course_id='courseid', revision='rev', + usage_id='this_block') + self.assertIsInstance(BlockUsageLocator.ensure_fully_specified(testurn), + BlockUsageLocator, testurn) + + # ------------------------------------------------------------------ + # Utilities + + def check_course_locn_fields(self, testobj, msg, version_guid=None, + course_id=None, revision=None): + self.assertEqual(testobj.version_guid, version_guid, msg) + self.assertEqual(testobj.course_id, course_id, msg) + self.assertEqual(testobj.revision, revision, msg) + + def check_block_locn_fields(self, testobj, msg, version_guid=None, + course_id=None, revision=None, block=None): + self.check_course_locn_fields(testobj, msg, version_guid, course_id, + revision) + self.assertEqual(testobj.usage_id, block) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py new file mode 100644 index 0000000000..efaa795681 --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py @@ -0,0 +1,923 @@ +''' +Created on Mar 25, 2013 + +@author: dmitchell +''' +import datetime +import subprocess +import unittest +import uuid +from importlib import import_module + +from xblock.core import Scope +from xmodule.course_module import CourseDescriptor +from xmodule.modulestore.exceptions import InsufficientSpecificationError, ItemNotFoundError, VersionConflictError +from xmodule.modulestore.locator import CourseLocator, BlockUsageLocator, VersionTree, DescriptionLocator +from pytz import UTC +from path import path +import re + +class SplitModuleTest(unittest.TestCase): + ''' + The base set of tests manually populates a db w/ courses which have + versions. It creates unique collection names and removes them after all + tests finish. + ''' + # Snippet of what would be in the django settings envs file + modulestore_options = { + 'default_class': 'xmodule.raw_module.RawDescriptor', + 'host': 'localhost', + 'db': 'test_xmodule', + 'collection': 'modulestore{0}'.format(uuid.uuid4().hex), + 'fs_root': '', + } + + MODULESTORE = { + 'ENGINE': 'xmodule.modulestore.split_mongo.SplitMongoModuleStore', + 'OPTIONS': modulestore_options + } + + # don't create django dependency; so, duplicates common.py in envs + match = re.search(r'(.*?/common)(?:$|/)', path(__file__)) + COMMON_ROOT = match.group(1) + + modulestore = None + + # These version_guids correspond to values hard-coded in fixture files + # used for these tests. The files live in mitx/fixtures/splitmongo_json/* + + GUID_D0 = "1d00000000000000dddd0000" # v12345d + GUID_D1 = "1d00000000000000dddd1111" # v12345d1 + GUID_D2 = "1d00000000000000dddd2222" # v23456d + GUID_D3 = "1d00000000000000dddd3333" # v12345d0 + GUID_D4 = "1d00000000000000dddd4444" # v23456d0 + GUID_D5 = "1d00000000000000dddd5555" # v345679d + GUID_P = "1d00000000000000eeee0000" # v23456p + + @staticmethod + def bootstrapDB(): + ''' + Loads the initial data into the db ensuring the collection name is + unique. + ''' + collection_prefix = SplitModuleTest.MODULESTORE['OPTIONS']['collection'] + '.' + dbname = SplitModuleTest.MODULESTORE['OPTIONS']['db'] + processes = [ + subprocess.Popen(['mongoimport', '-d', dbname, '-c', + collection_prefix + collection, '--jsonArray', + '--file', + SplitModuleTest.COMMON_ROOT + '/test/data/splitmongo_json/' + collection + '.json']) + for collection in ('active_versions', 'structures', 'definitions')] + for p in processes: + if p.wait() != 0: + raise Exception("DB did not init correctly") + + @classmethod + def tearDownClass(cls): + collection_prefix = SplitModuleTest.MODULESTORE['OPTIONS']['collection'] + '.' + if SplitModuleTest.modulestore: + for collection in ('active_versions', 'structures', 'definitions'): + modulestore().db.drop_collection(collection_prefix + collection) + # drop the modulestore to force re init + SplitModuleTest.modulestore = None + + def findByIdInResult(self, collection, _id): + """ + Result is a collection of descriptors. Find the one whose block id + matches the _id. + """ + for element in collection: + if element.location.usage_id == _id: + return element + + +class SplitModuleCourseTests(SplitModuleTest): + ''' + Course CRUD operation tests + ''' + + def test_get_courses(self): + courses = modulestore().get_courses('draft') + # should have gotten 3 draft courses + self.assertEqual(len(courses), 3, "Wrong number of courses") + # check metadata -- NOTE no promised order + course = self.findByIdInResult(courses, "head12345") + self.assertEqual(course.location.course_id, "GreekHero") + self.assertEqual(str(course.location.version_guid), self.GUID_D0, + "course version mismatch") + self.assertEqual(course.category, 'course', 'wrong category') + self.assertEqual(len(course.tabs), 6, "wrong number of tabs") + self.assertEqual(course.display_name, "The Ancient Greek Hero", + "wrong display name") + self.assertEqual(course.advertised_start, "Fall 2013", + "advertised_start") + self.assertEqual(len(course.children), 3, + "children") + self.assertEqual(course.definition_locator.definition_id, "head12345_12") + # check dates and graders--forces loading of descriptor + self.assertEqual(course.edited_by, "testassist@edx.org") + self.assertEqual(str(course.previous_version), self.GUID_D1) + self.assertDictEqual(course.grade_cutoffs, {"Pass": 0.45}) + + def test_revision_requests(self): + # query w/ revision qualifier (both draft and published) + courses_published = modulestore().get_courses('published') + self.assertEqual(len(courses_published), 1, len(courses_published)) + course = self.findByIdInResult(courses_published, "head23456") + self.assertIsNotNone(course, "published courses") + self.assertEqual(course.location.course_id, "wonderful") + self.assertEqual(str(course.location.version_guid), self.GUID_P, + course.location.version_guid) + self.assertEqual(course.category, 'course', 'wrong category') + self.assertEqual(len(course.tabs), 4, "wrong number of tabs") + self.assertEqual(course.display_name, "The most wonderful course", + course.display_name) + self.assertIsNone(course.advertised_start) + self.assertEqual(len(course.children), 0, + "children") + + def test_search_qualifiers(self): + # query w/ search criteria + courses = modulestore().get_courses('draft', qualifiers={'org': 'testx'}) + self.assertEqual(len(courses), 2) + self.assertIsNotNone(self.findByIdInResult(courses, "head12345")) + self.assertIsNotNone(self.findByIdInResult(courses, "head23456")) + + courses = modulestore().get_courses( + 'draft', + qualifiers={'edited_on': {"$lt": datetime.datetime(2013, 3, 28, 15)}}) + self.assertEqual(len(courses), 2) + + courses = modulestore().get_courses( + 'draft', + qualifiers={'org': 'testx', "prettyid": "test_course"}) + self.assertEqual(len(courses), 1) + self.assertIsNotNone(self.findByIdInResult(courses, "head12345")) + + def test_get_course(self): + ''' + Test the various calling forms for get_course + ''' + locator = CourseLocator(version_guid=self.GUID_D1) + course = modulestore().get_course(locator) + self.assertIsNone(course.location.course_id) + self.assertEqual(str(course.location.version_guid), self.GUID_D1) + self.assertEqual(course.category, 'course') + self.assertEqual(len(course.tabs), 6) + self.assertEqual(course.display_name, "The Ancient Greek Hero") + self.assertIsNone(course.advertised_start) + self.assertEqual(len(course.children), 0) + self.assertEqual(course.definition_locator.definition_id, "head12345_11") + # check dates and graders--forces loading of descriptor + self.assertEqual(course.edited_by, "testassist@edx.org") + self.assertDictEqual(course.grade_cutoffs, {"Pass": 0.55}) + + locator = CourseLocator(course_id='GreekHero', revision='draft') + course = modulestore().get_course(locator) + self.assertEqual(course.location.course_id, "GreekHero") + self.assertEqual(str(course.location.version_guid), self.GUID_D0) + self.assertEqual(course.category, 'course') + self.assertEqual(len(course.tabs), 6) + self.assertEqual(course.display_name, "The Ancient Greek Hero") + self.assertEqual(course.advertised_start, "Fall 2013") + self.assertEqual(len(course.children), 3) + # check dates and graders--forces loading of descriptor + self.assertEqual(course.edited_by, "testassist@edx.org") + self.assertDictEqual(course.grade_cutoffs, {"Pass": 0.45}) + + locator = CourseLocator(course_id='wonderful', revision='published') + course = modulestore().get_course(locator) + self.assertEqual(course.location.course_id, "wonderful") + self.assertEqual(str(course.location.version_guid), self.GUID_P) + + locator = CourseLocator(course_id='wonderful', revision='draft') + course = modulestore().get_course(locator) + self.assertEqual(str(course.location.version_guid), self.GUID_D2) + + def test_get_course_negative(self): + # Now negative testing + self.assertRaises(InsufficientSpecificationError, + modulestore().get_course, CourseLocator(course_id='edu.meh.blah')) + self.assertRaises(ItemNotFoundError, + modulestore().get_course, CourseLocator(course_id='nosuchthing', revision='draft')) + self.assertRaises(ItemNotFoundError, + modulestore().get_course, + CourseLocator(course_id='GreekHero', revision='published')) + + def test_course_successors(self): + """ + get_course_successors(course_locator, version_history_depth=1) + """ + locator = CourseLocator(version_guid=self.GUID_D3) + result = modulestore().get_course_successors(locator) + self.assertIsInstance(result, VersionTree) + self.assertIsNone(result.locator.course_id) + self.assertEqual(str(result.locator.version_guid), self.GUID_D3) + self.assertEqual(len(result.children), 1) + self.assertEqual(str(result.children[0].locator.version_guid), self.GUID_D1) + self.assertEqual(len(result.children[0].children), 0, "descended more than one level") + result = modulestore().get_course_successors(locator, version_history_depth=2) + self.assertEqual(len(result.children), 1) + self.assertEqual(str(result.children[0].locator.version_guid), self.GUID_D1) + self.assertEqual(len(result.children[0].children), 1) + result = modulestore().get_course_successors(locator, version_history_depth=99) + self.assertEqual(len(result.children), 1) + self.assertEqual(str(result.children[0].locator.version_guid), self.GUID_D1) + self.assertEqual(len(result.children[0].children), 1) + + +class SplitModuleItemTests(SplitModuleTest): + ''' + Item read tests including inheritance + ''' + + def test_has_item(self): + ''' + has_item(BlockUsageLocator) + ''' + # positive tests of various forms + locator = BlockUsageLocator(version_guid=self.GUID_D1, usage_id='head12345') + self.assertTrue(modulestore().has_item(locator), + "couldn't find in %s" % self.GUID_D1) + + locator = BlockUsageLocator(course_id='GreekHero', usage_id='head12345', revision='draft') + self.assertTrue(modulestore().has_item(locator), + "couldn't find in 12345") + self.assertTrue(modulestore().has_item( + BlockUsageLocator(course_id=locator.course_id, + revision='draft', + usage_id=locator.usage_id)), + "couldn't find in draft 12345") + self.assertFalse(modulestore().has_item( + BlockUsageLocator(course_id=locator.course_id, + revision='published', + usage_id=locator.usage_id)), + "found in published 12345") + locator.revision = 'draft' + self.assertTrue(modulestore().has_item(locator), + "not found in draft 12345") + + # not a course obj + locator = BlockUsageLocator(course_id='GreekHero', usage_id='chapter1', revision='draft') + self.assertTrue(modulestore().has_item(locator), + "couldn't find chapter1") + + # in published course + locator = BlockUsageLocator(course_id="wonderful", usage_id="head23456", revision='draft') + self.assertTrue(modulestore().has_item(BlockUsageLocator(course_id=locator.course_id, + usage_id=locator.usage_id, + revision='published')), + "couldn't find in 23456") + locator.revision = 'published' + self.assertTrue(modulestore().has_item(locator), "couldn't find in 23456") + + def test_negative_has_item(self): + # negative tests--not found + # no such course or block + locator = BlockUsageLocator(course_id="doesnotexist", usage_id="head23456", revision='draft') + self.assertFalse(modulestore().has_item(locator)) + locator = BlockUsageLocator(course_id="wonderful", usage_id="doesnotexist", revision='draft') + self.assertFalse(modulestore().has_item(locator)) + + # negative tests--insufficient specification + self.assertRaises(InsufficientSpecificationError, BlockUsageLocator) + self.assertRaises(InsufficientSpecificationError, + modulestore().has_item, BlockUsageLocator(version_guid=self.GUID_D1)) + self.assertRaises(InsufficientSpecificationError, + modulestore().has_item, BlockUsageLocator(course_id='GreekHero')) + + def test_get_item(self): + ''' + get_item(blocklocator) + ''' + # positive tests of various forms + locator = BlockUsageLocator(version_guid=self.GUID_D1, usage_id='head12345') + block = modulestore().get_item(locator) + self.assertIsInstance(block, CourseDescriptor) + + locator = BlockUsageLocator(course_id='GreekHero', usage_id='head12345', revision='draft') + block = modulestore().get_item(locator) + self.assertEqual(block.location.course_id, "GreekHero") + # look at this one in detail + self.assertEqual(len(block.tabs), 6, "wrong number of tabs") + self.assertEqual(block.display_name, "The Ancient Greek Hero") + self.assertEqual(block.advertised_start, "Fall 2013") + self.assertEqual(len(block.children), 3) + self.assertEqual(block.definition_locator.definition_id, "head12345_12") + # check dates and graders--forces loading of descriptor + self.assertEqual(block.edited_by, "testassist@edx.org") + self.assertDictEqual(block.grade_cutoffs, {"Pass": 0.45}, + block.grade_cutoffs) + + # try to look up other revisions + self.assertRaises(ItemNotFoundError, + modulestore().get_item, + BlockUsageLocator(course_id=locator.as_course_locator(), + usage_id=locator.usage_id, + revision='published')) + locator.revision = 'draft' + self.assertIsInstance(modulestore().get_item(locator), + CourseDescriptor) + + def test_get_non_root(self): + # not a course obj + locator = BlockUsageLocator(course_id='GreekHero', usage_id='chapter1', revision='draft') + block = modulestore().get_item(locator) + self.assertEqual(block.location.course_id, "GreekHero") + self.assertEqual(block.category, 'chapter') + self.assertEqual(block.definition_locator.definition_id, "chapter12345_1") + self.assertEqual(block.display_name, "Hercules") + self.assertEqual(block.edited_by, "testassist@edx.org") + + # in published course + locator = BlockUsageLocator(course_id="wonderful", usage_id="head23456", revision='published') + self.assertIsInstance(modulestore().get_item(locator), + CourseDescriptor) + + # negative tests--not found + # no such course or block + locator = BlockUsageLocator(course_id="doesnotexist", usage_id="head23456", revision='draft') + self.assertRaises(ItemNotFoundError, + modulestore().get_item, locator) + locator = BlockUsageLocator(course_id="wonderful", usage_id="doesnotexist", revision='draft') + self.assertRaises(ItemNotFoundError, + modulestore().get_item, locator) + + # negative tests--insufficient specification + self.assertRaises(InsufficientSpecificationError, + modulestore().get_item, BlockUsageLocator(version_guid=self.GUID_D1)) + self.assertRaises(InsufficientSpecificationError, + modulestore().get_item, BlockUsageLocator(course_id='GreekHero', revision='draft')) + + # pylint: disable=W0212 + def test_matching(self): + ''' + test the block and value matches help functions + ''' + self.assertTrue(modulestore()._value_matches('help', 'help')) + self.assertFalse(modulestore()._value_matches('help', 'Help')) + self.assertTrue(modulestore()._value_matches(['distract', 'help', 'notme'], 'help')) + self.assertFalse(modulestore()._value_matches(['distract', 'Help', 'notme'], 'help')) + self.assertFalse(modulestore()._value_matches({'field' : ['distract', 'Help', 'notme']}, {'field' : 'help'})) + self.assertFalse(modulestore()._value_matches(['distract', 'Help', 'notme'], {'field' : 'help'})) + self.assertTrue(modulestore()._value_matches( + {'field' : ['distract', 'help', 'notme'], + 'irrelevant' : 2}, + {'field' : 'help'})) + self.assertTrue(modulestore()._value_matches('I need some help', {'$regex' : 'help'})) + self.assertTrue(modulestore()._value_matches(['I need some help', 'today'], {'$regex' : 'help'})) + self.assertFalse(modulestore()._value_matches('I need some help', {'$regex' : 'Help'})) + self.assertFalse(modulestore()._value_matches(['I need some help', 'today'], {'$regex' : 'Help'})) + + self.assertTrue(modulestore()._block_matches({'a' : 1, 'b' : 2}, {'a' : 1})) + self.assertTrue(modulestore()._block_matches({'a' : 1, 'b' : 2}, {'c' : None})) + self.assertTrue(modulestore()._block_matches({'a' : 1, 'b' : 2}, {'a' : 1, 'c' : None})) + self.assertFalse(modulestore()._block_matches({'a' : 1, 'b' : 2}, {'a' : 2})) + self.assertFalse(modulestore()._block_matches({'a' : 1, 'b' : 2}, {'c' : 1})) + self.assertFalse(modulestore()._block_matches({'a' : 1, 'b' : 2}, {'a' : 1, 'c' : 1})) + + def test_get_items(self): + ''' + get_items(locator, qualifiers, [revision]) + ''' + locator = CourseLocator(version_guid=self.GUID_D0) + # get all modules + matches = modulestore().get_items(locator, {}) + self.assertEqual(len(matches), 6) + matches = modulestore().get_items(locator, {'category' : 'chapter'}) + self.assertEqual(len(matches), 3) + matches = modulestore().get_items(locator, {'category' : 'garbage'}) + self.assertEqual(len(matches), 0) + matches = modulestore().get_items(locator, {'category' : 'chapter', + 'metadata' : {'display_name' : {'$regex' : 'Hera'}}}) + self.assertEqual(len(matches), 2) + + matches = modulestore().get_items(locator, {'children' : 'chapter2'}) + self.assertEqual(len(matches), 1) + self.assertEqual(matches[0].location.usage_id, 'head12345') + + def test_get_parents(self): + ''' + get_parent_locations(locator, [usage_id], [revision]): [BlockUsageLocator] + ''' + locator = CourseLocator(course_id="GreekHero", revision='draft') + parents = modulestore().get_parent_locations(locator, usage_id='chapter1') + self.assertEqual(len(parents), 1) + self.assertEqual(parents[0].usage_id, 'head12345') + self.assertEqual(parents[0].course_id, "GreekHero") + locator.usage_id = 'chapter2' + parents = modulestore().get_parent_locations(locator) + self.assertEqual(len(parents), 1) + self.assertEqual(parents[0].usage_id, 'head12345') + parents = modulestore().get_parent_locations(locator, usage_id='nosuchblock') + self.assertEqual(len(parents), 0) + + def test_get_children(self): + """ + Test the existing get_children method on xdescriptors + """ + locator = BlockUsageLocator(course_id="GreekHero", usage_id="head12345", revision='draft') + block = modulestore().get_item(locator) + children = block.get_children() + expected_ids = [ + "chapter1", "chapter2", "chapter3" + ] + for child in children: + self.assertEqual(child.category, "chapter") + self.assertIn(child.location.usage_id, expected_ids) + expected_ids.remove(child.location.usage_id) + self.assertEqual(len(expected_ids), 0) + + +class TestItemCrud(SplitModuleTest): + """ + Test create update and delete of items + """ + # TODO do I need to test this case which I believe won't work: + # 1) fetch a course and some of its blocks + # 2) do a series of CRUD operations on those previously fetched elements + # The problem here will be that the version_guid of the items will be the version at time of fetch. + # Each separate save will change the head version; so, the 2nd piecemeal change will flag the version + # conflict. That is, if versions are v0..vn and start as v0 in initial fetch, the first CRUD op will + # say it's changing an object from v0, splitMongo will process it and make the current head v1, the next + # crud op will pass in its v0 element and splitMongo will flag the version conflict. + # What I don't know is how realistic this test is and whether to wrap the modulestore with a higher level + # transactional operation which manages the version change or make the threading cache reason out whether or + # not the changes are independent and additive and thus non-conflicting. + # A use case I expect is + # (client) change this metadata + # (server) done, here's the new info which, btw, updates the course version to v1 + # (client) add these children to this other node (which says it came from v0 or + # will the client have refreshed the version before doing the op?) + # In this case, having a server side transactional model won't help b/c the bug is a long-transaction on the + # on the client where it would be a mistake for the server to assume anything about client consistency. The best + # the server could do would be to see if the parent's children changed at all since v0. + + def test_create_minimal_item(self): + """ + create_item(course_or_parent_locator, category, user, definition_locator=None, new_def_data=None, + metadata=None): new_desciptor + """ + # grab link to course to ensure new versioning works + locator = CourseLocator(course_id="GreekHero", revision='draft') + premod_course = modulestore().get_course(locator) + premod_time = datetime.datetime.now(UTC) + # add minimal one w/o a parent + category = 'sequential' + new_module = modulestore().create_item(locator, category, 'user123', + metadata={'display_name': 'new sequential'}) + # check that course version changed and course's previous is the other one + self.assertEqual(new_module.location.course_id, "GreekHero") + self.assertNotEqual(new_module.location.version_guid, premod_course.location.version_guid) + self.assertIsNone(locator.version_guid, "Version inadvertently filled in") + current_course = modulestore().get_course(locator) + self.assertEqual(new_module.location.version_guid, current_course.location.version_guid) + + history_info = modulestore().get_course_history_info(current_course.location) + self.assertEqual(history_info['previous_version'], premod_course.location.version_guid) + self.assertEqual(str(history_info['original_version']), self.GUID_D3) + self.assertEqual(history_info['edited_by'], "user123") + self.assertGreaterEqual(history_info['edited_on'], premod_time) + self.assertLessEqual(history_info['edited_on'], datetime.datetime.now(UTC)) + # check block's info: category, definition_locator, and display_name + self.assertEqual(new_module.category, 'sequential') + self.assertIsNotNone(new_module.definition_locator) + self.assertEqual(new_module.display_name, 'new sequential') + # check that block does not exist in previous version + locator = BlockUsageLocator(version_guid=premod_course.location.version_guid, + usage_id=new_module.location.usage_id) + self.assertRaises(ItemNotFoundError, modulestore().get_item, locator) + + def test_create_parented_item(self): + """ + Test create_item w/ specifying the parent of the new item + """ + locator = BlockUsageLocator(course_id="wonderful", usage_id="head23456", revision='draft') + premod_course = modulestore().get_course(locator) + category = 'chapter' + new_module = modulestore().create_item(locator, category, 'user123', + metadata={'display_name': 'new chapter'}, + definition_locator=DescriptionLocator("chapter12345_2")) + # check that course version changed and course's previous is the other one + self.assertNotEqual(new_module.location.version_guid, premod_course.location.version_guid) + parent = modulestore().get_item(locator) + self.assertIn(new_module.location.usage_id, parent.children) + self.assertEqual(new_module.definition_locator.definition_id, "chapter12345_2") + + def test_unique_naming(self): + """ + Check that 2 modules of same type get unique usage_ids. Also check that if creation provides + a definition id and new def data that it branches the definition in the db. + Actually, this tries to test all create_item features not tested above. + """ + locator = BlockUsageLocator(course_id="contender", usage_id="head345679", revision='draft') + category = 'problem' + premod_time = datetime.datetime.now(UTC) + new_payload = "empty" + new_module = modulestore().create_item(locator, category, 'anotheruser', + metadata={'display_name': 'problem 1'}, new_def_data=new_payload) + another_payload = "not empty" + another_module = modulestore().create_item(locator, category, 'anotheruser', + metadata={'display_name': 'problem 2'}, + definition_locator=DescriptionLocator("problem12345_3_1"), + new_def_data=another_payload) + # check that course version changed and course's previous is the other one + parent = modulestore().get_item(locator) + self.assertNotEqual(new_module.location.usage_id, another_module.location.usage_id) + self.assertIn(new_module.location.usage_id, parent.children) + self.assertIn(another_module.location.usage_id, parent.children) + self.assertEqual(new_module.data, new_payload) + self.assertEqual(another_module.data, another_payload) + # check definition histories + new_history = modulestore().get_definition_history_info(new_module.definition_locator) + self.assertIsNone(new_history['previous_version']) + self.assertEqual(new_history['original_version'], new_module.definition_locator.definition_id) + self.assertEqual(new_history['edited_by'], "anotheruser") + self.assertLessEqual(new_history['edited_on'], datetime.datetime.now(UTC)) + self.assertGreaterEqual(new_history['edited_on'], premod_time) + another_history = modulestore().get_definition_history_info(another_module.definition_locator) + self.assertEqual(another_history['previous_version'], 'problem12345_3_1') + # TODO check that default fields are set + + def test_update_metadata(self): + """ + test updating an items metadata ensuring the definition doesn't version but the course does if it should + """ + locator = BlockUsageLocator(course_id="GreekHero", usage_id="problem3_2", revision='draft') + problem = modulestore().get_item(locator) + pre_def_id = problem.definition_locator.definition_id + pre_version_guid = problem.location.version_guid + self.assertIsNotNone(pre_def_id) + self.assertIsNotNone(pre_version_guid) + premod_time = datetime.datetime.now(UTC) + self.assertNotEqual(problem.max_attempts, 4, "Invalidates rest of test") + + problem.max_attempts = 4 + updated_problem = modulestore().update_item(problem, 'changeMaven') + # check that course version changed and course's previous is the other one + self.assertEqual(updated_problem.definition_locator.definition_id, pre_def_id) + self.assertNotEqual(updated_problem.location.version_guid, pre_version_guid) + self.assertEqual(updated_problem.max_attempts, 4) + # refetch to ensure original didn't change + original_location = BlockUsageLocator(version_guid=pre_version_guid, + usage_id=problem.location.usage_id) + problem = modulestore().get_item(original_location) + self.assertNotEqual(problem.max_attempts, 4, "original changed") + + current_course = modulestore().get_course(locator) + self.assertEqual(updated_problem.location.version_guid, current_course.location.version_guid) + + history_info = modulestore().get_course_history_info(current_course.location) + self.assertEqual(history_info['previous_version'], pre_version_guid) + self.assertEqual(str(history_info['original_version']), self.GUID_D3) + self.assertEqual(history_info['edited_by'], "changeMaven") + self.assertGreaterEqual(history_info['edited_on'], premod_time) + self.assertLessEqual(history_info['edited_on'], datetime.datetime.now(UTC)) + + def test_update_children(self): + """ + test updating an item's children ensuring the definition doesn't version but the course does if it should + """ + locator = BlockUsageLocator(course_id="GreekHero", usage_id="chapter3", revision='draft') + block = modulestore().get_item(locator) + pre_def_id = block.definition_locator.definition_id + pre_version_guid = block.location.version_guid + + # reorder children + self.assertGreater(len(block.children), 0, "meaningless test") + moved_child = block.children.pop() + updated_problem = modulestore().update_item(block, 'childchanger') + # check that course version changed and course's previous is the other one + self.assertEqual(updated_problem.definition_locator.definition_id, pre_def_id) + self.assertNotEqual(updated_problem.location.version_guid, pre_version_guid) + self.assertEqual(updated_problem.children, block.children) + self.assertNotIn(moved_child, updated_problem.children) + locator.usage_id = "chapter1" + other_block = modulestore().get_item(locator) + other_block.children.append(moved_child) + other_updated = modulestore().update_item(other_block, 'childchanger') + self.assertIn(moved_child, other_updated.children) + + def test_update_definition(self): + """ + test updating an item's definition: ensure it gets versioned as well as the course getting versioned + """ + locator = BlockUsageLocator(course_id="GreekHero", usage_id="head12345", revision='draft') + block = modulestore().get_item(locator) + pre_def_id = block.definition_locator.definition_id + pre_version_guid = block.location.version_guid + + block.grading_policy['GRADER'][0]['min_count'] = 13 + updated_block = modulestore().update_item(block, 'definition_changer') + + self.assertNotEqual(updated_block.definition_locator.definition_id, pre_def_id) + self.assertNotEqual(updated_block.location.version_guid, pre_version_guid) + self.assertEqual(updated_block.grading_policy['GRADER'][0]['min_count'], 13) + + def test_update_manifold(self): + """ + Test updating metadata, children, and definition in a single call ensuring all the versioning occurs + """ + # first add 2 children to the course for the update to manipulate + locator = BlockUsageLocator(course_id="contender", usage_id="head345679", revision='draft') + category = 'problem' + new_payload = "empty" + modulestore().create_item(locator, category, 'test_update_manifold', + metadata={'display_name': 'problem 1'}, new_def_data=new_payload) + another_payload = "not empty" + modulestore().create_item(locator, category, 'test_update_manifold', + metadata={'display_name': 'problem 2'}, + definition_locator=DescriptionLocator("problem12345_3_1"), + new_def_data=another_payload) + # pylint: disable=W0212 + modulestore()._clear_cache() + + # now begin the test + block = modulestore().get_item(locator) + pre_def_id = block.definition_locator.definition_id + pre_version_guid = block.location.version_guid + + self.assertNotEqual(block.grading_policy['GRADER'][0]['min_count'], 13) + block.grading_policy['GRADER'][0]['min_count'] = 13 + block.children = block.children[1:] + [block.children[0]] + block.advertised_start = "Soon" + + updated_block = modulestore().update_item(block, "test_update_manifold") + self.assertNotEqual(updated_block.definition_locator.definition_id, pre_def_id) + self.assertNotEqual(updated_block.location.version_guid, pre_version_guid) + self.assertEqual(updated_block.grading_policy['GRADER'][0]['min_count'], 13) + self.assertEqual(updated_block.children[0], block.children[0]) + self.assertEqual(updated_block.advertised_start, "Soon") + + def test_delete_item(self): + course = self.create_course_for_deletion() + self.assertRaises(ValueError, + modulestore().delete_item, + course.location, + 'deleting_user') + reusable_location = BlockUsageLocator( + course_id=course.location.course_id, + usage_id=course.location.usage_id, + revision='draft') + + # delete a leaf + problems = modulestore().get_items(reusable_location, {'category' : 'problem'}) + locn_to_del = problems[0].location + new_course_loc = modulestore().delete_item(locn_to_del, 'deleting_user') + deleted = BlockUsageLocator(course_id=reusable_location.course_id, + revision=reusable_location.revision, + usage_id=locn_to_del.usage_id) + self.assertFalse(modulestore().has_item(deleted)) + self.assertRaises(VersionConflictError, modulestore().has_item, locn_to_del) + locator = BlockUsageLocator(version_guid=locn_to_del.version_guid, + usage_id=locn_to_del.usage_id) + self.assertTrue(modulestore().has_item(locator)) + self.assertNotEqual(new_course_loc.version_guid, course.location.version_guid) + + # delete a subtree + nodes = modulestore().get_items(reusable_location, {'category' : 'chapter'}) + new_course_loc = modulestore().delete_item(nodes[0].location, 'deleting_user') + # check subtree + def check_subtree(node): + if node: + node_loc = node.location + self.assertFalse(modulestore().has_item( + BlockUsageLocator( + course_id=node_loc.course_id, + revision=node_loc.revision, + usage_id=node.location.usage_id))) + locator = BlockUsageLocator( + version_guid=node.location.version_guid, + usage_id=node.location.usage_id) + self.assertTrue(modulestore().has_item(locator)) + if node.has_children: + for sub in node.get_children(): + check_subtree(sub) + check_subtree(nodes[0]) + + + def create_course_for_deletion(self): + course = modulestore().create_course('nihilx', 'deletion', 'deleting_user') + root = BlockUsageLocator( + course_id=course.location.course_id, + usage_id=course.location.usage_id, + revision='draft') + for _ in range(4): + self.create_subtree_for_deletion(root, ['chapter', 'vertical', 'problem']) + return modulestore().get_item(root) + + def create_subtree_for_deletion(self, parent, category_queue): + if not category_queue: + return + node = modulestore().create_item(parent, category_queue[0], 'deleting_user') + node_loc = BlockUsageLocator(parent.as_course_locator(), usage_id=node.location.usage_id) + for _ in range(4): + self.create_subtree_for_deletion(node_loc, category_queue[1:]) + +class TestCourseCreation(SplitModuleTest): + """ + Test create_course, duh :-) + """ + def test_simple_creation(self): + """ + The simplest case but probing all expected results from it. + """ + # Oddly getting differences of 200nsec + pre_time = datetime.datetime.now(UTC) - datetime.timedelta(milliseconds=1) + new_course = modulestore().create_course('test_org', 'test_course', 'create_user') + new_locator = new_course.location + # check index entry + index_info = modulestore().get_course_index_info(new_locator) + self.assertEqual(index_info['org'], 'test_org') + self.assertEqual(index_info['prettyid'], 'test_course') + self.assertGreaterEqual(index_info["edited_on"], pre_time) + self.assertLessEqual(index_info["edited_on"], datetime.datetime.now(UTC)) + self.assertEqual(index_info['edited_by'], 'create_user') + # check structure info + structure_info = modulestore().get_course_history_info(new_locator) + self.assertEqual(structure_info['original_version'], index_info['versions']['draft']) + self.assertIsNone(structure_info['previous_version']) + self.assertGreaterEqual(structure_info["edited_on"], pre_time) + self.assertLessEqual(structure_info["edited_on"], datetime.datetime.now(UTC)) + self.assertEqual(structure_info['edited_by'], 'create_user') + # check the returned course object + self.assertIsInstance(new_course, CourseDescriptor) + self.assertEqual(new_course.category, 'course') + self.assertFalse(new_course.show_calculator) + self.assertTrue(new_course.allow_anonymous) + self.assertEqual(len(new_course.children), 0) + self.assertEqual(new_course.edited_by, "create_user") + self.assertEqual(len(new_course.grading_policy['GRADER']), 4) + self.assertDictEqual(new_course.grade_cutoffs, {"Pass": 0.5}) + + def test_cloned_course(self): + """ + Test making a course which points to an existing draft and published but not making any changes to either. + """ + pre_time = datetime.datetime.now(UTC) + original_locator = CourseLocator(course_id="wonderful", revision='draft') + original_index = modulestore().get_course_index_info(original_locator) + new_draft = modulestore().create_course( + 'leech', 'best_course', 'leech_master', id_root='best', + versions_dict=original_index['versions']) + new_draft_locator = new_draft.location + self.assertRegexpMatches(new_draft_locator.course_id, r'best.*') + # the edited_by and other meta fields on the new course will be the original author not this one + self.assertEqual(new_draft.edited_by, 'test@edx.org') + self.assertLess(new_draft.edited_on, pre_time) + self.assertEqual(new_draft.location.version_guid, original_index['versions']['draft']) + # however the edited_by and other meta fields on course_index will be this one + new_index = modulestore().get_course_index_info(new_draft_locator) + self.assertGreaterEqual(new_index["edited_on"], pre_time) + self.assertLessEqual(new_index["edited_on"], datetime.datetime.now(UTC)) + self.assertEqual(new_index['edited_by'], 'leech_master') + + new_published_locator = CourseLocator(course_id=new_draft_locator.course_id, revision='published') + new_published = modulestore().get_course(new_published_locator) + self.assertEqual(new_published.edited_by, 'test@edx.org') + self.assertLess(new_published.edited_on, pre_time) + self.assertEqual(new_published.location.version_guid, original_index['versions']['published']) + + # changing this course will not change the original course + # using new_draft.location will insert the chapter under the course root + new_item = modulestore().create_item(new_draft.location, 'chapter', 'leech_master', + metadata={'display_name': 'new chapter'}) + new_draft_locator.version_guid = None + new_index = modulestore().get_course_index_info(new_draft_locator) + self.assertNotEqual(new_index['versions']['draft'], original_index['versions']['draft']) + new_draft = modulestore().get_course(new_draft_locator) + self.assertEqual(new_item.edited_by, 'leech_master') + self.assertGreaterEqual(new_item.edited_on, pre_time) + self.assertNotEqual(new_item.location.version_guid, original_index['versions']['draft']) + self.assertNotEqual(new_draft.location.version_guid, original_index['versions']['draft']) + structure_info = modulestore().get_course_history_info(new_draft_locator) + self.assertGreaterEqual(structure_info["edited_on"], pre_time) + self.assertLessEqual(structure_info["edited_on"], datetime.datetime.now(UTC)) + self.assertEqual(structure_info['edited_by'], 'leech_master') + + original_course = modulestore().get_course(original_locator) + self.assertEqual(original_course.location.version_guid, original_index['versions']['draft']) + self.assertFalse(modulestore().has_item(BlockUsageLocator(original_locator, + usage_id=new_item.location.usage_id))) + + def test_derived_course(self): + """ + Create a new course which overrides metadata and course_data + """ + pre_time = datetime.datetime.now(UTC) + original_locator = CourseLocator(course_id="contender", revision='draft') + original = modulestore().get_course(original_locator) + original_index = modulestore().get_course_index_info(original_locator) + data_payload = {} + metadata_payload = {} + for field in original.fields: + if field.scope == Scope.content and field.name != 'location': + data_payload[field.name] = getattr(original, field.name) + elif field.scope == Scope.settings: + metadata_payload[field.name] = getattr(original, field.name) + data_payload['grading_policy']['GRADE_CUTOFFS'] = {'A': .9, 'B': .8, 'C': .65} + metadata_payload['display_name'] = 'Derivative' + new_draft = modulestore().create_course('leech', 'derivative', 'leech_master', id_root='counter', + versions_dict={'draft': original_index['versions']['draft']}, + course_data=data_payload, metadata=metadata_payload) + new_draft_locator = new_draft.location + self.assertRegexpMatches(new_draft_locator.course_id, r'counter.*') + # the edited_by and other meta fields on the new course will be the original author not this one + self.assertEqual(new_draft.edited_by, 'leech_master') + self.assertGreaterEqual(new_draft.edited_on, pre_time) + self.assertNotEqual(new_draft.location.version_guid, original_index['versions']['draft']) + # however the edited_by and other meta fields on course_index will be this one + new_index = modulestore().get_course_index_info(new_draft_locator) + self.assertGreaterEqual(new_index["edited_on"], pre_time) + self.assertLessEqual(new_index["edited_on"], datetime.datetime.now(UTC)) + self.assertEqual(new_index['edited_by'], 'leech_master') + self.assertEqual(new_draft.display_name, metadata_payload['display_name']) + self.assertDictEqual(new_draft.grading_policy['GRADE_CUTOFFS'], + data_payload['grading_policy']['GRADE_CUTOFFS']) + + def test_update_course_index(self): + """ + Test changing the org, pretty id, etc of a course. Test that it doesn't allow changing the id, etc. + """ + locator = CourseLocator(course_id="GreekHero", revision='draft') + modulestore().update_course_index(locator, {'org': 'funkyU'}) + course_info = modulestore().get_course_index_info(locator) + self.assertEqual(course_info['org'], 'funkyU') + + modulestore().update_course_index(locator, {'org': 'moreFunky', 'prettyid': 'Ancient Greek Demagods'}) + course_info = modulestore().get_course_index_info(locator) + self.assertEqual(course_info['org'], 'moreFunky') + self.assertEqual(course_info['prettyid'], 'Ancient Greek Demagods') + + self.assertRaises(ValueError, modulestore().update_course_index, locator, {'_id': 'funkygreeks'}) + + self.assertRaises(ValueError, modulestore().update_course_index, locator, + {'edited_on': datetime.datetime.now(UTC)}) + self.assertRaises(ValueError, modulestore().update_course_index, locator, + {'edited_by': 'sneak'}) + + self.assertRaises(ValueError, modulestore().update_course_index, locator, + {'versions': {'draft': self.GUID_D1}}) + + # an allowed but not necessarily recommended way to revert the draft version + versions = course_info['versions'] + versions['draft'] = self.GUID_D1 + modulestore().update_course_index(locator, {'versions': versions}, update_versions=True) + course = modulestore().get_course(locator) + self.assertEqual(str(course.location.version_guid), self.GUID_D1) + + # an allowed but not recommended way to publish a course + versions['published'] = self.GUID_D1 + modulestore().update_course_index(locator, {'versions': versions}, update_versions=True) + course = modulestore().get_course(CourseLocator(course_id=locator.course_id, revision="published")) + self.assertEqual(str(course.location.version_guid), self.GUID_D1) + + +class TestInheritance(SplitModuleTest): + """ + Test the metadata inheritance mechanism. + """ + def test_inheritance(self): + """ + The actual test + """ + # Note, not testing value where defined (course) b/c there's no + # defined accessor for it on CourseDescriptor. + locator = BlockUsageLocator(course_id="GreekHero", usage_id="problem3_2", revision='draft') + node = modulestore().get_item(locator) + # inherited + self.assertEqual(node.graceperiod, datetime.timedelta(hours=2)) + locator = BlockUsageLocator(course_id="GreekHero", usage_id="problem1", revision='draft') + node = modulestore().get_item(locator) + # overridden + self.assertEqual(node.graceperiod, datetime.timedelta(hours=4)) + + # TODO test inheritance after set and delete of attrs + + +#=========================================== +# This mocks the django.modulestore() function and is intended purely to disentangle +# the tests from django +def modulestore(): + def load_function(path): + module_path, _, name = path.rpartition('.') + return getattr(import_module(module_path), name) + + if SplitModuleTest.modulestore is None: + SplitModuleTest.bootstrapDB() + class_ = load_function(SplitModuleTest.MODULESTORE['ENGINE']) + + options = {} + + options.update(SplitModuleTest.MODULESTORE['OPTIONS']) + options['render_template'] = render_to_template_mock + + # pylint: disable=W0142 + SplitModuleTest.modulestore = class_(**options) + + return SplitModuleTest.modulestore + + +# pylint: disable=W0613 +def render_to_template_mock(*args): + pass diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index aee8e26171..2155ebd2c4 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -8,9 +8,10 @@ from collections import namedtuple from pkg_resources import resource_listdir, resource_string, resource_isdir from xmodule.modulestore import inheritance, Location -from xmodule.modulestore.exceptions import ItemNotFoundError, InsufficientSpecificationError +from xmodule.modulestore.exceptions import ItemNotFoundError, InsufficientSpecificationError, InvalidLocationError from xblock.core import XBlock, Scope, String, Integer, Float, ModelType +from xmodule.modulestore.locator import BlockUsageLocator log = logging.getLogger(__name__) @@ -27,7 +28,13 @@ class LocationField(ModelType): """ Parse the json value as a Location """ - return Location(value) + try: + return Location(value) + except InvalidLocationError: + if isinstance(value, BlockUsageLocator): + return value + else: + return BlockUsageLocator(value) def to_json(self, value): """ @@ -166,6 +173,10 @@ class XModule(XModuleFields, HTMLSnippet, XBlock): self.url_name = self.location.name if not hasattr(self, 'category'): self.category = self.location.category + elif isinstance(self.location, BlockUsageLocator): + self.url_name = self.location.usage_id + if not hasattr(self, 'category'): + raise InsufficientSpecificationError() else: raise InsufficientSpecificationError() self._loaded_children = None @@ -436,8 +447,17 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): self.url_name = self.location.name if not hasattr(self, 'category'): self.category = self.location.category + elif isinstance(self.location, BlockUsageLocator): + self.url_name = self.location.usage_id + if not hasattr(self, 'category'): + raise InsufficientSpecificationError() else: raise InsufficientSpecificationError() + # update_version is the version which last updated this xblock v prev being the penultimate updater + # leaving off original_version since it complicates creation w/o any obv value yet and is computable + # by following previous until None + # definition_locator is only used by mongostores which separate definitions from blocks + self.edited_by = self.edited_on = self.previous_version = self.update_version = self.definition_locator = None self._child_instances = None @property @@ -514,22 +534,30 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): # ================================= JSON PARSING =========================== @staticmethod - def load_from_json(json_data, system, default_class=None): + def load_from_json(json_data, system, default_class=None, parent_xblock=None): """ This method instantiates the correct subclass of XModuleDescriptor based - on the contents of json_data. + on the contents of json_data. It does not persist it and can create one which + has no usage id. - json_data must contain a 'location' element, and must be suitable to be - passed into the subclasses `from_json` method as model_data + parent_xblock is used to compute inherited metadata as well as to append the new xblock. + + json_data: + - 'location' : must have this field + - 'category': the xmodule category (required or location must be a Location) + - 'metadata': a dict of locally set metadata (not inherited) + - 'children': a list of children's usage_ids w/in this course + - 'definition': + - '_id' (optional): the usage_id of this. Will generate one if not given one. """ class_ = XModuleDescriptor.load_class( - json_data['location']['category'], + json_data.get('category', json_data.get('location', {}).get('category')), default_class ) - return class_.from_json(json_data, system) + return class_.from_json(json_data, system, parent_xblock) @classmethod - def from_json(cls, json_data, system): + def from_json(cls, json_data, system, parent_xblock=None): """ Creates an instance of this descriptor from the supplied json_data. This may be overridden by subclasses @@ -547,28 +575,25 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): Otherwise, it contains the single field 'data' 4) Any value later in this list overrides a value earlier in this list - system: A DescriptorSystem for interacting with external resources + json_data: + - 'category': the xmodule category (required) + - 'metadata': a dict of locally set metadata (not inherited) + - 'children': a list of children's usage_ids w/in this course + - 'definition': + - '_id' (optional): the usage_id of this. Will generate one if not given one. """ - model_data = {} + usage_id = json_data.get('_id', None) + if not '_inherited_metadata' in json_data and parent_xblock is not None: + json_data['_inherited_metadata'] = parent_xblock.xblock_kvs.get_inherited_metadata().copy() + json_metadata = json_data.get('metadata', {}) + for field in inheritance.INHERITABLE_METADATA: + if field in json_metadata: + json_data['_inherited_metadata'][field] = json_metadata[field] - for key, value in json_data.get('metadata', {}).items(): - model_data[cls._translate(key)] = value - - model_data.update(json_data.get('metadata', {})) - - definition = json_data.get('definition', {}) - if 'children' in definition: - model_data['children'] = definition['children'] - - if 'data' in definition: - if isinstance(definition['data'], dict): - model_data.update(definition['data']) - else: - model_data['data'] = definition['data'] - - model_data['location'] = json_data['location'] - - return cls(system, model_data) + new_block = system.xblock_from_json(cls, usage_id, json_data) + if parent_xblock is not None: + parent_xblock.children.append(new_block) + return new_block @classmethod def _translate(cls, key): diff --git a/common/test/data/splitmongo_json/active_versions.json b/common/test/data/splitmongo_json/active_versions.json new file mode 100644 index 0000000000..b41440e0e7 --- /dev/null +++ b/common/test/data/splitmongo_json/active_versions.json @@ -0,0 +1,27 @@ +[{"_id" : "GreekHero", + "org" : "testx", + "prettyid" : "test_course", + "versions" : { + "draft" : { "$oid" : "1d00000000000000dddd0000" } + }, + "edited_on" : {"$date" : 1364481713238}, + "edited_by" : "test@edx.org"}, + + {"_id" : "wonderful", + "org" : "testx", + "prettyid" : "another_course", + "versions" : { + "draft" : { "$oid" : "1d00000000000000dddd2222" }, + "published" : { "$oid" : "1d00000000000000eeee0000" } + }, + "edited_on" : {"$date" : 1364481313238}, + "edited_by" : "test@edx.org"}, + + {"_id" : "contender", + "org" : "guestx", + "prettyid" : "test_course", + "versions" : { + "draft" : { "$oid" : "1d00000000000000dddd5555" }}, + "edited_on" : {"$date" : 1364491313238}, + "edited_by" : "test@guestx.edu"} +] diff --git a/common/test/data/splitmongo_json/definitions.json b/common/test/data/splitmongo_json/definitions.json new file mode 100644 index 0000000000..0ed42112aa --- /dev/null +++ b/common/test/data/splitmongo_json/definitions.json @@ -0,0 +1,334 @@ +[ + { + "_id":"head12345_12", + "category":"course", + "data":{ + "textbooks":[ + + ], + "grading_policy":{ + "GRADER":[ + { + "min_count":4, + "weight":0.15, + "type":"Homework", + "drop_count":2, + "short_label":"HWa" + }, + { + "short_label":"", + "min_count":12, + "type":"Lab", + "drop_count":2, + "weight":0.15 + }, + { + "short_label":"Midterm", + "min_count":1, + "type":"Midterm Exam", + "drop_count":0, + "weight":0.3 + }, + { + "short_label":"Final", + "min_count":1, + "type":"Final Exam", + "drop_count":0, + "weight":0.4 + } + ], + "GRADE_CUTOFFS":{ + "Pass":0.45 + } + }, + "wiki_slug":null + }, + "edited_by":"testassist@edx.org", + "edited_on":{"$date" : 1364481713238}, + "previous_version":"head12345_11", + "original_version":"head12345_10" + }, + { + "_id":"head12345_11", + "category":"course", + "data":{ + "textbooks":[ + + ], + "grading_policy":{ + "GRADER":[ + { + "min_count":5, + "weight":0.15, + "type":"Homework", + "drop_count":1, + "short_label":"HWa" + }, + { + "short_label":"", + "min_count":12, + "type":"Lab", + "drop_count":2, + "weight":0.15 + }, + { + "short_label":"Midterm", + "min_count":1, + "type":"Midterm Exam", + "drop_count":0, + "weight":0.3 + }, + { + "short_label":"Final", + "min_count":1, + "type":"Final Exam", + "drop_count":0, + "weight":0.4 + } + ], + "GRADE_CUTOFFS":{ + "Pass":0.55 + } + }, + "wiki_slug":null + }, + "edited_by":"testassist@edx.org", + "edited_on":{"$date" : 1364481713238}, + "previous_version":"head12345_10", + "original_version":"head12345_10" + }, + { + "_id":"head12345_10", + "category":"course", + "data":{ + "textbooks":[ + + ], + "grading_policy":{ + "GRADER":[ + { + "min_count":5, + "weight":0.15, + "type":"Homework", + "drop_count":1, + "short_label":"HWa" + }, + { + "short_label":"", + "min_count":2, + "type":"Lab", + "drop_count":0, + "weight":0.15 + }, + { + "short_label":"Midterm", + "min_count":1, + "type":"Midterm Exam", + "drop_count":0, + "weight":0.3 + }, + { + "short_label":"Final", + "min_count":1, + "type":"Final Exam", + "drop_count":0, + "weight":0.4 + } + ], + "GRADE_CUTOFFS":{ + "Pass":0.75 + } + }, + "wiki_slug":null + }, + "edited_by":"test@edx.org", + "edited_on":{"$date": 1364473713238}, + "previous_version":null, + "original_version":"head12345_10" + }, + { + "_id":"head23456_1", + "category":"course", + "data":{ + "textbooks":[ + + ], + "grading_policy":{ + "GRADER":[ + { + "min_count":14, + "weight":0.25, + "type":"Homework", + "drop_count":1, + "short_label":"HWa" + }, + { + "short_label":"", + "min_count":12, + "type":"Lab", + "drop_count":2, + "weight":0.25 + }, + { + "short_label":"Midterm", + "min_count":1, + "type":"Midterm Exam", + "drop_count":0, + "weight":0.2 + }, + { + "short_label":"Final", + "min_count":1, + "type":"Final Exam", + "drop_count":0, + "weight":0.3 + } + ], + "GRADE_CUTOFFS":{ + "Pass":0.45 + } + }, + "wiki_slug":null + }, + "edited_by":"test@edx.org", + "edited_on":{"$date": 1364481313238}, + "previous_version":"head23456_0", + "original_version":"head23456_0" + }, + { + "_id":"head23456_0", + "category":"course", + "data":{ + "textbooks":[ + + ], + "grading_policy":{ + "GRADER":[ + { + "min_count":14, + "weight":0.25, + "type":"Homework", + "drop_count":1, + "short_label":"HWa" + }, + { + "short_label":"", + "min_count":12, + "type":"Lab", + "drop_count":2, + "weight":0.25 + }, + { + "short_label":"Midterm", + "min_count":1, + "type":"Midterm Exam", + "drop_count":0, + "weight":0.2 + }, + { + "short_label":"Final", + "min_count":1, + "type":"Final Exam", + "drop_count":0, + "weight":0.3 + } + ], + "GRADE_CUTOFFS":{ + "Pass":0.95 + } + }, + "wiki_slug":null + }, + "edited_by":"test@edx.org", + "edited_on":{"$date" : 1364481313238}, + "previous_version":null, + "original_version":"head23456_0" + }, + { + "_id":"head345679_1", + "category":"course", + "data":{ + "textbooks":[ + + ], + "grading_policy":{ + "GRADER":[ + { + "min_count":4, + "weight":0.25, + "type":"Homework", + "drop_count":0, + "short_label":"HW" + }, + { + "short_label":"Midterm", + "min_count":1, + "type":"Midterm Exam", + "drop_count":0, + "weight":0.4 + }, + { + "short_label":"Final", + "min_count":1, + "type":"Final Exam", + "drop_count":0, + "weight":0.35 + } + ], + "GRADE_CUTOFFS":{ + "Pass":0.25 + } + }, + "wiki_slug":null + }, + "edited_by":"test@edx.org", + "edited_on":{"$date" : 1364481313238}, + "previous_version":null, + "original_version":"head23456_0" + }, + { + "_id":"chapter12345_1", + "category":"chapter", + "data":null, + "edited_by":"testassist@edx.org", + "edited_on":{"$date" : 1364483713238}, + "previous_version":null, + "original_version":"chapter12345_1" + }, + { + "_id":"chapter12345_2", + "category":"chapter", + "data":null, + "edited_by":"testassist@edx.org", + "edited_on":{"$date" : 1364483713238}, + "previous_version":null, + "original_version":"chapter12345_2" + }, + { + "_id":"chapter12345_3", + "category":"chapter", + "data":null, + "edited_by":"testassist@edx.org", + "edited_on":{"$date" : 1364483713238}, + "previous_version":null, + "original_version":"chapter12345_3" + }, + { + "_id":"problem12345_3_1", + "category":"problem", + "data":"", + "edited_by":"testassist@edx.org", + "edited_on":{"$date" : 1364483713238}, + "previous_version":null, + "original_version":"problem12345_3_1" + }, + { + "_id":"problem12345_3_2", + "category":"problem", + "data":"", + "edited_by":"testassist@edx.org", + "edited_on":{"$date" : 1364483713238}, + "previous_version":null, + "original_version":"problem12345_3_2" + } +] \ No newline at end of file diff --git a/common/test/data/splitmongo_json/structures.json b/common/test/data/splitmongo_json/structures.json new file mode 100644 index 0000000000..0021225213 --- /dev/null +++ b/common/test/data/splitmongo_json/structures.json @@ -0,0 +1,471 @@ +[ + { + "_id": { "$oid" : "1d00000000000000dddd0000"}, + "root":"head12345", + "original_version":{ "$oid" : "1d00000000000000dddd3333" }, + "previous_version":{ "$oid" : "1d00000000000000dddd1111" }, + "edited_by":"testassist@edx.org", + "edited_on":{ + "$date":1364483713238 + }, + "blocks":{ + "head12345":{ + "children":[ + "chapter1", + "chapter2", + "chapter3" + ], + "category":"course", + "definition":"head12345_12", + "metadata":{ + "end":"2013-06-13T04:30", + "tabs":[ + { + "type":"courseware" + }, + { + "type":"course_info", + "name":"Course Info" + }, + { + "type":"discussion", + "name":"Discussion" + }, + { + "type":"wiki", + "name":"Wiki" + }, + { + "type":"static_tab", + "name":"Syllabus", + "url_slug":"01356a17b5924b17a04b7fc2426a3798" + }, + { + "type":"static_tab", + "name":"Advice for Students", + "url_slug":"57e9991c0d794ff58f7defae3e042e39" + } + ], + "enrollment_start":"2013-01-01T05:00", + "graceperiod":"2 hours 0 minutes 0 seconds", + "start":"2013-02-14T05:00", + "enrollment_end":"2013-03-02T05:00", + "data_dir":"MITx-2-Base", + "advertised_start":"Fall 2013", + "display_name":"The Ancient Greek Hero" + }, + "update_version":{ "$oid" : "1d00000000000000dddd0000" }, + "previous_version":{ "$oid" : "1d00000000000000dddd1111" }, + "edited_by":"testassist@edx.org", + "edited_on":{ + "$date":1364483713238 + } + }, + "chapter1":{ + "children":[ + + ], + "category":"chapter", + "definition":"chapter12345_1", + "metadata":{ + "display_name":"Hercules" + }, + "update_version":{ "$oid" : "1d00000000000000dddd0000" }, + "previous_version":null, + "edited_by":"testassist@edx.org", + "edited_on":{ + "$date":1364483713238 + } + }, + "chapter2":{ + "children":[ + + ], + "category":"chapter", + "definition":"chapter12345_2", + "metadata":{ + "display_name":"Hera heckles Hercules" + }, + "update_version":{ "$oid" : "1d00000000000000dddd0000" }, + "previous_version":null, + "edited_by":"testassist@edx.org", + "edited_on":{ + "$date":1364483713238 + } + }, + "chapter3":{ + "children":[ + "problem1", + "problem3_2" + ], + "category":"chapter", + "definition":"chapter12345_3", + "metadata":{ + "display_name":"Hera cuckolds Zeus" + }, + "update_version":{ "$oid" : "1d00000000000000dddd0000" }, + "previous_version":null, + "edited_by":"testassist@edx.org", + "edited_on":{ + "$date":1364483713238 + } + }, + "problem1":{ + "children":[ + + ], + "category":"problem", + "definition":"problem12345_3_1", + "metadata":{ + "display_name":"Problem 3.1", + "graceperiod":"4 hours 0 minutes 0 seconds" + }, + "update_version":{ "$oid" : "1d00000000000000dddd0000" }, + "previous_version":null, + "edited_by":"testassist@edx.org", + "edited_on":{ + "$date":1364483713238 + } + }, + "problem3_2":{ + "children":[ + + ], + "category":"problem", + "definition":"problem12345_3_2", + "metadata":{ + "display_name":"Problem 3.2" + }, + "update_version":{ "$oid" : "1d00000000000000dddd0000" }, + "previous_version":null, + "edited_by":"testassist@edx.org", + "edited_on":{ + "$date":1364483713238 + } + } + } + }, + { + "_id": { "$oid" : "1d00000000000000dddd1111"}, + "root":"head12345", + "original_version":{ "$oid" : "1d00000000000000dddd3333" }, + "previous_version":{ "$oid" : "1d00000000000000dddd3333" }, + "edited_by":"testassist@edx.org", + "edited_on":{ + "$date":1364481713238 + }, + "blocks":{ + "head12345":{ + "children":[ + + ], + "category":"course", + "definition":"head12345_11", + "metadata":{ + "end":"2013-04-13T04:30", + "tabs":[ + { + "type":"courseware" + }, + { + "type":"course_info", + "name":"Course Info" + }, + { + "type":"discussion", + "name":"Discussion" + }, + { + "type":"wiki", + "name":"Wiki" + }, + { + "type":"static_tab", + "name":"Syllabus", + "url_slug":"01356a17b5924b17a04b7fc2426a3798" + }, + { + "type":"static_tab", + "name":"Advice for Students", + "url_slug":"57e9991c0d794ff58f7defae3e042e39" + } + ], + "enrollment_start":null, + "graceperiod":"2 hours 0 minutes 0 seconds", + "start":"2013-02-14T05:00", + "enrollment_end":null, + "data_dir":"MITx-2-Base", + "advertised_start":null, + "display_name":"The Ancient Greek Hero" + }, + "update_version":{ "$oid" : "1d00000000000000dddd1111" }, + "previous_version":{ "$oid" : "1d00000000000000dddd3333" }, + "edited_by":"testassist@edx.org", + "edited_on":{ + "$date":1364481713238 + } + } + } + }, + { + "_id": { "$oid" : "1d00000000000000dddd3333"}, + "root":"head12345", + "original_version":{ "$oid" : "1d00000000000000dddd3333" }, + "previous_version":null, + "edited_by":"test@edx.org", + "edited_on":{ + "$date":1364473713238 + }, + "blocks":{ + "head12345":{ + "children":[ + + ], + "category":"course", + "definition":"head12345_10", + "metadata":{ + "end":null, + "tabs":[ + { + "type":"courseware" + }, + { + "type":"course_info", + "name":"Course Info" + }, + { + "type":"discussion", + "name":"Discussion" + }, + { + "type":"wiki", + "name":"Wiki" + } + ], + "enrollment_start":null, + "graceperiod":null, + "start":"2013-02-14T05:00", + "enrollment_end":null, + "data_dir":"MITx-2-Base", + "advertised_start":null, + "display_name":"The Ancient Greek Hero" + }, + "update_version":{ "$oid" : "1d00000000000000dddd3333" }, + "previous_version":null, + "edited_by":"test@edx.org", + "edited_on":{ + "$date":1364473713238 + } + } + } + }, + { + "_id": { "$oid" : "1d00000000000000dddd2222"}, + "root":"head23456", + "original_version":{ "$oid" : "1d00000000000000dddd4444" }, + "previous_version":{ "$oid" : "1d00000000000000dddd4444" }, + "edited_by":"test@edx.org", + "edited_on":{ + "$date":1364481313238 + }, + "blocks":{ + "head23456":{ + "children":[ + + ], + "category":"course", + "definition":"head23456_1", + "metadata":{ + "end":null, + "tabs":[ + { + "type":"courseware" + }, + { + "type":"course_info", + "name":"Course Info" + }, + { + "type":"discussion", + "name":"Discussion" + }, + { + "type":"wiki", + "name":"Wiki" + } + ], + "enrollment_start":null, + "graceperiod":null, + "start":"2013-02-14T05:00", + "enrollment_end":null, + "data_dir":"MITx-2-Base", + "advertised_start":null, + "display_name":"The most wonderful course" + }, + "update_version":{ "$oid" : "1d00000000000000dddd2222" }, + "previous_version":{ "$oid" : "1d00000000000000dddd4444" }, + "edited_by":"test@edx.org", + "edited_on":{ + "$date":1364481313238 + } + + } + } + }, + { + "_id": { "$oid" : "1d00000000000000dddd4444"}, + "root":"head23456", + "original_version":{ "$oid" : "1d00000000000000dddd4444" }, + "previous_version":null, + "edited_by":"test@edx.org", + "edited_on":{ + "$date":1364480313238 + }, + "blocks":{ + "head23456":{ + "children":[ + + ], + "category":"course", + "definition":"head23456_0", + "metadata":{ + "end":null, + "tabs":[ + { + "type":"courseware" + }, + { + "type":"course_info", + "name":"Course Info" + }, + { + "type":"discussion", + "name":"Discussion" + }, + { + "type":"wiki", + "name":"Wiki" + } + ], + "enrollment_start":null, + "graceperiod":null, + "start":"2013-02-14T05:00", + "enrollment_end":null, + "data_dir":"MITx-2-Base", + "advertised_start":null, + "display_name":"A wonderful course" + }, + "update_version":{ "$oid" : "1d00000000000000dddd4444" }, + "previous_version":null, + "edited_by":"test@edx.org", + "edited_on":{ + "$date":1364480313238 + } + } + } + }, + { + "_id": { "$oid" : "1d00000000000000eeee0000"}, + "root":"head23456", + "original_version":{ "$oid" : "1d00000000000000eeee0000" }, + "previous_version":null, + "edited_by":"test@edx.org", + "edited_on":{ + "$date":1364481333238 + }, + "blocks":{ + "head23456":{ + "children":[ + + ], + "category":"course", + "definition":"head23456_1", + "metadata":{ + "end":null, + "tabs":[ + { + "type":"courseware" + }, + { + "type":"course_info", + "name":"Course Info" + }, + { + "type":"discussion", + "name":"Discussion" + }, + { + "type":"wiki", + "name":"Wiki" + } + ], + "enrollment_start":null, + "graceperiod":null, + "start":"2013-02-14T05:00", + "enrollment_end":null, + "data_dir":"MITx-2-Base", + "advertised_start":null, + "display_name":"The most wonderful course" + }, + "update_version":{ "$oid" : "1d00000000000000eeee0000" }, + "previous_version":null, + "edited_by":"test@edx.org", + "edited_on":{ + "$date":1364481333238 + } + } + } + }, + { + "_id": { "$oid" : "1d00000000000000dddd5555"}, + "root":"head345679", + "original_version":{ "$oid" : "1d00000000000000dddd5555" }, + "previous_version":null, + "edited_by":"test@guestx.edu", + "edited_on":{ + "$date":1364491313238 + }, + "blocks":{ + "head345679":{ + "children":[ + + ], + "category":"course", + "definition":"head345679_1", + "metadata":{ + "end":null, + "tabs":[ + { + "type":"courseware" + }, + { + "type":"course_info", + "name":"Course Info" + }, + { + "type":"discussion", + "name":"Discussion" + }, + { + "type":"wiki", + "name":"Wiki" + } + ], + "enrollment_start":null, + "graceperiod":null, + "start":"2013-03-14T05:00", + "enrollment_end":null, + "data_dir":"MITx-3-Base", + "advertised_start":null, + "display_name":"Yet another contender" + }, + "update_version":{ "$oid" : "1d00000000000000dddd5555" }, + "previous_version":null, + "edited_by":"test@guestx.edu", + "edited_on":{ + "$date":1364491313238 + } + } + } + } +] diff --git a/docs/source/persistence.rst b/docs/source/persistence.rst new file mode 100644 index 0000000000..54551b292b --- /dev/null +++ b/docs/source/persistence.rst @@ -0,0 +1,658 @@ + + + +This document describes the split mongostore representation which +separates course structure from content where each course run can have +its own structure. It does not describe the original mongostore +representation which combined structure and content and used the key +to distinguish draft from published elements. + +This document does not describe mongo nor its operations. See +`http://www.mongodb.org/`_ for information on Mongo. + + + +Product Goals and Discussion +---------------------------- + +(Mark Chang) + +This work was instigated by the studio team's need to correctly do +metadata inheritance. As we moved from an on-startup load of the +courseware, the system was able to inflate and perform an inheritance +calculation step such that the intended properties of children could +be set through inheritance. While not strictly a requirement from the +studio authoring approach, where inheritance really rears its head is +on import of existing courseware that was designed assuming +inheritance. + +A short term patch was applied that allowed inheritance to act +correctly, but it was felt that it was insufficient and this would be +an opportunity to make a more clean datastore representation. After +much difficulty with how draft objects would work, Calen Pennington +worked through a split data store model ala FAT filesystem (Mark's +metaphor, not Cale's) to split the structure from the content. The +goal would be a sea of content documents that would not know about the +structure they were utilized within. Cale began the work and handed it +off to Don Mitchell. + +In the interim, great discussion was had at the Architect's Council +that firmed up the design and strategy for implementation, adding +great richness and completeness to the new data structure. + +The immediate +needs are two, and only two. + + +#. functioning metadata inheritance +#. good groundwork for versioning + + +While the discussions of the atomic unit of courseware available for +sharing, how these are shared, and how they refer back to the parent +definition are all valuable, they will not be built in the near term. I +understand and expect there to be many refactorings, improvements, and +migrations in the future. + +I fully anticipate much more detail to be uncovered even in this first +thin implementation. When that happens, we will need as much advice +from those watching this page to make sure we move in the right +direction. We also must have the right design artifacts to document +where we stand relative to the overall design that has loftier goals. + + +Representation +-------------- + +The xmodule collections: + + ++ `modulestore.active_versions`: this collection maps the org, course, + and run to the current draft and published versions of the course. ++ `modulestore.structures`: this collection has one entry per course + run and one for the template. ++ `modulestore.definitions`: this collection has one entry per + "module" or "block" version. + +modulestore.active_versions: 2 simple maps for dereferencing the +correct course from the structures collection. Every course run will +have a draft version. Not every course run will have a published +version. No course run will have more than one of each of these. + +:: + + { '_id' : uniqueid, + 'versions' : { : versionGuid, ..} + 'creator' : user_id, + 'created' : date (native mongo rep) + } + +:: + + + ++ `id` is a unique id for finding this course run. It's a + location-reference string, like 'edu.mit.eng.eecs.6002x.industry.spring2013'. ++ `versions`: These are references to `modulestore.structures`. A + location-reference like + `edu.mit.eng.eecs.6002x.industry.spring2013;draft` refers to the value + associated with `draft` for this document. + + + `versionName` is `draft`, `published`, or another user-defined + string. + + `versionGuid` is a system generated globally unique id (hash). It + points to the entry in `modulestore.structures` ` ` + + + +`draftVersion`: the design will try to generate a new draft version +for each change to the course object: that is, for each move, +deletion, node creation, or metadata change. Cloning a course +(creating a new run of a course or such) will create a new entry in +this table with just a `draftVersion` and will cause a copy of the +corresponding entry in `modulestore.structures`. The entry in +`structures` will point to its version parent in the source course. + + + + +modulestore.structures : the entries in this collection follow this +definition: + +:: + + { '_id' : course_guid, + 'blocks' : + { block_guid : // the guid is an arbitrary id to represent this node in the course tree + { 'children' : [ block_guid* ], + 'metadata' : { property map }, + 'definition' : definition_guid, + 'category' : 'section' | 'sequence' | ... } + + +:: + + ...// more guids + + +:: + + }, + 'root' : block_guid, + 'original' : course_guid, // the first version of this course from which all others were derived + 'previous' : course_guid | null, // the previous revision of this course (null if this is the original) + 'version_entry' : uniqueid, // from the active_versions collection + 'creator' : user_id + } + + + ++ `blocks`: each block is a node in the course such as the course, a + section, a subsection, a unit, or a component. The block ids remain + the same over edits (they're not versioned). ++ `root`: the true top of the course. Not all nodes without parents + are truly roots. Some are orphans. ++ `course_guid, block_guid, definition_guid` are not those specific + strings but instead some system generated globally unique id. + + + The one which gets passed around and pointed to by urls is the + `block_guid`; so, it will be the one the system ensures is readable. + Unlike the other guids, this one stays the same over revisions and can + even be the same between course runs (although the course run + contextualizes it to distinguish its instantiated version). + ++ `definition` points to the specific revision of the given element in + `modulestore.definitions` which this version of the course includes. ++ `children` lists the block_guids which are the children of this node + in the course tree. It's an error if the guid in the `children` list + does not occur in the `blocks` dictionary. ++ `metadata` is the node's explicitly defined metadata some of which + may be inherited by its children + + +For debugging purposes, there may be value in adding a courseId field +(org, course, run) for use via db browsers. + +modulestore.definitions : the data associated with each version of +each node in the structures. Many courses may point to the same +definition or may point to different versions derived from the same +original definition. + +:: + + { '_id' : guid, + 'data' : .., + 'default_settings' : {'display_name':..,..}, // a starting point for new uses of this definition + 'category' : xblocktype, // the xmodule/xblock type such as course, problem, html, video, about + 'original' : guid, // the first kept version of this definition from which all others were derived + 'previous' : guid | null, // the previous revision of this definition (null if this is the original) + 'creator' : user_id // the id of whomever pressed the draft or publish button + } + + + ++ `_id`: a guid to uniquely identify the definition. ++ `data` is the payload used by the xmodule and following the + xmodule's data representation. ++ `category` is the xmodule type and used to figure out which xmodule + to instantiate. + + +There may be some debugging value to adding a courseId field, but it +may also be misleading if the element is used in more than one course. + + +Templates +~~~~~~~~~ + +(I'm refactoring templates quite a bit from their representation prior +to this design) + +All field defaults will be defined through the xblock field.default +mechanism. Templates, otoh, are for representing optional boilerplate +usually for examples such as a multiple-choice problem or a video +component with the fields all filled in. Templates are stored in yaml +files which provide a template name, sorting and filtering information +(e.g., requires advanced editor v allows simple editor), and then +field: value pairs for setting xblocks' fields upon template +selection. + +Most of the pre-existing templates including all of the 'empty' ones +will go away. The ones which will stay are the ones truly just giving +examples or starting points for variants. This change will require +that the template choice code provide a default 'blank' choice to the +user which just instantiates the model w/ its defaults versus a choice +of the boilerplates. The client can therefore populate its own model +of the xblock and then send a create-item request to the server when +the user says he/she's ready to save it. + + +Import/export +~~~~~~~~~~~~~ + +Export should allow the user to select the version of the course to +export which can be any of the draft or published versions. At a +minimum, the user should choose between draft or published. + +Import should import the course as a draft course regardless of +whether it was exported as a published or draft one, I believe. If +there's already a draft for the same course, in the best of all +worlds, it would have the guid to see if the guid exists in the +structures collection, and, if so, just make that the current +draftVersion (don't do any actual data changes). If there's no guid or +the guid doesn't exist in the structures collection, then we'll need +to work out the logic for how to decide what definitions to create v +update v point to. + + +Course ID +~~~~~~~~~ + +Currently, we use a triple to identify a run of a course. The triple +is organization, course name, and run identity (e.g., 2013Q1). The +system does not care what the id consists of only that it uniquely +identify an edition of the course. The system uses this id to organize +the course composition and find the course elements. It distinguishes +between a current being-edited version (aka, draft) and publicly +viewable version (published). Not every course has a published +version, but every course will have a draft version. The application +specifies whether it wants the draft or published version. This system +allows the application to easily switch between the 2; however, it +will have a configuration in which it's impossible to access the draft +so that we can add access optimizations and extraction filtering later +if needed. + + +Location +~~~~~~~~ + +The purpose of `Location` is to identify content. That is, to be able +to locate content by providing sufficient addressing. The `Location` +object is ubiquitous throughout the current code and thus will be +difficult to adapt and make more flexible. Right now, it's a very +simple `namedtuple` and a lot of code presumes this. This refactoring +generalizes and subclasses it to handle various addressing schemes and +remove direct manipulations. + +Our code needs to locate several types of things and should probably +use several different types of locators for these. These are the types +of things we need to address. Some of these can be the same as others, +but I wanted to lay them out fairly fine grained here before proposing +my distinctions: + + +#. Courses: an object representing a course as an offering but not any + of its content. Used for dashboards and other such navigators. These + may specify a version or merely reference the idea of the course's + existence. +#. Course structures: the names (and other metadata), `Locations`, and + children pointers but not definitions for all the blocks in a course + or a subtree of a course. Our applications often display contextual, + outline, or other such structural information which do not need to + include definitions but need to show display names, graded as, and + other status info. This document's design makes fetching these a + single document fetch; however, if it has to fetch the full course, it + will require far more work (getting all definitions too) than the apps + need. +#. Blocks (uses of definitions within a version of a course including + metadata, pointers to children, and type specific content) +#. Definitions: use independent definitions of content without + metadata (and currently w/o pointers to children). +#. Version trees Fetching the time history portrayal of a definition, + course, or block including branching. +#. Collections of courses, definitions, or blocks matching some + partial descriptors (e.g., all courses for org x, all definitions of + type foo, all blocks in course y of type x, all currently accessible + courses (published with startdate < today and enddate > today)). +#. Fetching of courses, blocks, or definitions via "human readable" + urls. +#. (partial descriptors) may suffice for this as human readable + does not guarantee uniqueness. + + +Some of these differ not so much in how to address them but in what +should be returned. The content should be up to the functions not the +addressing scheme. So, I think the addressable things are: + + +#. Course as in #1 above: usually a specific offering of a course. + Often used as a context for the other queries. +#. Blocks (aka usages) as in #3 above: a specific block contextualized + in a course +#. Definitions (#4): a specific definition +#. Collections of courses, blocks within a specific course, or + definitions matching a partial descriptor + + + +Course locator (course_loc) +``````````````````````````` + +There are 3 ways to locate a course: + + +#. By its unique id in the `active_versions` collection with an + implied or specified selection of draft or published version. +#. By its unique id in the `structures` collection. + + + +Block locator (block_loc) +````````````````````````` + +A block locator finds a specific node in a specific version of a +course. Thus, it needs a course locator plus a `usage_id`. + + +Definition locator (definition_loc) +``````````````````````````````````` + +Just a `guid`. + + +Partial descriptor collections locators (partial) +````````````````````````````````````````````````` + +In the most general case, and to simplify implementation, these can be +any payload passable to mongo for doing the lookup. The specification +of which collection to look into can be implied by which lookup +function your code calls (get_courses, get_blocks, get_definitions) or +we could add it as another property. For now, I will leave this as +merely a search string. Thus, to find all courses for org = mitx, +`{"org": "mitx"}`. To find all blocks in a course whose display name +contains "circuit example", call `get_blocks` with the course locator +plus `{"metadata.display_name" : /circuit example/i}` (the i makes it +case insensitive and is just an example). To find if a definition is +used in a course, call get_blocks with the course locator plus +`{definition : definition_guid}`. Note, this looks for a specific +version of the definition. If you wanted to see if it used any of a +set of versions, use `{definition : {"$in" : [definition_guid*]}}` + + +i4x locator +``````````` + +To support existing xml based courses and any urls, we need to +support i4x locators. These are tuples of `(org course category id +['draft'])`. The trouble with these is that they don't uniquely +identify a course run from which to dereference the element. There's +also no requirement that `id` have any uniqueness outside the scope of +the other elements. There's some debate as to whether these address +blocks or definitions. To mean, they seem to address blocks; however, +in the current system there is no distinction between blocks and +definitions; so, either could be argued. + +This version will define an `i4x_location` class for representing +these and using them for xml based courses if necessary. + +Current code munges strings to make them 'acceptable' by replacing +'illegal' chars with underscores. I'd like to suggest leaving strings +as is and using url escaping to make acceptable urls. As to making +human readable names from display strings, that should be the +responsibility of the naming module not the Location representation, +imo. + + +Use cases (expository) +~~~~~~~~~~~~~~~~~~~~~~ + +There's a section below walking through a specific use case. This one +just tries to review potential functionality. + + +Inheritance +``````````` + +Our system has the notion of policies which should control the +behavior of whole courses or subtrees within courses. Such policies +include graceperiods, discussion forum controls, dates, whether to +show answers, how to randomize, etc. It's important that the course +authors' intent propagates to all relevant course sections. The +desired behavior is that (some? all?) metadata attributes on modules +flow down to all children unless overridden. + +This design addresses inheritance by making course structure and +metadata separate from content thus enabling a single or small number +of db queries to get these and then compute the inheritance. + + +Separating editing from live production +``````````````````````````````````````` + +Course authors should be able to make changes in isolation from +production and then push out consistent chunks of changes for all +students to see as atomic and consistent. The current system allows +authors to change text and content without affecting production but +not metadata nor course structure. This design separates all changes +from production until pushed. + + +Sharing of content, part 1 +`````````````````````````` + +Authors want to share content between course runs and even between +different courses. The current system requires copying all such +content and losing the providence information which could be used to +take advantage of other peoples' changes. This design allows multiple +courses and multiple places within a course to point to the same +definitions and thus potentially, at some day, see other changes to +the content. + + +Sharing of content, part 2: course structure +```````````````````````````````````````````` + +Because courses structures are separate from their identities, courses +can share structure and track changes in the same way as definitions. +That is, a new course run can point to an existing course instance +with its version history and then branch it from there. + + +Sharing of content, part 3: modules +``````````````````````````````````` + +Suppose a course includes a soldering tutorial (or a required lab +safety lesson). Other courses want to use the same tutorial and +possibly allow the student to skip it if the student succeeded at it +in another course. As the tutorial updates, other courses may want to +track the updates or choose to move to the updates without having to +copy the modules from the module's authoritative parent course. + +This design enables sharing of composed modules but it does not track +the revisions of those modules separately from their courses. It does +not adequately address this but may be extendible enough to do so. +That is, we could represent these shared units as separate "courses" +and allow ids in block.children[] to point to courses as well as other +blocks in the same course. + +We should decide on the behaviors we want. Such as, some times the +student has to repeat the content or the student never has to repeat +it or? progress should be tracked by the owning course or as a stand +alone minicourse type element? Because it's a safety lesson, all +courses should track the current published head and not have their own +heads or they should choose when to promote the head? + +Are these shared elements rare and large grained enough to make the +indirection not expensive or will it result in devolving to the +current one entry per module design for deducing course structure? + + +Functional differences from existing modulestore: +------------------------------------------------- + + ++ Courses and definitions support trees of versions knowing from where + they were derived. For now, I will not implement the server functions + for retrieving and manipulating these version trees and will leave + those for a future effort. I will only implement functions which + extend the trees. ++ Changes to course structure don't immediately affect production: + note, we need to figure out the granularity of the user's publish + behavior for pushing out these actions. That is, do they publish a + whole subtree which may include new children in order to make these + effective, do they publish all structural (deletion, move) changes + under a subtree but not insertions as an action, do they publish each + action individually, or what? How do they know that any of these are + not yet published? Do we have phantom placeholders for deleted nodes + w/ "publish deletion" buttons? + + + Element deletion + + Element move + + metadata changes + ++ No location objects used as ids! This implementation will use guids + instead. There's a reasonable objection to guids as being too ugly, + long, and indecipherable. I will check mongy, pymongo, and python guid + generation mechanisms to find out if there's a way to make ones which + include a prepended string (such as course and run or an explicitly + stated prepend string) and minimize guid length (e.g., by using + sequential serial # from a global or local pool). + + + +Use case walkthrough: +--------------------- + +Simple course creation with no precursor course: Note, this shows that +publishing creates subsets and side copies not in line versions of +nodes. +user db create course for org, course id, run id +active_versions.draftVersion: add entry +definitions: add entry C w/ category = 'course', no data +structures: add entry w/ 1 child C, original = self, no previous, +author = user +add section S copy structures entry, new one points to old as original +and previous +active_versions.draftVersion points to new +definitions: add entry S w/ category = 'section' +structures entry: + ++ add S to children of the course block, + + + ++ add S to blocks w/ no children + +add subsection T copy structures entry, new one points to old as +original and previous +active_versions.draftVersion points to new +definitions: add entry T w/ category = 'sequential' +structures entry: + ++ add T to children of the S block entry, + + + ++ add T to blocks w/ no children + +add unit U copy structures entry, new one points to old as original +and previous +active_versions.draftVersion points to new +definitions: add entry U w/ category = 'vertical' +structures entry: + ++ add U to children of the T block entry, + + + ++ add U to blocks w/ no children + +publish U +create structures entry, new one points to self as original (no +pointer to draft course b/c it's not really a clone) +active_versions.publishedVersion points to new +block: add U, T, S, C pointers with each as respective child +(regardless of other children they may have in draft), and their +metadata +add units V, W, X under T copy structures entry of the draftVersion, +new one points to old as original and previous +active_versions.draftVersion points to new +definitions: add entries V, W, X w/ category = 'vertical' +structures entry: + ++ add V, W, X to children of the T block entry, + + + ++ add V, W, X to blocks w/ no children + +edit U copy structures entry, new one points to old as original and +previous +active_versions.draftVersion points to new +definitions: copy entry U to U_2 w/ updates, U_2 points to U as +original and previous +structures entry: + ++ replace U w/ U_2 in children of the T block entry, + + + ++ copy entry U in blocks to entry U_2 and remove U + +add subsection Z under S copy structures entry, new one points to old +as original and previous +active_versions.draftVersion points to new +definitions: add entry Z w/ category = 'sequential' +structures entry: + ++ add Z to children of the S block entry, + + + ++ add Z to blocks w/ no children + +edit S's name (metadata) copy structures entry, new one points to old +as original and previous +active_versions.draftVersion points to new +structures entry: update S's metadata w/ new name publish U, V copy +publishedCourse structures entry, new one points to old published as +original and previous +active_versions.publishedVersion points to new +block: update T to point to new U & V and not old U +Note: does not update S's name publish C copy publishedCourse +structures entry, new one points to old published as original and +previous +active_versions.publishedVersion points to new +blocks: note that C child S == published(S) but metadata !=, update +metadata +note that S has unpublished children: publish them (recurse on this) +note that Z is unpublished: add pointer to blocks and children of S +note that W, X unpublished: add to blocks, add to children of T edit C +metadata (e.g., graceperiod) copy draft structures entry, new one +points to old as original and previous +active_versions.draftVersion points to new +structures entry: update C's metadata add Y under Z ... publish C's +metadata change copy publishedCourse structures entry, new one points +to old published as original and previous +active_versions.publishedVersion points to new +blocks: update C's metadata +Note: no copying of Y or any other changes to published move X under Z +copy draft structures entry, new one points to old as original and +previous +active_versions.draftVersion points to new +structures entry: remove X from T's children and add to Z's +Note: making it persistently clear to the user that X still exists +under T in the published version will be crucial delete W copy draft +structures entry, new one points to old as original and previous +active_versions.draftVersion points to new +structures entry: remove W from T's children and remove W from blocks +Note: no actual deletion of W, just no longer reachable w/in the draft +course, but still in published; so, need to keep user aware of that. +publish Z Note: the interesting thing here is that X cannot occur +under both Z and T, but the user's not publishing T, here's where +having a consistent definition of original may help. If the original +of a new element == original of an existing, then it's an update? +copy publishedCourse entry... +definitions: add Y, copy/update Z, X if either have any data changes +(they don't) +blocks: remove X from T's children and add to Z's, add Y to Z, add Y +publish deletion of W copy publishedCourse entry... +structures entry: remove W from T's children and remove W from blocks +Conflict detection: + +Need a scenario where 2 authors make edits to different parts of +course, to parts while parents being moved, while parents being +deleted, to same parts, ... + +.. _http://www.mongodb.org/: http://www.mongodb.org/ + From 62afa23a0d82d5f3abe52e0f375744f28f039b07 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Tue, 16 Jul 2013 16:26:05 -0400 Subject: [PATCH 17/77] Make tests not msec time sensitive --- .../xmodule/modulestore/tests/test_split_modulestore.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py index efaa795681..cd8048a1b0 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py @@ -461,7 +461,7 @@ class TestItemCrud(SplitModuleTest): # grab link to course to ensure new versioning works locator = CourseLocator(course_id="GreekHero", revision='draft') premod_course = modulestore().get_course(locator) - premod_time = datetime.datetime.now(UTC) + premod_time = datetime.datetime.now(UTC) - datetime.timedelta(seconds=1) # add minimal one w/o a parent category = 'sequential' new_module = modulestore().create_item(locator, category, 'user123', @@ -512,7 +512,7 @@ class TestItemCrud(SplitModuleTest): """ locator = BlockUsageLocator(course_id="contender", usage_id="head345679", revision='draft') category = 'problem' - premod_time = datetime.datetime.now(UTC) + premod_time = datetime.datetime.now(UTC) - datetime.timedelta(seconds=1) new_payload = "empty" new_module = modulestore().create_item(locator, category, 'anotheruser', metadata={'display_name': 'problem 1'}, new_def_data=new_payload) @@ -549,7 +549,7 @@ class TestItemCrud(SplitModuleTest): pre_version_guid = problem.location.version_guid self.assertIsNotNone(pre_def_id) self.assertIsNotNone(pre_version_guid) - premod_time = datetime.datetime.now(UTC) + premod_time = datetime.datetime.now(UTC) - datetime.timedelta(seconds=1) self.assertNotEqual(problem.max_attempts, 4, "Invalidates rest of test") problem.max_attempts = 4 From 22802a4d29fe2e215081cebaadd6a98626797750 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Thu, 18 Jul 2013 17:01:25 -0400 Subject: [PATCH 18/77] change from_xml class method on video class to make sure we honor the fact that the xml string could actually be a pointer to another file on disk which actually contains the attributes --- common/lib/xmodule/xmodule/video_module.py | 6 +----- common/lib/xmodule/xmodule/xml_module.py | 4 +++- common/test/data/toy/chapter/secret/magic.xml | 2 +- common/test/data/toy/course/2012_Fall.xml | 6 +++--- common/test/data/toy/vertical/vertical_test.xml | 2 ++ common/test/data/toy/video/separate_file_video.xml | 1 + lms/djangoapps/courseware/tests/test_module_render.py | 9 +++++---- 7 files changed, 16 insertions(+), 14 deletions(-) create mode 100644 common/test/data/toy/video/separate_file_video.xml diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index 5354297c2b..1d9ad35135 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -125,7 +125,7 @@ class VideoDescriptor(VideoFields, url identifiers """ video = super(VideoDescriptor, cls).from_xml(xml_data, system, org, course) - _parse_video_xml(video, xml_data) + _parse_video_xml(video, video.data) return video def definition_to_xml(self, resource_fs): @@ -146,10 +146,6 @@ def _parse_video_xml(video, xml_data): display_name = xml.get('display_name') if display_name: video.display_name = display_name - elif video.url_name is not None: - # copies the logic of display_name_with_default in order that studio created videos will have an - # initial non guid name - video.display_name = video.url_name.replace('_', ' ') youtube = xml.get('youtube') if youtube: diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index 882e308c77..5b8d2c8aee 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -306,6 +306,7 @@ class XmlDescriptor(XModuleDescriptor): org and course are optional strings that will be used in the generated modules url identifiers """ + xml_object = etree.fromstring(xml_data) # VS[compat] -- just have the url_name lookup, once translation is done url_name = xml_object.get('url_name', xml_object.get('slug')) @@ -318,7 +319,8 @@ class XmlDescriptor(XModuleDescriptor): filepath = cls._format_filepath(xml_object.tag, name_to_pathname(url_name)) definition_xml = cls.load_file(filepath, system.resources_fs, location) else: - definition_xml = xml_object # this is just a pointer, not the real definition content + definition_xml = xml_object + filepath = None definition, children = cls.load_definition(definition_xml, system, location) # note this removes metadata diff --git a/common/test/data/toy/chapter/secret/magic.xml b/common/test/data/toy/chapter/secret/magic.xml index 62756b8896..f85d2e75da 100644 --- a/common/test/data/toy/chapter/secret/magic.xml +++ b/common/test/data/toy/chapter/secret/magic.xml @@ -1,3 +1,3 @@ - diff --git a/common/test/data/toy/course/2012_Fall.xml b/common/test/data/toy/course/2012_Fall.xml index c3500040c2..8f0125ef2d 100644 --- a/common/test/data/toy/course/2012_Fall.xml +++ b/common/test/data/toy/course/2012_Fall.xml @@ -3,10 +3,10 @@ - - diff --git a/common/test/data/toy/vertical/vertical_test.xml b/common/test/data/toy/vertical/vertical_test.xml index e801a4ac86..68c5745f37 100644 --- a/common/test/data/toy/vertical/vertical_test.xml +++ b/common/test/data/toy/vertical/vertical_test.xml @@ -1,4 +1,6 @@ +

      Have you changed your mind?

      Yes diff --git a/common/test/data/toy/video/separate_file_video.xml b/common/test/data/toy/video/separate_file_video.xml new file mode 100644 index 0000000000..b2bf08540a --- /dev/null +++ b/common/test/data/toy/video/separate_file_video.xml @@ -0,0 +1 @@ +
      <%= gettext("delete chapter") %> From 4f9a290a326832ac259066f145a2e0373fc61ac3 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Mon, 22 Jul 2013 14:08:14 -0400 Subject: [PATCH 47/77] Link section to course outline --- cms/templates/unit.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/templates/unit.html b/cms/templates/unit.html index 300d631421..2904da9731 100644 --- a/cms/templates/unit.html +++ b/cms/templates/unit.html @@ -174,7 +174,7 @@
      1. - ${section.display_name_with_default} + ${section.display_name_with_default}
        1. From d87dba9e04be0281fdfc430ce2616ddc05589070 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Mon, 22 Jul 2013 15:05:31 -0400 Subject: [PATCH 48/77] Correctly persist checklist settings. Incidentally, fixes an acceptance test (which would have caught this bug) not running. Its step regex was "They are correctly selected after I reload the page$", which happens to be matched by "I reload the page$", another step definition. Lettuce seems to arbitrarily pick between definitions if they both match a step, so it's good to ensure that each step isn't a sub-regexes of another. --- cms/djangoapps/contentstore/features/checklists.feature | 3 +-- cms/djangoapps/contentstore/features/checklists.py | 2 +- cms/djangoapps/contentstore/views/checklist.py | 3 +++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/features/checklists.feature b/cms/djangoapps/contentstore/features/checklists.feature index 021589df99..10db23c4fa 100644 --- a/cms/djangoapps/contentstore/features/checklists.feature +++ b/cms/djangoapps/contentstore/features/checklists.feature @@ -8,9 +8,8 @@ Feature: Course checklists Scenario: A course author can mark tasks as complete Given I have opened Checklists Then I can check and uncheck tasks in a checklist - And They are correctly selected after I reload the page + And They are correctly selected after reloading the page - @skip Scenario: A task can link to a location within Studio Given I have opened Checklists When I select a link to the course outline diff --git a/cms/djangoapps/contentstore/features/checklists.py b/cms/djangoapps/contentstore/features/checklists.py index 13d3ca99b7..e8dcd755a3 100644 --- a/cms/djangoapps/contentstore/features/checklists.py +++ b/cms/djangoapps/contentstore/features/checklists.py @@ -45,7 +45,7 @@ def i_can_check_and_uncheck_tasks(step): verifyChecklist2Status(2, 7, 29) -@step('They are correctly selected after I reload the page$') +@step('They are correctly selected after reloading the page$') def tasks_correctly_selected_after_reload(step): reload_the_page(step) verifyChecklist2Status(2, 7, 29) diff --git a/cms/djangoapps/contentstore/views/checklist.py b/cms/djangoapps/contentstore/views/checklist.py index fdb5857ba7..bcf4a1a5b9 100644 --- a/cms/djangoapps/contentstore/views/checklist.py +++ b/cms/djangoapps/contentstore/views/checklist.py @@ -37,6 +37,7 @@ def get_checklists(request, org, course, name): checklists, modified = expand_checklist_action_urls(course_module) if copied or modified: + course_module.save() modulestore.update_metadata(location, own_metadata(course_module)) return render_to_response('checklists.html', { @@ -69,6 +70,7 @@ def update_checklist(request, org, course, name, checklist_index=None): # seeming noop which triggers kvs to record that the metadata is not default course_module.checklists = course_module.checklists checklists, _ = expand_checklist_action_urls(course_module) + course_module.save() modulestore.update_metadata(location, own_metadata(course_module)) return JsonResponse(checklists[index]) else: @@ -79,6 +81,7 @@ def update_checklist(request, org, course, name, checklist_index=None): # In the JavaScript view initialize method, we do a fetch to get all the checklists. checklists, modified = expand_checklist_action_urls(course_module) if modified: + course_module.save() modulestore.update_metadata(location, own_metadata(course_module)) return JsonResponse(checklists) From 060b2e17e58d1a90a6978318ad3ee4f40f82da27 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Mon, 22 Jul 2013 10:34:23 -0400 Subject: [PATCH 49/77] Trim whitespace when adding course authors. --- cms/djangoapps/contentstore/views/user.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py index ee6b0bf84d..020e4b5cb9 100644 --- a/cms/djangoapps/contentstore/views/user.py +++ b/cms/djangoapps/contentstore/views/user.py @@ -83,6 +83,9 @@ def add_user(request, location): } return JsonResponse(msg, 400) + # remove leading/trailing whitespace if necessary + email = email.strip() + # check that logged in user has admin permissions to this course if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME): raise PermissionDenied() From a9fb82cae6c7f8786bdb29f400591b71cc3c30ab Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 22 Jul 2013 13:20:50 -0400 Subject: [PATCH 50/77] Make XModuleDescriptor use the XBlock children API N.B. When we are in a world of mixed XModules and XBlocks, the system/runtime will have to be cognizant of that when asked to return a XModule from an XModuleDescriptor child [#LMS-190] --- common/lib/xmodule/xmodule/x_module.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index fa18d79f77..89f72e8099 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -202,6 +202,13 @@ class XModule(XModuleFields, HTMLSnippet, XBlock): ''' if self._loaded_children is None: child_descriptors = self.get_child_descriptors() + + # This deliberately uses system.get_module, rather than runtime.get_block, + # because we're looking at XModule children, rather than XModuleDescriptor children. + # That means it can use the deprecated XModule apis, rather than future XBlock apis + + # TODO: Once we're in a system where this returns a mix of XModuleDescriptors + # and XBlocks, we're likely to have to change this more children = [self.system.get_module(descriptor) for descriptor in child_descriptors] # get_module returns None if the current user doesn't have access # to the location. @@ -493,7 +500,7 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): child = child_loc else: try: - child = self.system.load_item(child_loc) + child = self.runtime.get_block(child_loc) except ItemNotFoundError: log.exception('Unable to load item {loc}, skipping'.format(loc=child_loc)) continue @@ -807,6 +814,10 @@ class DescriptorSystem(object): self.resources_fs = resources_fs self.error_tracker = error_tracker + def get_block(self, block_id): + """See documentation for `xblock.runtime:Runtime.get_block`""" + return self.load_item(block_id) + class XMLParsingSystem(DescriptorSystem): def __init__(self, load_item, resources_fs, error_tracker, process_xml, policy, **kwargs): From af522af2ca386173a9b015a113444f554e0fe75c Mon Sep 17 00:00:00 2001 From: Adam Palay Date: Mon, 22 Jul 2013 11:06:58 -0400 Subject: [PATCH 51/77] formatting --- common/lib/capa/capa/capa_problem.py | 14 ++- common/lib/capa/capa/correctmap.py | 34 ++++--- common/lib/capa/capa/responsetypes.py | 24 +++-- common/lib/capa/capa/xqueue_interface.py | 19 ++-- common/lib/xmodule/xmodule/capa_module.py | 8 +- lms/djangoapps/courseware/module_render.py | 102 ++++++++++++--------- 6 files changed, 115 insertions(+), 86 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 2c813f49d5..c4dbc56d63 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -242,11 +242,15 @@ class LoncapaProblem(object): return None # Get a list of timestamps of all queueing requests, then convert it to a DateTime object - queuetime_strs = [self.correct_map.get_queuetime_str(answer_id) - for answer_id in self.correct_map - if self.correct_map.is_queued(answer_id)] - queuetimes = [datetime.strptime(qt_str, xqueue_interface.dateformat) - for qt_str in queuetime_strs] + queuetime_strs = [ + self.correct_map.get_queuetime_str(answer_id) + for answer_id in self.correct_map + if self.correct_map.is_queued(answer_id) + ] + queuetimes = [ + datetime.strptime(qt_str, xqueue_interface.dateformat) + for qt_str in queuetime_strs + ] return max(queuetimes) diff --git a/common/lib/capa/capa/correctmap.py b/common/lib/capa/capa/correctmap.py index 950cd199fc..e50be92152 100644 --- a/common/lib/capa/capa/correctmap.py +++ b/common/lib/capa/capa/correctmap.py @@ -37,23 +37,27 @@ class CorrectMap(object): return self.cmap.__iter__() # See the documentation for 'set_dict' for the use of kwargs - def set(self, - answer_id=None, - correctness=None, - npoints=None, - msg='', - hint='', - hintmode=None, - queuestate=None, **kwargs): + def set( + self, + answer_id=None, + correctness=None, + npoints=None, + msg='', + hint='', + hintmode=None, + queuestate=None, + **kwargs + ): if answer_id is not None: - self.cmap[str(answer_id)] = {'correctness': correctness, - 'npoints': npoints, - 'msg': msg, - 'hint': hint, - 'hintmode': hintmode, - 'queuestate': queuestate, - } + self.cmap[str(answer_id)] = { + 'correctness': correctness, + 'npoints': npoints, + 'msg': msg, + 'hint': hint, + 'hintmode': hintmode, + 'queuestate': queuestate, + } def __repr__(self): return repr(self.cmap) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index f99518c8ce..7adf337fe9 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1365,9 +1365,11 @@ class CodeResponse(LoncapaResponse): # Note that submission can be a file submission = student_answers[self.answer_id] except Exception as err: - log.error('Error in CodeResponse %s: cannot get student answer for %s;' - ' student_answers=%s' % - (err, self.answer_id, convert_files_to_filenames(student_answers))) + log.error( + 'Error in CodeResponse %s: cannot get student answer for %s;' + ' student_answers=%s' % + (err, self.answer_id, convert_files_to_filenames(student_answers)) + ) raise Exception(err) # We do not support xqueue within Studio. @@ -1386,14 +1388,15 @@ class CodeResponse(LoncapaResponse): anonymous_student_id = self.system.anonymous_student_id # Generate header - queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime + - anonymous_student_id + - self.answer_id) + queuekey = xqueue_interface.make_hashkey( + str(self.system.seed) + qtime + anonymous_student_id + self.answer_id + ) callback_url = self.system.xqueue['construct_callback']() xheader = xqueue_interface.make_xheader( lms_callback_url=callback_url, lms_key=queuekey, - queue_name=self.queue_name) + queue_name=self.queue_name + ) # Generate body if is_list_of_files(submission): @@ -1406,9 +1409,10 @@ class CodeResponse(LoncapaResponse): # Metadata related to the student submission revealed to the external # grader - student_info = {'anonymous_student_id': anonymous_student_id, - 'submission_time': qtime, - } + student_info = { + 'anonymous_student_id': anonymous_student_id, + 'submission_time': qtime, + } contents.update({'student_info': json.dumps(student_info)}) # Submit request. When successful, 'msg' is the prior length of the diff --git a/common/lib/capa/capa/xqueue_interface.py b/common/lib/capa/capa/xqueue_interface.py index 5cf2488af0..4da8e11d53 100644 --- a/common/lib/capa/capa/xqueue_interface.py +++ b/common/lib/capa/capa/xqueue_interface.py @@ -30,9 +30,11 @@ def make_xheader(lms_callback_url, lms_key, queue_name): 'queue_name': designate a specific queue within xqueue server, e.g. 'MITx-6.00x' (string) } """ - return json.dumps({'lms_callback_url': lms_callback_url, - 'lms_key': lms_key, - 'queue_name': queue_name}) + return json.dumps({ + 'lms_callback_url': lms_callback_url, + 'lms_key': lms_key, + 'queue_name': queue_name + }) def parse_xreply(xreply): @@ -60,7 +62,7 @@ class XQueueInterface(object): ''' def __init__(self, url, django_auth, requests_auth=None): - self.url = url + self.url = url self.auth = django_auth self.session = requests.session(auth=requests_auth) @@ -95,13 +97,13 @@ class XQueueInterface(object): return (error, msg) - def _login(self): - payload = {'username': self.auth['username'], - 'password': self.auth['password']} + payload = { + 'username': self.auth['username'], + 'password': self.auth['password'] + } return self._http_post(self.url + '/xqueue/login/', payload) - def _send_to_queue(self, header, body, files_to_upload): payload = {'xqueue_header': header, 'xqueue_body': body} @@ -112,7 +114,6 @@ class XQueueInterface(object): return self._http_post(self.url + '/xqueue/submit/', payload, files=files) - def _http_post(self, url, data, files=None): try: r = self.session.post(url, data=data, files=files) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index e8f8baf45f..aa86cb6c5c 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -1126,8 +1126,12 @@ class CapaDescriptor(CapaFields, RawDescriptor): mako_template = "widgets/problem-edit.html" js = {'coffee': [resource_string(__name__, 'js/src/problem/edit.coffee')]} js_module_name = "MarkdownEditingDescriptor" - css = {'scss': [resource_string(__name__, 'css/editor/edit.scss'), - resource_string(__name__, 'css/problem/edit.scss')]} + css = { + 'scss': [ + resource_string(__name__, 'css/editor/edit.scss'), + resource_string(__name__, 'css/problem/edit.scss') + ] + } # Capa modules have some additional metadata: # TODO (vshnayder): do problems have any other metadata? Do they diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index de709f7652..56f1d206cd 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -213,22 +213,28 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours return None # Setup system context for module instance - ajax_url = reverse('modx_dispatch', - kwargs=dict(course_id=course_id, - location=descriptor.location.url(), - dispatch=''), - ) + ajax_url = reverse( + 'modx_dispatch', + kwargs=dict( + course_id=course_id, + location=descriptor.location.url(), + dispatch='' + ), + ) # Intended use is as {ajax_url}/{dispatch_command}, so get rid of the trailing slash. ajax_url = ajax_url.rstrip('/') def make_xqueue_callback(dispatch='score_update'): # Fully qualified callback URL for external queueing system - relative_xqueue_callback_url = reverse('xqueue_callback', - kwargs=dict(course_id=course_id, - userid=str(user.id), - mod_id=descriptor.location.url(), - dispatch=dispatch), - ) + relative_xqueue_callback_url = reverse( + 'xqueue_callback', + kwargs=dict( + course_id=course_id, + userid=str(user.id), + mod_id=descriptor.location.url(), + dispatch=dispatch + ), + ) return xqueue_callback_url_prefix + relative_xqueue_callback_url # Default queuename is course-specific and is derived from the course that @@ -313,10 +319,12 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours score_bucket = get_score_bucket(student_module.grade, student_module.max_grade) org, course_num, run = course_id.split("/") - tags = ["org:{0}".format(org), - "course:{0}".format(course_num), - "run:{0}".format(run), - "score_bucket:{0}".format(score_bucket)] + tags = [ + "org:{0}".format(org), + "course:{0}".format(course_num), + "run:{0}".format(run), + "score_bucket:{0}".format(score_bucket) + ] if grade_bucket_type is not None: tags.append('type:%s' % grade_bucket_type) @@ -326,38 +334,41 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours # TODO (cpennington): When modules are shared between courses, the static # prefix is going to have to be specific to the module, not the directory # that the xml was loaded from - system = ModuleSystem(track_function=track_function, - render_template=render_to_string, - ajax_url=ajax_url, - xqueue=xqueue, - # TODO (cpennington): Figure out how to share info between systems - filestore=descriptor.system.resources_fs, - get_module=inner_get_module, - user=user, - # TODO (cpennington): This should be removed when all html from - # a module is coming through get_html and is therefore covered - # by the replace_static_urls code below - replace_urls=partial( - static_replace.replace_static_urls, - data_directory=getattr(descriptor, 'data_dir', None), - course_namespace=descriptor.location._replace(category=None, name=None), - ), - node_path=settings.NODE_PATH, - xblock_model_data=xblock_model_data, - publish=publish, - anonymous_student_id=unique_id_for_user(user), - course_id=course_id, - open_ended_grading_interface=open_ended_grading_interface, - s3_interface=s3_interface, - cache=cache, - can_execute_unsafe_code=(lambda: can_execute_unsafe_code(course_id)), - ) + system = ModuleSystem( + track_function=track_function, + render_template=render_to_string, + ajax_url=ajax_url, + xqueue=xqueue, + # TODO (cpennington): Figure out how to share info between systems + filestore=descriptor.system.resources_fs, + get_module=inner_get_module, + user=user, + # TODO (cpennington): This should be removed when all html from + # a module is coming through get_html and is therefore covered + # by the replace_static_urls code below + replace_urls=partial( + static_replace.replace_static_urls, + data_directory=getattr(descriptor, 'data_dir', None), + course_namespace=descriptor.location._replace(category=None, name=None), + ), + node_path=settings.NODE_PATH, + xblock_model_data=xblock_model_data, + publish=publish, + anonymous_student_id=unique_id_for_user(user), + course_id=course_id, + open_ended_grading_interface=open_ended_grading_interface, + s3_interface=s3_interface, + cache=cache, + can_execute_unsafe_code=(lambda: can_execute_unsafe_code(course_id)), + ) # pass position specified in URL to module through ModuleSystem system.set('position', position) system.set('DEBUG', settings.DEBUG) if settings.MITX_FEATURES.get('ENABLE_PSYCHOMETRICS'): - system.set('psychometrics_handler', # set callback for updating PsychometricsData - make_psychometrics_data_update_handler(course_id, user, descriptor.location.url())) + system.set( + 'psychometrics_handler', # set callback for updating PsychometricsData + make_psychometrics_data_update_handler(course_id, user, descriptor.location.url()) + ) try: module = descriptor.xmodule(system) @@ -381,13 +392,14 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours system.set('user_is_staff', has_access(user, descriptor.location, 'staff', course_id)) _get_html = module.get_html - if wrap_xmodule_display == True: + if wrap_xmodule_display is True: _get_html = wrap_xmodule(module.get_html, module, 'xmodule_display.html') module.get_html = replace_static_urls( _get_html, getattr(descriptor, 'data_dir', None), - course_namespace=module.location._replace(category=None, name=None)) + course_namespace=module.location._replace(category=None, name=None) + ) # Allow URLs of the form '/course/' refer to the root of multicourse directory # hierarchy of this course From b744aaa3609c1d98abedfdcdaadc082e6eb6485b Mon Sep 17 00:00:00 2001 From: Adam Palay Date: Mon, 22 Jul 2013 16:12:07 -0400 Subject: [PATCH 52/77] make sure parsed times are set to UTC --- common/djangoapps/student/views.py | 4 +- common/lib/capa/capa/capa_problem.py | 56 +++++++++++++++++++--------- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 398a3f6efc..433578f3e9 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -111,9 +111,9 @@ def get_date_for_press(publish_date): # strip off extra months, and just use the first: date = re.sub(multimonth_pattern, ", ", publish_date) if re.search(day_pattern, date): - date = datetime.datetime.strptime(date, "%B %d, %Y") + date = datetime.datetime.strptime(date, "%B %d, %Y").replace(tzinfo=UTC) else: - date = datetime.datetime.strptime(date, "%B, %Y") + date = datetime.datetime.strptime(date, "%B, %Y").replace(tzinfo=UTC) return date diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index c4dbc56d63..c2bdeadc21 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -32,6 +32,8 @@ import capa.xqueue_interface as xqueue_interface import capa.responsetypes as responsetypes from capa.safe_exec import safe_exec +from pytz import UTC + # dict of tagname, Response Class -- this should come from auto-registering response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__]) @@ -42,13 +44,22 @@ solution_tags = ['solution'] response_properties = ["codeparam", "responseparam", "answer", "openendedparam"] # special problem tags which should be turned into innocuous HTML -html_transforms = {'problem': {'tag': 'div'}, - 'text': {'tag': 'span'}, - 'math': {'tag': 'span'}, - } +html_transforms = { + 'problem': {'tag': 'div'}, + 'text': {'tag': 'span'}, + 'math': {'tag': 'span'}, +} # These should be removed from HTML output, including all subelements -html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup", "openendedparam", "openendedrubric"] +html_problem_semantics = [ + "codeparam", + "responseparam", + "answer", + "script", + "hintgroup", + "openendedparam", + "openendedrubric" +] log = logging.getLogger(__name__) @@ -248,7 +259,7 @@ class LoncapaProblem(object): if self.correct_map.is_queued(answer_id) ] queuetimes = [ - datetime.strptime(qt_str, xqueue_interface.dateformat) + datetime.strptime(qt_str, xqueue_interface.dateformat).replace(tzinfo=UTC) for qt_str in queuetime_strs ] @@ -408,10 +419,16 @@ class LoncapaProblem(object): # open using ModuleSystem OSFS filestore ifp = self.system.filestore.open(filename) except Exception as err: - log.warning('Error %s in problem xml include: %s' % ( - err, etree.tostring(inc, pretty_print=True))) - log.warning('Cannot find file %s in %s' % ( - filename, self.system.filestore)) + log.warning( + 'Error %s in problem xml include: %s' % ( + err, etree.tostring(inc, pretty_print=True) + ) + ) + log.warning( + 'Cannot find file %s in %s' % ( + filename, self.system.filestore + ) + ) # if debugging, don't fail - just log error # TODO (vshnayder): need real error handling, display to users if not self.system.get('DEBUG'): @@ -422,8 +439,11 @@ class LoncapaProblem(object): # read in and convert to XML incxml = etree.XML(ifp.read()) except Exception as err: - log.warning('Error %s in problem xml include: %s' % ( - err, etree.tostring(inc, pretty_print=True))) + log.warning( + 'Error %s in problem xml include: %s' % ( + err, etree.tostring(inc, pretty_print=True) + ) + ) log.warning('Cannot parse XML in %s' % (filename)) # if debugging, don't fail - just log error # TODO (vshnayder): same as above @@ -583,8 +603,9 @@ class LoncapaProblem(object): # let each Response render itself if problemtree in self.responders: overall_msg = self.correct_map.get_overall_message() - return self.responders[problemtree].render_html(self._extract_html, - response_msg=overall_msg) + return self.responders[problemtree].render_html( + self._extract_html, response_msg=overall_msg + ) # let each custom renderer render itself: if problemtree.tag in customrender.registry.registered_tags(): @@ -632,9 +653,10 @@ class LoncapaProblem(object): answer_id = 1 input_tags = inputtypes.registry.registered_tags() - inputfields = tree.xpath("|".join(['//' + response.tag + '[@id=$id]//' + x - for x in (input_tags + solution_tags)]), - id=response_id_str) + inputfields = tree.xpath( + "|".join(['//' + response.tag + '[@id=$id]//' + x for x in (input_tags + solution_tags)]), + id=response_id_str + ) # assign one answer_id for each input type or solution type for entry in inputfields: From b2b3a50400662bd8867ad78edf97e440b0289c93 Mon Sep 17 00:00:00 2001 From: Adam Palay Date: Mon, 22 Jul 2013 16:21:47 -0400 Subject: [PATCH 53/77] convert datetime.now() to datetime.now(UTC) for xqueue --- common/lib/capa/capa/responsetypes.py | 3 ++- common/lib/capa/capa/tests/test_responsetypes.py | 13 +++++++++---- common/lib/xmodule/xmodule/fields.py | 1 + .../open_ended_grading_classes/open_ended_module.py | 5 +++-- .../xmodule/tests/test_combined_open_ended.py | 6 ++++-- 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 7adf337fe9..03c82ea218 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -33,6 +33,7 @@ from shapely.geometry import Point, MultiPoint from calc import evaluator, UndefinedVariable from . import correctmap from datetime import datetime +from pytz import UTC from .util import * from lxml import etree from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME? @@ -1383,7 +1384,7 @@ class CodeResponse(LoncapaResponse): #------------------------------------------------------------ qinterface = self.system.xqueue['interface'] - qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat) + qtime = datetime.strftime(datetime.now(UTC), xqueue_interface.dateformat) anonymous_student_id = self.system.anonymous_student_id diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 2a15145579..5679fcef93 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -18,6 +18,8 @@ from capa.correctmap import CorrectMap from capa.util import convert_files_to_filenames from capa.xqueue_interface import dateformat +from pytz import UTC + class ResponseTest(unittest.TestCase): """ Base class for tests of capa responses.""" @@ -333,8 +335,9 @@ class SymbolicResponseTest(ResponseTest): correct_map = problem.grade_answers(input_dict) - self.assertEqual(correct_map.get_correctness('1_2_1'), - expected_correctness) + self.assertEqual( + correct_map.get_correctness('1_2_1'), expected_correctness + ) class OptionResponseTest(ResponseTest): @@ -778,13 +781,15 @@ class CodeResponseTest(ResponseTest): cmap = CorrectMap() for i, answer_id in enumerate(answer_ids): queuekey = 1000 + i - latest_timestamp = datetime.now() + latest_timestamp = datetime.now(UTC) queuestate = CodeResponseTest.make_queuestate(queuekey, latest_timestamp) cmap.update(CorrectMap(answer_id=answer_id, queuestate=queuestate)) self.problem.correct_map.update(cmap) # Queue state only tracks up to second - latest_timestamp = datetime.strptime(datetime.strftime(latest_timestamp, dateformat), dateformat) + latest_timestamp = datetime.strptime( + datetime.strftime(latest_timestamp, dateformat), dateformat + ).replace(tzinfo=UTC) self.assertEquals(self.problem.get_recentmost_queuetime(), latest_timestamp) diff --git a/common/lib/xmodule/xmodule/fields.py b/common/lib/xmodule/xmodule/fields.py index 465993a51f..dc2f000286 100644 --- a/common/lib/xmodule/xmodule/fields.py +++ b/common/lib/xmodule/xmodule/fields.py @@ -80,6 +80,7 @@ class Date(ModelType): TIMEDELTA_REGEX = re.compile(r'^((?P\d+?) day(?:s?))?(\s)?((?P\d+?) hour(?:s?))?(\s)?((?P\d+?) minute(?:s)?)?(\s)?((?P\d+?) second(?:s)?)?$') + class Timedelta(ModelType): def from_json(self, time_str): """ diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py index 8d8a85f788..2e7a3eaf89 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py @@ -19,6 +19,7 @@ import openendedchild from numpy import median from datetime import datetime +from pytz import UTC from .combined_open_ended_rubric import CombinedOpenEndedRubric @@ -170,7 +171,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): if xqueue is None: return {'success': False, 'msg': "Couldn't submit feedback."} qinterface = xqueue['interface'] - qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat) + qtime = datetime.strftime(datetime.now(UTC), xqueue_interface.dateformat) anonymous_student_id = system.anonymous_student_id queuekey = xqueue_interface.make_hashkey(str(system.seed) + qtime + anonymous_student_id + @@ -224,7 +225,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): if xqueue is None: return False qinterface = xqueue['interface'] - qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat) + qtime = datetime.strftime(datetime.now(UTC), xqueue_interface.dateformat) anonymous_student_id = system.anonymous_student_id diff --git a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py index 8162d588bb..4fd0ddccf7 100644 --- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py @@ -14,6 +14,7 @@ from xmodule.modulestore import Location from lxml import etree import capa.xqueue_interface as xqueue_interface from datetime import datetime +from pytz import UTC import logging log = logging.getLogger(__name__) @@ -212,7 +213,7 @@ class OpenEndedModuleTest(unittest.TestCase): 'submission_id': '1', 'grader_id': '1', 'score': 3} - qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat) + qtime = datetime.strftime(datetime.now(UTC), xqueue_interface.dateformat) student_info = {'anonymous_student_id': self.test_system.anonymous_student_id, 'submission_time': qtime} contents = { @@ -233,7 +234,7 @@ class OpenEndedModuleTest(unittest.TestCase): def test_send_to_grader(self): submission = "This is a student submission" - qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat) + qtime = datetime.strftime(datetime.now(UTC), xqueue_interface.dateformat) student_info = {'anonymous_student_id': self.test_system.anonymous_student_id, 'submission_time': qtime} contents = self.openendedmodule.payload.copy() @@ -632,6 +633,7 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore): module.handle_ajax("reset", {}) self.assertEqual(module.state, "initial") + class OpenEndedModuleXmlAttemptTest(unittest.TestCase, DummyModulestore): """ Test if student is able to reset the problem From 2b404622639d1b03db92c86340df7f7cebc9824e Mon Sep 17 00:00:00 2001 From: Adam Palay Date: Mon, 22 Jul 2013 16:36:30 -0400 Subject: [PATCH 54/77] convert all datetime.now() to datetime.now(UTC) --- common/djangoapps/heartbeat/views.py | 4 +- common/djangoapps/student/models.py | 58 ++++++++++--------- common/djangoapps/student/views.py | 4 +- .../lib/capa/capa/tests/test_responsetypes.py | 4 +- .../openendedchild.py | 10 +++- i18n/tests/test_extract.py | 25 ++++---- i18n/tests/test_generate.py | 11 +++- .../management/commands/ungenerated_certs.py | 8 +-- 8 files changed, 71 insertions(+), 53 deletions(-) diff --git a/common/djangoapps/heartbeat/views.py b/common/djangoapps/heartbeat/views.py index d7c3a32192..0cee7116b4 100644 --- a/common/djangoapps/heartbeat/views.py +++ b/common/djangoapps/heartbeat/views.py @@ -1,16 +1,18 @@ import json from datetime import datetime +from pytz import UTC from django.http import HttpResponse from xmodule.modulestore.django import modulestore from dogapi import dog_stats_api + @dog_stats_api.timed('edxapp.heartbeat') def heartbeat(request): """ Simple view that a loadbalancer can check to verify that the app is up """ output = { - 'date': datetime.now().isoformat(), + 'date': datetime.now(UTC).isoformat(), 'courses': [course.location.url() for course in modulestore().get_courses()], } return HttpResponse(json.dumps(output, indent=4)) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index af93c34317..e6530338a8 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -69,30 +69,33 @@ class UserProfile(models.Model): location = models.CharField(blank=True, max_length=255, db_index=True) # Optional demographic data we started capturing from Fall 2012 - this_year = datetime.now().year + this_year = datetime.now(UTC).year VALID_YEARS = range(this_year, this_year - 120, -1) year_of_birth = models.IntegerField(blank=True, null=True, db_index=True) GENDER_CHOICES = (('m', 'Male'), ('f', 'Female'), ('o', 'Other')) - gender = models.CharField(blank=True, null=True, max_length=6, db_index=True, - choices=GENDER_CHOICES) + gender = models.CharField( + blank=True, null=True, max_length=6, db_index=True, choices=GENDER_CHOICES + ) # [03/21/2013] removed these, but leaving comment since there'll still be # p_se and p_oth in the existing data in db. # ('p_se', 'Doctorate in science or engineering'), # ('p_oth', 'Doctorate in another field'), - LEVEL_OF_EDUCATION_CHOICES = (('p', 'Doctorate'), - ('m', "Master's or professional degree"), - ('b', "Bachelor's degree"), - ('a', "Associate's degree"), - ('hs', "Secondary/high school"), - ('jhs', "Junior secondary/junior high/middle school"), - ('el', "Elementary/primary school"), - ('none', "None"), - ('other', "Other")) + LEVEL_OF_EDUCATION_CHOICES = ( + ('p', 'Doctorate'), + ('m', "Master's or professional degree"), + ('b', "Bachelor's degree"), + ('a', "Associate's degree"), + ('hs', "Secondary/high school"), + ('jhs', "Junior secondary/junior high/middle school"), + ('el', "Elementary/primary school"), + ('none', "None"), + ('other', "Other") + ) level_of_education = models.CharField( - blank=True, null=True, max_length=6, db_index=True, - choices=LEVEL_OF_EDUCATION_CHOICES - ) + blank=True, null=True, max_length=6, db_index=True, + choices=LEVEL_OF_EDUCATION_CHOICES + ) mailing_address = models.TextField(blank=True, null=True) goals = models.TextField(blank=True, null=True) allow_certificate = models.BooleanField(default=1) @@ -307,18 +310,18 @@ class TestCenterUserForm(ModelForm): ACCOMMODATION_REJECTED_CODE = 'NONE' ACCOMMODATION_CODES = ( - (ACCOMMODATION_REJECTED_CODE, 'No Accommodation Granted'), - ('EQPMNT', 'Equipment'), - ('ET12ET', 'Extra Time - 1/2 Exam Time'), - ('ET30MN', 'Extra Time - 30 Minutes'), - ('ETDBTM', 'Extra Time - Double Time'), - ('SEPRMM', 'Separate Room'), - ('SRREAD', 'Separate Room and Reader'), - ('SRRERC', 'Separate Room and Reader/Recorder'), - ('SRRECR', 'Separate Room and Recorder'), - ('SRSEAN', 'Separate Room and Service Animal'), - ('SRSGNR', 'Separate Room and Sign Language Interpreter'), - ) + (ACCOMMODATION_REJECTED_CODE, 'No Accommodation Granted'), + ('EQPMNT', 'Equipment'), + ('ET12ET', 'Extra Time - 1/2 Exam Time'), + ('ET30MN', 'Extra Time - 30 Minutes'), + ('ETDBTM', 'Extra Time - Double Time'), + ('SEPRMM', 'Separate Room'), + ('SRREAD', 'Separate Room and Reader'), + ('SRRERC', 'Separate Room and Reader/Recorder'), + ('SRRECR', 'Separate Room and Recorder'), + ('SRSEAN', 'Separate Room and Service Animal'), + ('SRSGNR', 'Separate Room and Sign Language Interpreter'), +) ACCOMMODATION_CODE_DICT = {code: name for (code, name) in ACCOMMODATION_CODES} @@ -572,7 +575,6 @@ class TestCenterRegistrationForm(ModelForm): return code - def get_testcenter_registration(user, course_id, exam_series_code): try: tcu = TestCenterUser.objects.get(user=user) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 433578f3e9..553643bde7 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -1100,7 +1100,7 @@ def confirm_email_change(request, key): meta = up.get_meta() if 'old_emails' not in meta: meta['old_emails'] = [] - meta['old_emails'].append([user.email, datetime.datetime.now().isoformat()]) + meta['old_emails'].append([user.email, datetime.datetime.now(UTC).isoformat()]) up.set_meta(meta) up.save() # Send it to the old email... @@ -1198,7 +1198,7 @@ def accept_name_change_by_id(id): meta = up.get_meta() if 'old_names' not in meta: meta['old_names'] = [] - meta['old_names'].append([up.name, pnc.rationale, datetime.datetime.now().isoformat()]) + meta['old_names'].append([up.name, pnc.rationale, datetime.datetime.now(UTC).isoformat()]) up.set_meta(meta) up.name = pnc.new_name diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 5679fcef93..a756dc640e 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -705,7 +705,7 @@ class CodeResponseTest(ResponseTest): # Now we queue the LCP cmap = CorrectMap() for i, answer_id in enumerate(answer_ids): - queuestate = CodeResponseTest.make_queuestate(i, datetime.now()) + queuestate = CodeResponseTest.make_queuestate(i, datetime.now(UTC)) cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate)) self.problem.correct_map.update(cmap) @@ -721,7 +721,7 @@ class CodeResponseTest(ResponseTest): old_cmap = CorrectMap() for i, answer_id in enumerate(answer_ids): queuekey = 1000 + i - queuestate = CodeResponseTest.make_queuestate(queuekey, datetime.now()) + queuestate = CodeResponseTest.make_queuestate(queuekey, datetime.now(UTC)) old_cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate)) # Message format common to external graders diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py index 047ab0244c..10f939b270 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py @@ -5,6 +5,7 @@ import re import open_ended_image_submission from xmodule.progress import Progress +import capa.xqueue_interface as xqueue_interface from capa.util import * from .peer_grading_service import PeerGradingService, MockPeerGradingService import controller_query_service @@ -334,12 +335,15 @@ class OpenEndedChild(object): log.exception("Could not create image and check it.") if image_ok: - image_key = image_data.name + datetime.now().strftime("%Y%m%d%H%M%S") + image_key = image_data.name + datetime.now(UTC).strftime( + xqueue_interface.dateformat + ) try: image_data.seek(0) - success, s3_public_url = open_ended_image_submission.upload_to_s3(image_data, image_key, - self.s3_interface) + success, s3_public_url = open_ended_image_submission.upload_to_s3( + image_data, image_key, self.s3_interface + ) except: log.exception("Could not upload image to S3.") diff --git a/i18n/tests/test_extract.py b/i18n/tests/test_extract.py index 7e8b1a9d2b..3ef87a3736 100644 --- a/i18n/tests/test_extract.py +++ b/i18n/tests/test_extract.py @@ -1,7 +1,9 @@ -import os, polib +import os +import polib from unittest import TestCase from nose.plugins.skip import SkipTest from datetime import datetime, timedelta +from pytz import UTC import extract from config import CONFIGURATION @@ -9,6 +11,7 @@ from config import CONFIGURATION # Make sure setup runs only once SETUP_HAS_RUN = False + class TestExtract(TestCase): """ Tests functionality of i18n/extract.py @@ -19,20 +22,20 @@ class TestExtract(TestCase): # Skip this test because it takes too long (>1 minute) # TODO: figure out how to declare a "long-running" test suite # and add this test to it. - raise SkipTest() + raise SkipTest() global SETUP_HAS_RUN - + # Subtract 1 second to help comparisons with file-modify time succeed, # since os.path.getmtime() is not millisecond-accurate - self.start_time = datetime.now() - timedelta(seconds=1) + self.start_time = datetime.now(UTC) - timedelta(seconds=1) super(TestExtract, self).setUp() if not SETUP_HAS_RUN: # Run extraction script. Warning, this takes 1 minute or more extract.main() SETUP_HAS_RUN = True - def get_files (self): + def get_files(self): """ This is a generator. Returns the fully expanded filenames for all extracted files @@ -65,19 +68,21 @@ class TestExtract(TestCase): entry2.msgid = "This is not a keystring" self.assertTrue(extract.is_key_string(entry1.msgid)) self.assertFalse(extract.is_key_string(entry2.msgid)) - + def test_headers(self): """Verify all headers have been modified""" for path in self.get_files(): po = polib.pofile(path) header = po.header - self.assertEqual(header.find('edX translation file'), 0, - msg='Missing header in %s:\n"%s"' % \ - (os.path.basename(path), header)) + self.assertEqual( + header.find('edX translation file'), + 0, + msg='Missing header in %s:\n"%s"' % (os.path.basename(path), header) + ) def test_metadata(self): """Verify all metadata has been modified""" - for path in self.get_files(): + for path in self.get_files(): po = polib.pofile(path) metadata = po.metadata value = metadata['Report-Msgid-Bugs-To'] diff --git a/i18n/tests/test_generate.py b/i18n/tests/test_generate.py index 468858664f..b9a36ada33 100644 --- a/i18n/tests/test_generate.py +++ b/i18n/tests/test_generate.py @@ -1,11 +1,16 @@ -import os, string, random, re +import os +import string +import random +import re from polib import pofile from unittest import TestCase from datetime import datetime, timedelta +from pytz import UTC import generate from config import CONFIGURATION + class TestGenerate(TestCase): """ Tests functionality of i18n/generate.py @@ -15,7 +20,7 @@ class TestGenerate(TestCase): def setUp(self): # Subtract 1 second to help comparisons with file-modify time succeed, # since os.path.getmtime() is not millisecond-accurate - self.start_time = datetime.now() - timedelta(seconds=1) + self.start_time = datetime.now(UTC) - timedelta(seconds=1) def test_merge(self): """ @@ -49,7 +54,7 @@ class TestGenerate(TestCase): """ This is invoked by test_main to ensure that it runs after calling generate.main(). - + There should be exactly three merge comment headers in our merged .po file. This counts them to be sure. A merge comment looks like this: diff --git a/lms/djangoapps/certificates/management/commands/ungenerated_certs.py b/lms/djangoapps/certificates/management/commands/ungenerated_certs.py index ab1459766a..c9f944158a 100644 --- a/lms/djangoapps/certificates/management/commands/ungenerated_certs.py +++ b/lms/djangoapps/certificates/management/commands/ungenerated_certs.py @@ -8,6 +8,7 @@ from xmodule.course_module import CourseDescriptor from xmodule.modulestore.django import modulestore from certificates.models import CertificateStatuses import datetime +from pytz import UTC class Command(BaseCommand): @@ -41,7 +42,6 @@ class Command(BaseCommand): 'whose entry in the certificate table matches STATUS. ' 'STATUS can be generating, unavailable, deleted, error ' 'or notpassing.'), - ) def handle(self, *args, **options): @@ -83,20 +83,20 @@ class Command(BaseCommand): xq = XQueueCertInterface() total = enrolled_students.count() count = 0 - start = datetime.datetime.now() + start = datetime.datetime.now(UTC) for student in enrolled_students: count += 1 if count % STATUS_INTERVAL == 0: # Print a status update with an approximation of # how much time is left based on how long the last # interval took - diff = datetime.datetime.now() - start + diff = datetime.datetime.now(UTC) - start timeleft = diff * (total - count) / STATUS_INTERVAL hours, remainder = divmod(timeleft.seconds, 3600) minutes, seconds = divmod(remainder, 60) print "{0}/{1} completed ~{2:02}:{3:02}m remaining".format( count, total, hours, minutes) - start = datetime.datetime.now() + start = datetime.datetime.now(UTC) if certificate_status_for_student( student, course_id)['status'] in valid_statuses: From 4ee16f166461956f9a558fe87a5a41612dd25363 Mon Sep 17 00:00:00 2001 From: Sarina Canelake Date: Mon, 22 Jul 2013 14:47:37 -0400 Subject: [PATCH 55/77] Add diff-quality rake task (`rake quality`) --- jenkins/test.sh | 3 +++ rakelib/quality.rake | 14 ++++++++++++++ rakelib/tests.rake | 2 +- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/jenkins/test.sh b/jenkins/test.sh index 70a9e168bc..60dd59f7c0 100755 --- a/jenkins/test.sh +++ b/jenkins/test.sh @@ -77,6 +77,9 @@ rake pylint > pylint.log || cat pylint.log # Generate coverage reports rake coverage +# Generate quality reports +rake quality + rake autodeploy_properties github_status state:success "passed" diff --git a/rakelib/quality.rake b/rakelib/quality.rake index b9254528cf..7cbe10ce1f 100644 --- a/rakelib/quality.rake +++ b/rakelib/quality.rake @@ -43,3 +43,17 @@ end end task :pep8 => :"pep8:#{system}" end + +dquality_dir = File.join(REPORT_DIR, "diff_quality") +directory dquality_dir + +desc "Build the html diff quality reports, and print the reports to the console." +task :quality => dquality_dir do + # Generage diff-quality html report for pep8, and print to console + sh("diff-quality --violations=pep8 --html-report #{dquality_dir}/diff_quality_pep8.html") + sh("diff-quality --violations=pep8") + + # Generage diff-quality html report for pylint, and print to console + sh("diff-quality --violations=pylint --html-report #{dquality_dir}/diff_quality_pylint.html") + sh("diff-quality --violations=pylint") +end \ No newline at end of file diff --git a/rakelib/tests.rake b/rakelib/tests.rake index 7e3e672f39..57861902bc 100644 --- a/rakelib/tests.rake +++ b/rakelib/tests.rake @@ -161,4 +161,4 @@ task :coverage => :report_dirs do if not found_coverage_info puts "No coverage info found. Run `rake test` before running `rake coverage`." end -end +end \ No newline at end of file From 1c79b9c8747abc0513e0adc78c0b0876718e4a0f Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Sat, 20 Jul 2013 21:02:24 -0400 Subject: [PATCH 56/77] add a /jump_to_id/ shortcut for producing more durable links between courseware in Studio --- cms/templates/unit.html | 2 +- common/djangoapps/static_replace/__init__.py | 21 ++++++++++++++++++- common/djangoapps/xmodule_modifiers.py | 13 ++++++++++++ .../test/data/toy/html/secret/toyjumpto.html | 1 + .../test/data/toy/html/secret/toyjumpto.xml | 1 + lms/djangoapps/courseware/module_render.py | 11 +++++++++- lms/djangoapps/courseware/tests/test_views.py | 13 ++++++++++++ lms/djangoapps/courseware/views.py | 21 +++++++++++++++++++ lms/urls.py | 2 ++ 9 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 common/test/data/toy/html/secret/toyjumpto.html create mode 100644 common/test/data/toy/html/secret/toyjumpto.xml diff --git a/cms/templates/unit.html b/cms/templates/unit.html index 300d631421..22333fd85f 100644 --- a/cms/templates/unit.html +++ b/cms/templates/unit.html @@ -171,7 +171,7 @@

          ${_("Unit Location")}

          -
          +
          ${_("Unit Identifier:")} 
          1. ${section.display_name_with_default} diff --git a/common/djangoapps/static_replace/__init__.py b/common/djangoapps/static_replace/__init__.py index b73a658c5f..4a4a3fe576 100644 --- a/common/djangoapps/static_replace/__init__.py +++ b/common/djangoapps/static_replace/__init__.py @@ -43,6 +43,26 @@ def try_staticfiles_lookup(path): return url +def replace_jump_to_id_urls(text, course_id, jump_to_id_base_url): + """ + This will replace a link to another piece of courseware to a 'jump_to' + URL that will redirect to the right place in the courseware + + NOTE: This is similar to replace_course_urls in terms of functionality + but it is intended to be used when we only have a 'id' that the + course author provides. This is much more helpful when using + Studio authored courses since they don't need to know the path. This + is also durable with respect to item moves. + """ + + def replace_jump_to_id_url(match): + quote = match.group('quote') + rest = match.group('rest') + return "".join([quote, jump_to_id_base_url + rest, quote]) + + return re.sub(_url_replace_regex('/jump_to_id/'), replace_jump_to_id_url, text) + + def replace_course_urls(text, course_id): """ Replace /course/$stuff urls with /courses/$course_id/$stuff urls @@ -53,7 +73,6 @@ def replace_course_urls(text, course_id): returns: text with the links replaced """ - def replace_course_url(match): quote = match.group('quote') rest = match.group('rest') diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py index 3efc04789e..b0fa557c5d 100644 --- a/common/djangoapps/xmodule_modifiers.py +++ b/common/djangoapps/xmodule_modifiers.py @@ -42,6 +42,19 @@ def wrap_xmodule(get_html, module, template, context=None): return _get_html +def replace_jump_to_id_urls(get_html, course_id, jump_to_id_base_url): + """ + This will replace a link between courseware in the format + /jump_to/ with a URL for a page that will correctly redirect + This is similar to replace_course_urls, but much more flexible and + durable for Studio authored courses. See more comments in static_replace.replace_jump_to_urls + """ + @wraps(get_html) + def _get_html(): + return static_replace.replace_jump_to_id_urls(get_html(), course_id, jump_to_id_base_url) + return _get_html + + def replace_course_urls(get_html, course_id): """ Updates the supplied module with a new get_html function that wraps diff --git a/common/test/data/toy/html/secret/toyjumpto.html b/common/test/data/toy/html/secret/toyjumpto.html new file mode 100644 index 0000000000..779cd78d7c --- /dev/null +++ b/common/test/data/toy/html/secret/toyjumpto.html @@ -0,0 +1 @@ +This is a link to another page diff --git a/common/test/data/toy/html/secret/toyjumpto.xml b/common/test/data/toy/html/secret/toyjumpto.xml new file mode 100644 index 0000000000..af9cdf4f74 --- /dev/null +++ b/common/test/data/toy/html/secret/toyjumpto.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index de709f7652..01b2451d3a 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -27,7 +27,7 @@ from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.x_module import ModuleSystem -from xmodule_modifiers import replace_course_urls, replace_static_urls, add_histogram, wrap_xmodule, save_module # pylint: disable=F0401 +from xmodule_modifiers import replace_course_urls, replace_jump_to_id_urls, replace_static_urls, add_histogram, wrap_xmodule, save_module # pylint: disable=F0401 import static_replace from psychometrics.psychoanalyze import make_psychometrics_data_update_handler @@ -393,6 +393,15 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours # hierarchy of this course module.get_html = replace_course_urls(module.get_html, course_id) + # this will rewrite intra-courseware links + # that use the shorthand /jump_to_id/. This is very helpful + # for studio authored courses (compared to the /course/... format) since it is + # is durable with respect to moves and the author doesn't need to + # know the hierarchy + module.get_html = replace_jump_to_id_urls(module.get_html, course_id, + reverse('jump_to_id', + kwargs={'course_id': course_id, 'module_id': ''})) + if settings.MITX_FEATURES.get('DISPLAY_HISTOGRAMS_TO_STAFF'): if has_access(user, module, 'staff', course_id): module.get_html = add_histogram(module.get_html, module, user) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 07be74c98e..c80e314cec 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -44,6 +44,19 @@ class TestJumpTo(TestCase): response = self.client.get(jumpto_url) self.assertRedirects(response, expected, status_code=302, target_status_code=302) + def test_jumpto_id(self): + location = Location('i4x', 'edX', 'toy', 'chapter', 'Overview') + jumpto_url = '%s/%s/jump_to_id/%s' % ('/courses', self.course_name, location.name) + expected = 'courses/edX/toy/2012_Fall/courseware/Overview/' + response = self.client.get(jumpto_url) + self.assertRedirects(response, expected, status_code=302, target_status_code=302) + + def test_jumpto_id_invalid_location(self): + location = Location('i4x', 'edX', 'toy', 'NoSuchPlace', None) + jumpto_url = '%s/%s/jump_to/%s' % ('/courses', self.course_name, location.name) + response = self.client.get(jumpto_url) + self.assertEqual(response.status_code, 404) + class ViewsTestCase(TestCase): def setUp(self): diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index f1e1f7660c..81e1ad2427 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -33,6 +33,7 @@ from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem from xmodule.modulestore.search import path_to_location +from xmodule.course_module import CourseDescriptor import comment_client @@ -446,6 +447,26 @@ def index(request, course_id, chapter=None, section=None, return result +@ensure_csrf_cookie +def jump_to_id(request, course_id, module_id): + """ + This entry point allows for a shorter version of a jump to where just the id of the element is + passed in. This assumes that id is unique within the course_id namespace + """ + + course_location = CourseDescriptor.id_to_location(course_id) + + items = modulestore().get_items(['i4x', course_location.org, course_location.course, None, module_id]) + + if len(items) == 0: + raise Http404("Could not find id = {0} in course_id = {1}".format(module_id, course_id)) + if len(items) > 1: + logging.warning("Multiple items found with id = {0} in course_id = {1}. Using first found {2}...". + format(module_id, course_id, items[0].location.url())) + + return jump_to(request, course_id, items[0].location.url()) + + @ensure_csrf_cookie def jump_to(request, course_id, location): """ diff --git a/lms/urls.py b/lms/urls.py index 6c32face81..9670a67a4a 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -177,6 +177,8 @@ if settings.COURSEWARE_ENABLED: urlpatterns += ( url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/jump_to/(?P.*)$', 'courseware.views.jump_to', name="jump_to"), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/jump_to_id/(?P.*)$', + 'courseware.views.jump_to_id', name="jump_to_id"), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/modx/(?P.*?)/(?P[^/]*)$', 'courseware.module_render.modx_dispatch', name='modx_dispatch'), From 63c5cfda94fcf4e7b27cbddae0941135905501e2 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Sat, 20 Jul 2013 21:51:11 -0400 Subject: [PATCH 57/77] add comment and fix mistake in test --- lms/djangoapps/courseware/module_render.py | 2 ++ lms/djangoapps/courseware/tests/test_views.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 01b2451d3a..6d8b244f27 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -398,6 +398,8 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours # for studio authored courses (compared to the /course/... format) since it is # is durable with respect to moves and the author doesn't need to # know the hierarchy + # NOTE: module_id is empty string here. The 'module_id' will get assigned in the replacement + # function, we just need to specify something to get the reverse() to work module.get_html = replace_jump_to_id_urls(module.get_html, course_id, reverse('jump_to_id', kwargs={'course_id': course_id, 'module_id': ''})) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index c80e314cec..081d922114 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -53,7 +53,7 @@ class TestJumpTo(TestCase): def test_jumpto_id_invalid_location(self): location = Location('i4x', 'edX', 'toy', 'NoSuchPlace', None) - jumpto_url = '%s/%s/jump_to/%s' % ('/courses', self.course_name, location.name) + jumpto_url = '%s/%s/jump_to_id/%s' % ('/courses', self.course_name, location.name) response = self.client.get(jumpto_url) self.assertEqual(response.status_code, 404) From 059450c0de0013e87ea92ac599b9d996e15dbf7c Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Mon, 22 Jul 2013 09:28:10 -0400 Subject: [PATCH 58/77] Studio: revises HTML and styling around new unit ID on unit view --- cms/static/sass/views/_unit.scss | 19 +++++++++++++++++++ cms/templates/unit.html | 7 ++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/cms/static/sass/views/_unit.scss b/cms/static/sass/views/_unit.scss index 45e9823c2e..06685ad96b 100644 --- a/cms/static/sass/views/_unit.scss +++ b/cms/static/sass/views/_unit.scss @@ -747,6 +747,7 @@ body.unit { // Unit Page Sidebar .unit-settings { + .window-contents { padding: $baseline/2 $baseline; } @@ -854,6 +855,24 @@ body.unit { } .unit-location { + + // unit id + .wrapper-unit-id { + + .unit-id { + + .label { + @extend .t-title7; + margin-bottom: ($baseline/4); + color: $gray-d1; + } + + .value { + margin-bottom: 0; + } + } + } + .url { box-shadow: none; width: 100%; diff --git a/cms/templates/unit.html b/cms/templates/unit.html index 22333fd85f..c31d5254e4 100644 --- a/cms/templates/unit.html +++ b/cms/templates/unit.html @@ -171,7 +171,12 @@

            ${_("Unit Location")}

            -
            ${_("Unit Identifier:")} 
            +
            +

            + ${_("Unit Identifier:")} + +

            +
            1. ${section.display_name_with_default} From 80e0b99342d68a3bae0ad5106e123f1da125656d Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Mon, 22 Jul 2013 12:14:52 -0400 Subject: [PATCH 59/77] add a reference in the toy course to the test jump_to_id HTML module --- common/test/data/toy/course/2012_Fall.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/common/test/data/toy/course/2012_Fall.xml b/common/test/data/toy/course/2012_Fall.xml index 8f0125ef2d..4bd311c328 100644 --- a/common/test/data/toy/course/2012_Fall.xml +++ b/common/test/data/toy/course/2012_Fall.xml @@ -3,6 +3,7 @@ +