Merge branch 'wip_MDL-49327_2.9_guide_getinstance' of https://github.com/nixorv/moodle
authorDan Poltawski <dan@moodle.com>
Tue, 24 Mar 2015 09:29:06 +0000 (09:29 +0000)
committerDan Poltawski <dan@moodle.com>
Tue, 24 Mar 2015 09:29:06 +0000 (09:29 +0000)
Conflicts:
grade/grading/form/guide/tests/guide_test.php

55 files changed:
calendar/lib.php
calendar/tests/behat/calendar.feature
composer.json
course/modedit.php
course/modlib.php
course/moodleform_mod.php
enrol/paypal/lib.php
grade/report/user/styles.css
grade/tests/behat/grade_to_pass.feature [new file with mode: 0644]
lang/en/grades.php
lib/coursecatlib.php
lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-debug.js
lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-min.js
lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor.js
lib/editor/atto/yui/src/editor/js/clean.js
lib/editor/atto/yui/src/editor/js/editor.js
lib/tests/behat/behat_data_generators.php
lib/yui/build/moodle-core-checknet/moodle-core-checknet-debug.js
lib/yui/build/moodle-core-checknet/moodle-core-checknet-min.js
lib/yui/build/moodle-core-checknet/moodle-core-checknet.js
lib/yui/src/checknet/js/checknet.js
mod/choice/backup/moodle2/backup_choice_stepslib.php
mod/choice/db/install.xml
mod/choice/db/upgrade.php
mod/choice/lang/en/choice.php
mod/choice/lib.php
mod/choice/mod_form.php
mod/choice/report.php
mod/choice/tests/behat/include_inactive.feature [new file with mode: 0644]
mod/choice/version.php
mod/choice/view.php
mod/feedback/lib.php
mod/glossary/import.php
mod/glossary/lib.php
mod/imscp/lib.php
mod/imscp/tests/lib_test.php [new file with mode: 0644]
mod/imscp/view.php
mod/lesson/classes/event/lesson_restarted.php [new file with mode: 0644]
mod/lesson/classes/event/lesson_resumed.php [new file with mode: 0644]
mod/lesson/classes/event/page_moved.php [new file with mode: 0644]
mod/lesson/editpage.php
mod/lesson/lang/en/lesson.php
mod/lesson/locallib.php
mod/lesson/renderer.php
mod/lesson/tests/events_test.php
mod/lesson/version.php
mod/quiz/lang/en/quiz.php
mod/quiz/lib.php
mod/quiz/tests/behat/quiz_reset.feature [new file with mode: 0644]
mod/upgrade.txt
mod/workshop/lang/en/workshop.php
mod/workshop/lib.php
mod/workshop/mod_form.php
user/externallib.php
user/tests/externallib_test.php

index 572238a..10805f6 100644 (file)
@@ -659,8 +659,14 @@ function calendar_add_event_metadata($event) {
         $event->courselink = calendar_get_courselink($event->courseid);
         $event->cssclass = 'calendar_event_course';
     } else if ($event->groupid) {                                    // Group event
-        $event->icon = '<img src="'.$OUTPUT->pix_url('i/groupevent') . '" alt="'.get_string('groupevent', 'calendar').'" class="icon" />';
-        $event->courselink = calendar_get_courselink($event->courseid);
+        if ($group = calendar_get_group_cached($event->groupid)) {
+            $groupname = format_string($group->name, true, context_course::instance($group->courseid));
+        } else {
+            $groupname = '';
+        }
+        $event->icon = html_writer::empty_tag('image', array('src' => $OUTPUT->pix_url('i/groupevent'),
+            'alt' => get_string('groupevent', 'calendar'), 'title' => $groupname, 'class' => 'icon'));
+        $event->courselink = calendar_get_courselink($event->courseid) . ', ' . $groupname;
         $event->cssclass = 'calendar_event_group';
     } else if($event->userid) {                                      // User event
         $event->icon = '<img src="'.$OUTPUT->pix_url('i/userevent') . '" alt="'.get_string('userevent', 'calendar').'" class="icon" />';
@@ -1415,6 +1421,20 @@ function calendar_get_course_cached(&$coursecache, $courseid) {
     return $coursecache[$courseid];
 }
 
+/**
+ * Get group from groupid for calendar display
+ *
+ * @param int $groupid
+ * @return stdClass group object with fields 'id', 'name' and 'courseid'
+ */
+function calendar_get_group_cached($groupid) {
+    static $groupscache = array();
+    if (!isset($groupscache[$groupid])) {
+        $groupscache[$groupid] = groups_get_group($groupid, 'id,name,courseid');
+    }
+    return $groupscache[$groupid];
+}
+
 /**
  * Returns the courses to load events for, the
  *
index 619590c..de0e752 100644 (file)
@@ -9,12 +9,20 @@ Feature: Perform basic calendar functionality
       | username | firstname | lastname | email |
       | student1 | Student | 1 | student1@asd.com |
       | student2 | Student | 2 | student2@asd.com |
+      | student3 | Student | 3 | student3@asd.com |
     And the following "courses" exist:
       | fullname | shortname | format |
       | Course 1 | C1 | topics |
     And the following "course enrolments" exist:
       | user | course | role |
       | student1 | C1 | student |
+      | student3 | C1 | student |
+    And the following "groups" exist:
+      | name | course | idnumber |
+      | Group 1 | C1 | G1 |
+    And the following "group members" exist:
+      | user | group |
+      | student1 | G1 |
     When I log in as "admin"
     And I follow "Course 1"
     And I turn editing mode on
@@ -50,6 +58,23 @@ Feature: Perform basic calendar functionality
     And I follow "This month"
     And I should not see "Really awesome event!"
 
+  Scenario: Create a group event
+    And I create a calendar event with form data:
+      | Type of event | group |
+      | Group | Group 1 |
+      | Event title | Really awesome event! |
+      | Description | Come join this awesome event |
+    And I log out
+    And I log in as "student1"
+    And I follow "Course 1"
+    And I follow "This month"
+    And I follow "Really awesome event!"
+    And "Group 1" "text" should exist in the ".eventlist" "css_element"
+    And I log out
+    And I log in as "student3"
+    And I follow "This month"
+    And I should not see "Really awesome event!"
+
   Scenario: Create a user event
     And I create a calendar event with form data:
       | Type of event | user |
index 60864a8..d1c1fff 100644 (file)
@@ -8,6 +8,6 @@
     "require-dev": {
         "phpunit/phpunit": "3.7.*",
         "phpunit/dbUnit": "1.2.*",
-        "moodlehq/behat-extension": "1.29.3"
+        "moodlehq/behat-extension": "1.29.4"
     }
 }
index 234e3ad..aaa87e9 100644 (file)
@@ -189,6 +189,10 @@ if (!empty($add)) {
                                              'iteminstance'=>$data->instance, 'courseid'=>$course->id))) {
         // add existing outcomes
         foreach ($items as $item) {
+            if (!empty($item->gradepass)) {
+                $decimalpoints = $item->get_decimals();
+                $data->gradepass = format_float($item->gradepass, $decimalpoints);
+            }
             if (!empty($item->outcomeid)) {
                 $data->{'outcome_'.$item->outcomeid} = 1;
             }
index 9a44714..ed653ce 100644 (file)
@@ -191,8 +191,16 @@ function edit_module_post_actions($moduleinfo, $course) {
     // Sync idnumber with grade_item.
     if ($hasgrades && $grade_item = grade_item::fetch(array('itemtype'=>'mod', 'itemmodule'=>$moduleinfo->modulename,
                  'iteminstance'=>$moduleinfo->instance, 'itemnumber'=>0, 'courseid'=>$course->id))) {
+        $gradeupdate = false;
         if ($grade_item->idnumber != $moduleinfo->cmidnumber) {
             $grade_item->idnumber = $moduleinfo->cmidnumber;
+            $gradeupdate = true;
+        }
+        if (isset($moduleinfo->gradepass) && $grade_item->gradepass != $moduleinfo->gradepass) {
+            $grade_item->gradepass = $moduleinfo->gradepass;
+            $gradeupdate = true;
+        }
+        if ($gradeupdate) {
             $grade_item->update();
         }
     }
index 6a3a845..ef638b3 100644 (file)
@@ -299,6 +299,22 @@ abstract class moodleform_mod extends moodleform {
             $errors['assessed'] = get_string('scaleselectionrequired', 'rating');
         }
 
+        // Grade to pass: ensure that the grade to pass is valid for points and scales.
+        // If we are working with a scale, convert into a positive number for validation.
+
+        if (isset($data['gradepass']) && (isset($data['grade']) || isset($data['scale']))) {
+            $scale = isset($data['grade']) ? $data['grade'] : $data['scale'];
+            if ($scale < 0) {
+                $scalevalues = $DB->get_record('scale', array('id' => -$scale));
+                $grade = count(explode(',', $scalevalues->scale));
+            } else {
+                $grade = $scale;
+            }
+            if ($data['gradepass'] > $grade) {
+                $errors['gradepass'] = get_string('gradepassgreaterthangrade', 'grades', $grade);
+            }
+        }
+
         // Completion: Don't let them choose automatic completion without turning
         // on some conditions. Ignore this check when completion settings are
         // locked, as the options are then disabled.
@@ -624,6 +640,9 @@ abstract class moodleform_mod extends moodleform {
                     $mform->addElement('select', 'advancedgradingmethod_'.$areaname,
                         get_string('gradingmethod', 'core_grading'), $this->current->_advancedgradingdata['methods']);
                     $mform->addHelpButton('advancedgradingmethod_'.$areaname, 'gradingmethod', 'core_grading');
+                    if (!$this->_features->rating) {
+                        $mform->disabledIf('advancedgradingmethod_'.$areaname, 'grade[modgrade_type]', 'eq', 'none');
+                    }
 
                 } else {
                     // the module defines multiple gradable areas, display a selector
@@ -644,6 +663,19 @@ abstract class moodleform_mod extends moodleform {
                         get_string('gradecategoryonmodform', 'grades'),
                         grade_get_categories_menu($COURSE->id, $this->_outcomesused));
                 $mform->addHelpButton('gradecat', 'gradecategoryonmodform', 'grades');
+                if (!$this->_features->rating) {
+                    $mform->disabledIf('gradecat', 'grade[modgrade_type]', 'eq', 'none');
+                }
+            }
+
+            // Grade to pass.
+            $mform->addElement('text', 'gradepass', get_string('gradepass', 'grades'));
+            $mform->addHelpButton('gradepass', 'gradepass', 'grades');
+            $mform->setDefault('gradepass', '');
+            $mform->setType('gradepass', PARAM_FLOAT);
+            $mform->addRule('gradepass', null, 'numeric', null, 'client');
+            if (!$this->_features->rating) {
+                $mform->disabledIf('gradepass', 'grade[modgrade_type]', 'eq', 'none');
             }
         }
     }
index 6dfe3cc..845e2e4 100644 (file)
@@ -60,7 +60,21 @@ class enrol_paypal_plugin extends enrol_plugin {
      * @return array of pix_icon
      */
     public function get_info_icons(array $instances) {
-        return array(new pix_icon('icon', get_string('pluginname', 'enrol_paypal'), 'enrol_paypal'));
+        $found = false;
+        foreach ($instances as $instance) {
+            if ($instance->enrolstartdate != 0 && $instance->enrolstartdate > time()) {
+                continue;
+            }
+            if ($instance->enrolenddate != 0 && $instance->enrolenddate < time()) {
+                continue;
+            }
+            $found = true;
+            break;
+        }
+        if ($found) {
+            return array(new pix_icon('icon', get_string('pluginname', 'enrol_paypal'), 'enrol_paypal'));
+        }
+        return array();
     }
 
     public function roles_protected() {
index 614ccca..a17da1c 100644 (file)
@@ -19,6 +19,9 @@
     min-width: 4.5em;
     vertical-align: top;
 }
+.dir-rtl.path-grade-report-user .user-grade td {
+    direction: ltr;
+}
 .path-grade-report-user .user-grade .b1l {
     padding: 0;
     width:24px;
diff --git a/grade/tests/behat/grade_to_pass.feature b/grade/tests/behat/grade_to_pass.feature
new file mode 100644 (file)
index 0000000..8ac6e30
--- /dev/null
@@ -0,0 +1,251 @@
+@core @core_grades
+Feature: We can set the grade to pass value
+  In order to set the grade to pass value
+  As a teacher
+  I assign a grade to pass to an activity while editing the activity.
+  I need to ensure that the grade to pass is visible in the gradebook.
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@asd.com |
+    And the following "courses" exist:
+      | fullname | shortname | format | numsections |
+      | Course 1 | C1 | weeks | 5 |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+    And the following "scales" exist:
+      | name | scale |
+      | Test Scale 1 | Disappointing, Good, Very good, Excellent |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+
+  @javascript
+  Scenario: Validate that switching the type of grading used correctly disables grade to pass
+    When I turn editing mode on
+    And I add a "Assignment" to section "1"
+    And I expand all fieldsets
+    And I set the field "grade[modgrade_type]" to "Point"
+    Then the "Grade to pass" "field" should be enabled
+    And I set the field "grade[modgrade_type]" to "None"
+    And the "Grade to pass" "field" should be disabled
+    And I press "Save and return to course"
+
+  @javascript
+  Scenario: Create an activity with a Grade to pass value greater than the maximum grade
+    When I turn editing mode on
+    And I add a "Assignment" to section "1" and I fill the form with:
+      | Assignment name | Test Assignment 1 |
+      | Description | Submit your online text |
+      | assignsubmission_onlinetext_enabled | 1 |
+      | grade[modgrade_type] | Point |
+      | grade[modgrade_point] | 50 |
+      | Grade to pass | 100 |
+    Then I should see "The grade to pass can not be greater than the maximum possible grade 50"
+    And I press "Cancel"
+
+  @javascript
+  Scenario: Set a valid grade to pass for an assignment activity using points
+    When I turn editing mode on
+    And I add a "Assignment" to section "1" and I fill the form with:
+      | Assignment name | Test Assignment 1 |
+      | Description | Submit your online text |
+      | assignsubmission_onlinetext_enabled | 1 |
+      | grade[modgrade_type] | Point |
+      | grade[modgrade_point] | 50 |
+      | Grade to pass | 25 |
+    And I follow "Grades"
+    And I turn editing mode on
+    And I click on "Edit  assign Test Assignment 1" "link"
+    Then the field "Grade to pass" matches value "25"
+    And I follow "Course 1"
+    And I follow "Test Assignment 1"
+    And I follow "Edit settings"
+    And I expand all fieldsets
+    And I set the field "Grade to pass" to "30"
+    And I press "Save and return to course"
+    And I follow "Grades"
+    And I click on "Edit  assign Test Assignment 1" "link"
+    And the field "Grade to pass" matches value "30"
+
+  @javascript
+  Scenario: Set a valid grade to pass for an assignment activity using scales
+    When I turn editing mode on
+    And I add a "Assignment" to section "1" and I fill the form with:
+      | Assignment name | Test Assignment 1 |
+      | Description | Submit your online text |
+      | grade[modgrade_type] | Scale |
+      | grade[modgrade_scale] | Test Scale 1 |
+      | Grade to pass | 3 |
+    And I follow "Grades"
+    And I turn editing mode on
+    And I click on "Edit  assign Test Assignment 1" "link"
+    And I follow "Show more..."
+    Then the field "Grade to pass" matches value "3"
+    And I set the field "Grade to pass" to "4"
+    And I press "Save changes"
+    And I follow "Course 1"
+    And I follow "Test Assignment 1"
+    And I follow "Edit settings"
+    And the field "Grade to pass" matches value "4"
+
+  @javascript
+  Scenario: Set a invalid grade to pass for an assignment activity using scales
+    When I turn editing mode on
+    And I add a "Assignment" to section "1" and I fill the form with:
+      | Assignment name | Test Assignment 1 |
+      | Description | Submit your online text |
+      | grade[modgrade_type] | Scale |
+      | grade[modgrade_scale] | Test Scale 1 |
+      | Grade to pass | 10 |
+    Then I should see "The grade to pass can not be greater than the maximum possible grade 4"
+
+  @javascript
+  Scenario: Set a valid grade to pass for workshop activity
+    When I turn editing mode on
+    And I add a "Workshop" to section "1" and I fill the form with:
+      | Workshop name | Test Workshop 1 |
+      | Description | Test workshop |
+      | grade | 80 |
+      | Submission grade to pass | 40 |
+      | gradinggrade | 20 |
+      | Assessment grade to pass | 10 |
+    And I follow "Grades"
+    And I turn editing mode on
+    And I click on "Edit  workshop Test Workshop 1 (submission)" "link"
+    And I follow "Show more..."
+    Then the field "Grade to pass" matches value "40"
+    And I set the field "Grade to pass" to "45"
+    And I press "Save changes"
+    And I click on "Edit  workshop Test Workshop 1 (assessment)" "link"
+    And I follow "Show more..."
+    And the field "Grade to pass" matches value "10"
+    And I set the field "Grade to pass" to "15"
+    And I press "Save changes"
+    And I follow "Course 1"
+    And I follow "Test Workshop 1"
+    And I follow "Edit settings"
+    And the field "Submission grade to pass" matches value "45"
+    And the field "Assessment grade to pass" matches value "15"
+
+  @javascript
+  Scenario: Set an invalid grade to pass for workshop activity
+    When I turn editing mode on
+    And I add a "Workshop" to section "1" and I fill the form with:
+      | Workshop name | Test Workshop 1 |
+      | Description | Test workshop |
+      | grade | 80 |
+      | Submission grade to pass | 90 |
+      | gradinggrade | 20 |
+      | Assessment grade to pass | 30 |
+    Then "The grade to pass can not be greater than the maximum possible grade 80" "text" should exist in the "#fitem_id_submissiongradepass .error" "css_element"
+    Then "The grade to pass can not be greater than the maximum possible grade 20" "text" should exist in the "#fitem_id_gradinggradepass .error" "css_element"
+
+  @javascript
+  Scenario: Set a valid grade to pass for quiz activity
+    When I turn editing mode on
+    And I add a "Quiz" to section "1" and I fill the form with:
+      | Name | Test Quiz 1 |
+      | Grade to pass | 9.5 |
+    And I follow "Grades"
+    And I turn editing mode on
+    And I click on "Edit  quiz Test Quiz 1" "link"
+    And I follow "Show more..."
+    Then the field "Grade to pass" matches value "9.5"
+    And I set the field "Grade to pass" to "8"
+    And I press "Save changes"
+    And I follow "Course 1"
+    And I follow "Test Quiz 1"
+    And I follow "Edit settings"
+    And the field "Grade to pass" matches value "8.00"
+
+  @javascript
+  Scenario: Set a valid grade to pass for lesson activity
+    When I turn editing mode on
+    And I add a "Lesson" to section "1" and I fill the form with:
+      | Name          | Test Lesson 1 |
+      | Description   | Test          |
+      | Grade to pass | 90            |
+    And I follow "Grades"
+    And I turn editing mode on
+    And I click on "Edit  lesson Test Lesson 1" "link"
+    And I follow "Show more..."
+    Then the field "Grade to pass" matches value "90"
+    And I set the field "Grade to pass" to "80"
+    And I press "Save changes"
+    And I follow "Course 1"
+    And I follow "Test Lesson 1"
+    And I follow "Edit settings"
+    And the field "Grade to pass" matches value "80"
+
+  @javascript
+  Scenario: Set a valid grade to pass for database activity
+    When I turn editing mode on
+    And I add a "Database" to section "1" and I fill the form with:
+      | Name           | Test Database 1    |
+      | Description    | Test               |
+      | Grade to pass  | 90                 |
+      | Aggregate type | Average of ratings |
+    And I follow "Grades"
+    And I turn editing mode on
+    And I click on "Edit  data Test Database 1" "link"
+    And I follow "Show more..."
+    Then the field "Grade to pass" matches value "90"
+    And I set the field "Grade to pass" to "80"
+    And I press "Save changes"
+    And I follow "Course 1"
+    And I follow "Test Database 1"
+    And I follow "Edit settings"
+    And the field "Grade to pass" matches value "80"
+
+  @javascript
+  Scenario: Set an invalid grade to pass for forum activity
+    When I turn editing mode on
+    And I add a "Forum" to section "1" and I fill the form with:
+      | Forum name     | Test Forum 1    |
+      | Description    | Test               |
+      | Grade to pass  | 90                 |
+      | Aggregate type | Average of ratings |
+      | scale[modgrade_point] | 60 |
+    Then I should see "The grade to pass can not be greater than the maximum possible grade 60"
+
+  @javascript
+  Scenario: Set a valid grade to pass for forum activity
+    When I turn editing mode on
+    And I add a "Forum" to section "1" and I fill the form with:
+      | Forum name     | Test Forum 1    |
+      | Description    | Test               |
+      | Grade to pass  | 90                 |
+      | Aggregate type | Average of ratings |
+    And I follow "Grades"
+    And I turn editing mode on
+    And I click on "Edit  forum Test Forum 1" "link"
+    And I follow "Show more..."
+    Then the field "Grade to pass" matches value "90"
+    And I set the field "Grade to pass" to "80"
+    And I press "Save changes"
+    And I follow "Course 1"
+    And I follow "Test Forum 1"
+    And I follow "Edit settings"
+    And the field "Grade to pass" matches value "80"
+
+  @javascript
+  Scenario: Set a valid grade to pass for glossary activity
+    When I turn editing mode on
+    And I add a "Glossary" to section "1" and I fill the form with:
+      | Name           | Test Glossary 1    |
+      | Description    | Test               |
+      | Grade to pass  | 90                 |
+      | Aggregate type | Average of ratings |
+    And I follow "Grades"
+    And I turn editing mode on
+    And I click on "Edit  glossary Test Glossary 1" "link"
+    And I follow "Show more..."
+    Then the field "Grade to pass" matches value "90"
+    And I set the field "Grade to pass" to "80"
+    And I press "Save changes"
+    And I follow "Course 1"
+    And I follow "Test Glossary 1"
+    And I follow "Edit settings"
+    And the field "Grade to pass" matches value "80"
index e80d92e..a15d522 100644 (file)
@@ -296,6 +296,7 @@ $string['gradeoutcomes'] = 'Outcomes';
 $string['gradeoutcomescourses'] = 'Course outcomes';
 $string['gradepass'] = 'Grade to pass';
 $string['gradepass_help'] = 'This setting determines the minimum grade required to pass. The value is used in activity and course completion, and in the gradebook, where pass grades are highlighted in green and fail grades in red.';
+$string['gradepassgreaterthangrade'] = 'The grade to pass can not be greater than the maximum possible grade {$a}';
 $string['gradepointdefault'] = 'Grade point default';
 $string['gradepointdefault_help'] = 'This setting determines the default value for the grade point value available in an activity.';
 $string['gradepointdefault_validateerror'] = 'This setting must be an integer between 1 and the grade point maximum.';
index a027c55..d9609b4 100644 (file)
@@ -1562,6 +1562,9 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate {
         require_once($CFG->libdir.'/questionlib.php');
         require_once($CFG->dirroot.'/cohort/lib.php');
 
+        // Make sure we won't timeout when deleting a lot of courses.
+        $settimeout = core_php_time_limit::raise();
+
         $deletedcourses = array();
 
         // Get children. Note, we don't want to use cache here because it would be rebuilt too often.
index 52d04bf..40c81a1 100644 (file)
Binary files a/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-debug.js and b/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-debug.js differ
index 6a50ded..5d6e764 100644 (file)
Binary files a/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-min.js and b/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-min.js differ
index f01c730..c794c84 100644 (file)
Binary files a/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor.js and b/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor.js differ
index 89ccac7..1d3f478 100644 (file)
@@ -84,31 +84,195 @@ EditorClean.prototype = {
      * @return {String} The cleaned HTML
      */
     _cleanHTML: function(content) {
-        // What are we doing ?
-        // We are cleaning random HTML from all over the shop into a set of useful html suitable for content.
-        // We are allowing styles etc, but not e.g. font tags, class="MsoNormal" etc.
+        // Removing limited things that can break the page or a disallowed, like unclosed comments, style blocks, etc.
 
         var rules = [
+            // Remove any style blocks. Some browsers do not work well with them in a contenteditable.
+            // Plus style blocks are not allowed in body html, except with "scoped", which most browsers don't support as of 2015.
+            // Reference: "http://stackoverflow.com/questions/1068280/javascript-regex-multiline-flag-doesnt-work"
+            {regex: /<style[^>]*>[\s\S]*?<\/style>/gi, replace: ""},
+
             // Source: "http://stackoverflow.com/questions/2875027/clean-microsoft-word-pasted-text-using-javascript"
             // Source: "http://stackoverflow.com/questions/1068280/javascript-regex-multiline-flag-doesnt-work"
-
             // Remove all HTML comments.
             {regex: /<!--[\s\S]*?-->/gi, replace: ""},
-            // Source: "http://www.1stclassmedia.co.uk/developers/clean-ms-word-formatting.php"
-            // Remove <?xml>, <\?xml>.
-            {regex: /<\\?\?xml[^>]*>/gi, replace: ""},
-            // Remove <o:blah>, <\o:blah>.
-            {regex: /<\/?\w+:[^>]*>/gi, replace: ""}, // e.g. <o:p...
-            // Remove MSO-blah, MSO:blah (e.g. in style attributes)
-            {regex: /\s*MSO[-:][^;"']*;?/gi, replace: ""},
-            // Remove empty spans
-            {regex: /<span[^>]*>(&nbsp;|\s)*<\/span>/gi, replace: ""},
-            // Remove class="Msoblah"
-            {regex: /class="Mso[^"]*"/gi, replace: ""},
+
+            // Remove any open HTML comment opens that are not followed by a close. This can completely break page layout.
+            {regex: /<!--(?![\s\S]*?-->)/gi, replace: ""},
 
             // Source: "http://www.codinghorror.com/blog/2006/01/cleaning-words-nasty-html.html"
-            // Remove forbidden tags for content, title, meta, style, st0-9, head, font, html, body.
-            {regex: /<(\/?title|\/?meta|\/?style|\/?st\d|\/?head|\/?font|\/?html|\/?body|!\[)[^>]*?>/gi, replace: ""},
+            // Remove forbidden tags for content, title, meta, style, st0-9, head, font, html, body, link.
+            {regex: /<\/?(?:title|meta|style|st\d|head|font|html|body|link|!\[)[^>]*?>/gi, replace: ""}
+        ];
+
+        return this._filterContentWithRules(content, rules);
+    },
+
+    /**
+     * Take the supplied content and run on the supplied regex rules.
+     *
+     * @method _filterContentWithRules
+     * @private
+     * @param {String} content The content to clean
+     * @param {Array} rules An array of structures: [ {regex: /something/, replace: "something"}, {...}, ...]
+     * @return {String} The cleaned content
+     */
+    _filterContentWithRules: function(content, rules) {
+        var i = 0;
+        for (i = 0; i < rules.length; i++) {
+            content = content.replace(rules[i].regex, rules[i].replace);
+        }
+
+        return content;
+    },
+
+    /**
+     * Intercept and clean html paste events.
+     *
+     * @method pasteCleanup
+     * @param {Object} sourceEvent The YUI EventFacade  object
+     * @return {Boolean} True if the passed event should continue, false if not.
+     */
+    pasteCleanup: function(sourceEvent) {
+        // We only expect paste events, but we will check anyways.
+        if (sourceEvent.type === 'paste') {
+            // The YUI event wrapper doesn't provide paste event info, so we need the underlying event.
+            var event = sourceEvent._event;
+            // Check if we have a valid clipboardData object in the event.
+            // IE has a clipboard object at window.clipboardData, but as of IE 11, it does not provide HTML content access.
+            if (event && event.clipboardData && event.clipboardData.getData) {
+                // Check if there is HTML type to be pasted, this is all we care about.
+                var types = event.clipboardData.types;
+                var isHTML = false;
+                // Different browsers use different things to hold the types, so test various functions.
+                if (!types) {
+                    isHTML = false;
+                } else if (typeof types.contains === 'function') {
+                    isHTML = types.contains('text/html');
+                } else if (typeof types.indexOf === 'function') {
+                    isHTML = (types.indexOf('text/html') > -1);
+                    if (!isHTML) {
+                        if ((types.indexOf('com.apple.webarchive') > -1) || (types.indexOf('com.apple.iWork.TSPNativeData') > -1)) {
+                            // This is going to be a specialized Apple paste paste. We cannot capture this, so clean everything.
+                            this.fallbackPasteCleanupDelayed();
+                            return true;
+                        }
+                    }
+                } else {
+                    // We don't know how to handle the clipboard info, so wait for the clipboard event to finish then fallback.
+                    this.fallbackPasteCleanupDelayed();
+                    return true;
+                }
+
+                if (isHTML) {
+                    // Get the clipboard content.
+                    var content;
+                    try {
+                        content = event.clipboardData.getData('text/html');
+                    } catch (error) {
+                        // Something went wrong. Fallback.
+                        this.fallbackPasteCleanupDelayed();
+                        return true;
+                    }
+
+                    // Stop the original paste.
+                    sourceEvent.preventDefault();
+
+                    // Scrub the paste content.
+                    content = this._cleanPasteHTML(content);
+
+                    // Save the current selection.
+                    // Using saveSelection as it produces a more consistent experience.
+                    var selection = window.rangy.saveSelection();
+
+                    // Insert the content.
+                    this.insertContentAtFocusPoint(content);
+
+                    // Restore the selection, and collapse to end.
+                    window.rangy.restoreSelection(selection);
+                    window.rangy.getSelection().collapseToEnd();
+
+                    // Update the text area.
+                    this.updateOriginal();
+                    return false;
+                } else {
+                    // This is a non-html paste event, we can just let this continue on and call updateOriginalDelayed.
+                    this.updateOriginalDelayed();
+                    return true;
+                }
+            } else {
+                // If we reached a here, this probably means the browser has limited (or no) clipboard support.
+                // Wait for the clipboard event to finish then fallback.
+                this.fallbackPasteCleanupDelayed();
+                return true;
+            }
+        }
+
+        // We should never get here - we must have received a non-paste event for some reason.
+        // Um, just call updateOriginalDelayed() - it's safe.
+        this.updateOriginalDelayed();
+        return true;
+    },
+
+    /**
+     * Cleanup code after a paste event if we couldn't intercept the paste content.
+     *
+     * @method fallbackPasteCleanup
+     * @chainable
+     */
+    fallbackPasteCleanup: function() {
+        Y.log('Using fallbackPasteCleanup for atto cleanup', 'debug', LOGNAME);
+
+        // Save the current selection (cursor position).
+        var selection = window.rangy.saveSelection();
+
+        // Get, clean, and replace the content in the editable.
+        var content = this.editor.get('innerHTML');
+        this.editor.set('innerHTML', this._cleanPasteHTML(content));
+
+        // Update the textarea.
+        this.updateOriginal();
+
+        // Restore the selection (cursor position).
+        window.rangy.restoreSelection(selection);
+
+        return this;
+    },
+
+    /**
+     * Calls fallbackPasteCleanup on a short timer to allow the paste event handlers to complete.
+     *
+     * @method fallbackPasteCleanupDelayed
+     * @chainable
+     */
+    fallbackPasteCleanupDelayed: function() {
+        Y.soon(Y.bind(this.fallbackPasteCleanup, this));
+
+        return this;
+    },
+
+    /**
+     * Cleanup html that comes from WYSIWYG paste events. These are more likely to contain messy code that we should strip.
+     *
+     * @method _cleanPasteHTML
+     * @private
+     * @param {String} content The html content to clean
+     * @return {String} The cleaned HTML
+     */
+    _cleanPasteHTML: function(content) {
+        // Return an empty string if passed an invalid or empty object.
+        if (!content || content.length === 0) {
+            return "";
+        }
+
+        // Rules that get rid of the real-nasties and don't care about normalize code (correct quotes, white spaces, etc).
+        var rules = [
+            // Remove any xml blocks.
+            {regex: /<xml[^>]*>[\s\S]*?<\/xml>/gi, replace: ""},
+            // Remove any <?xml><\?xml> blocks.
+            {regex: /<\?xml[^>]*>[\s\S]*?<\\\?xml>/gi, replace: ""},
+            // Remove <o:blah>, <\o:blah>.
+            {regex: /<\/?\w+:[^>]*>/gi, replace: ""},
 
             // Source: "http://www.tim-jarrett.com/labs_javascript_scrub_word.php"
             // Replace extended chars with simple text.
@@ -125,11 +289,45 @@ EditorClean.prototype = {
             {regex: new RegExp(String.fromCharCode(8230), 'gi'), replace: '...'}
         ];
 
-        var i = 0;
-        for (i = 0; i < rules.length; i++) {
-            content = content.replace(rules[i].regex, rules[i].replace);
+        // Apply the first set of harsher rules.
+        content = this._filterContentWithRules(content, rules);
+
+        // Apply the standard rules, which mainly cleans things like headers, links, and style blocks.
+        content = this._cleanHTML(content);
+
+        // Check if the string is empty or only contains whitespace.
+        if (content.length === 0 || !content.match(/\S/)) {
+            return content;
         }
 
+        // Now we let the browser normalize the code by loading it into the DOM and then get the html back.
+        // This gives us well quoted, well formatted code to continue our work on. Word may provide very poorly formatted code.
+        var holder = document.createElement('div');
+        holder.innerHTML = content;
+        content = holder.innerHTML;
+        // Free up the DOM memory.
+        holder.innerHTML = "";
+
+        // Run some more rules that care about quotes and whitespace.
+        rules = [
+            // Remove MSO-blah, MSO:blah in style attributes. Only removes one or more that appear in succession.
+            {regex: /(<[^>]*?style\s*?=\s*?"[^>"]*?)(?:[\s]*MSO[-:][^>;"]*;?)+/gi, replace: "$1"},
+            // Remove MSO classes in class attributes. Only removes one or more that appear in succession.
+            {regex: /(<[^>]*?class\s*?=\s*?"[^>"]*?)(?:[\s]*MSO[_a-zA-Z0-9\-]*)+/gi, replace: "$1"},
+            // Remove Apple- classes in class attributes. Only removes one or more that appear in succession.
+            {regex: /(<[^>]*?class\s*?=\s*?"[^>"]*?)(?:[\s]*Apple-[_a-zA-Z0-9\-]*)+/gi, replace: "$1"},
+            // Remove OLE_LINK# anchors that may litter the code.
+            {regex: /<a [^>]*?name\s*?=\s*?"OLE_LINK\d*?"[^>]*?>\s*?<\/a>/gi, replace: ""},
+            // Remove empty spans.
+            {regex: /<span[^>]*>(&nbsp;|\s)*<\/span>/gi, replace: ""}
+        ];
+
+        // Apply the rules.
+        content = this._filterContentWithRules(content, rules);
+
+        // Reapply the standard cleaner to the content.
+        content = this._cleanHTML(content);
+
         return content;
     }
 };
index 93e4ede..8089a11 100644 (file)
@@ -296,7 +296,8 @@ Y.extend(Editor, Y.Base, {
      * @chainable
      */
     setupAutomaticPolling: function() {
-        this._registerEventHandle(this.editor.on(['keyup', 'paste', 'cut'], this.updateOriginal, this));
+        this._registerEventHandle(this.editor.on(['keyup', 'cut'], this.updateOriginal, this));
+        this._registerEventHandle(this.editor.on('paste', this.pasteCleanup, this));
 
         // Call this.updateOriginal after dropped content has been processed.
         this._registerEventHandle(this.editor.on('drop', this.updateOriginalDelayed, this));
index a405408..a2ce75f 100644 (file)
@@ -352,6 +352,18 @@ class behat_data_generators extends behat_base {
             $data['enrol'] = 'manual';
         }
 
+        if (!isset($data['timestart'])) {
+            $data['timestart'] = 0;
+        }
+
+        if (!isset($data['timeend'])) {
+            $data['timeend'] = 0;
+        }
+
+        if (!isset($data['status'])) {
+            $data['status'] = null;
+        }
+
         // If the provided course shortname is the site shortname we consider it a system role assign.
         if ($data['courseid'] == $SITE->id) {
             // Frontpage course assign.
@@ -360,7 +372,8 @@ class behat_data_generators extends behat_base {
 
         } else {
             // Course assign.
-            $this->datagenerator->enrol_user($data['userid'], $data['courseid'], $data['roleid'], $data['enrol']);
+            $this->datagenerator->enrol_user($data['userid'], $data['courseid'], $data['roleid'], $data['enrol'],
+                    $data['timestart'], $data['timeend'], $data['status']);
         }
 
     }
index aec6317..25ca9bf 100644 (file)
Binary files a/lib/yui/build/moodle-core-checknet/moodle-core-checknet-debug.js and b/lib/yui/build/moodle-core-checknet/moodle-core-checknet-debug.js differ
index 0dc73e0..cd16c7b 100644 (file)
Binary files a/lib/yui/build/moodle-core-checknet/moodle-core-checknet-min.js and b/lib/yui/build/moodle-core-checknet/moodle-core-checknet-min.js differ
index a08ce4d..a89d4d1 100644 (file)
Binary files a/lib/yui/build/moodle-core-checknet/moodle-core-checknet.js and b/lib/yui/build/moodle-core-checknet/moodle-core-checknet.js differ
index 42e92db..04572af 100644 (file)
@@ -144,10 +144,10 @@ Y.extend(CheckNet, Y.Base, {
          *
          * @attribute timeout
          * @type Number
-         * @value 2000
+         * @value 4000
          */
         timeout: {
-            value: 2000
+            value: 4000
         },
 
         /**
@@ -157,10 +157,10 @@ Y.extend(CheckNet, Y.Base, {
          * @attribute frequency
          * @writeOnce
          * @type Number
-         * @value 5000
+         * @value 10000
          */
         frequency: {
-            value: 5000
+            value: 10000
         },
 
         /**
index 061b738..415ada9 100644 (file)
@@ -41,7 +41,7 @@ class backup_choice_activity_structure_step extends backup_activity_structure_st
             'name', 'intro', 'introformat', 'publish',
             'showresults', 'display', 'allowupdate', 'showunanswered',
             'limitanswers', 'timeopen', 'timeclose', 'timemodified',
-            'completionsubmit', 'showpreview'));
+            'completionsubmit', 'showpreview', 'includeinactive'));
 
         $options = new backup_nested_element('options');
 
index 8291b8e..0e3cdcb 100644 (file)
@@ -18,6 +18,7 @@
         <FIELD NAME="allowupdate" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="allowmultiple" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="showunanswered" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="includeinactive" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="1" SEQUENCE="false"/>
         <FIELD NAME="limitanswers" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="timeopen" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="timeclose" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
index d745ff3..53f932a 100644 (file)
@@ -80,6 +80,21 @@ function xmldb_choice_upgrade($oldversion) {
         upgrade_mod_savepoint(true, 2014111001, 'choice');
     }
 
+    if ($oldversion < 2014111002) {
+
+        // Define field includeinactive to be added to choice.
+        $table = new xmldb_table('choice');
+        $field = new xmldb_field('includeinactive', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, '1', 'showunanswered');
+
+        // Conditionally launch add field includeactive.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Choice savepoint reached.
+        upgrade_mod_savepoint(true, 2014111002, 'choice');
+    }
+
     return true;
 }
 
index f3d60bc..8e57741 100644 (file)
@@ -62,6 +62,7 @@ $string['choicesaved'] = 'Your choice has been saved';
 $string['choicetext'] = 'Choice text';
 $string['chooseaction'] = 'Choose an action ...';
 $string['description'] = 'Description';
+$string['includeinactive'] = 'Include responses from inactive/suspended users';
 $string['limit'] = 'Limit';
 $string['limitno'] = 'Limit {no}';
 $string['limitanswers'] = 'Limit the number of responses allowed';
index fff0e08..8d62076 100644 (file)
@@ -697,9 +697,10 @@ function choice_reset_userdata($data) {
  * @param object $choice
  * @param object $cm
  * @param int $groupmode
+ * @param bool $onlyactive Whether to get response data for active users only.
  * @return array
  */
-function choice_get_response_data($choice, $cm, $groupmode) {
+function choice_get_response_data($choice, $cm, $groupmode, $onlyactive) {
     global $CFG, $USER, $DB;
 
     $context = context_module::instance($cm->id);
@@ -716,7 +717,8 @@ function choice_get_response_data($choice, $cm, $groupmode) {
 
 /// First get all the users who have access here
 /// To start with we assume they are all "unanswered" then move them later
-    $allresponses[0] = get_enrolled_users($context, 'mod/choice:choose', $currentgroup, user_picture::fields('u', array('idnumber')));
+    $allresponses[0] = get_enrolled_users($context, 'mod/choice:choose', $currentgroup,
+            user_picture::fields('u', array('idnumber')), null, 0, 0, $onlyactive);
 
 /// Get all the recorded responses for this choice
     $rawresponses = $DB->get_records('choice_answers', array('choiceid' => $choice->id));
@@ -790,10 +792,14 @@ function choice_extend_settings_navigation(settings_navigation $settings, naviga
         if ($groupmode) {
             groups_get_activity_group($PAGE->cm, true);
         }
-        // We only actually need the choice id here
-        $choice = new stdClass;
-        $choice->id = $PAGE->cm->instance;
-        $allresponses = choice_get_response_data($choice, $PAGE->cm, $groupmode);   // Big function, approx 6 SQL calls per user
+
+        $choice = choice_get_choice($PAGE->cm->instance);
+
+        // Check if we want to include responses from inactive users.
+        $onlyactive = $choice->includeinactive ? false : true;
+
+        // Big function, approx 6 SQL calls per user.
+        $allresponses = choice_get_response_data($choice, $PAGE->cm, $groupmode, $onlyactive);
 
         $responsecount =0;
         foreach($allresponses as $optionid => $userlist) {
index 9b9e495..0e5cfbc 100644 (file)
@@ -93,6 +93,8 @@ class mod_choice_mod_form extends moodleform_mod {
 
         $mform->addElement('selectyesno', 'showunanswered', get_string("showunanswered", "choice"));
 
+        $mform->addElement('selectyesno', 'includeinactive', get_string('includeinactive', 'choice'));
+        $mform->setDefault('includeinactive', 0);
 
 //-------------------------------------------------------------------------------
         $this->standard_coursemodule_elements();
index d5c2f4a..9620cf1 100644 (file)
     } else {
         $groupmode = groups_get_activity_groupmode($cm);
     }
-    $users = choice_get_response_data($choice, $cm, $groupmode);
+
+    // Check if we want to include responses from inactive users.
+    $onlyactive = $choice->includeinactive ? false : true;
+
+    $users = choice_get_response_data($choice, $cm, $groupmode, $onlyactive);
 
     if ($download == "ods" && has_capability('mod/choice:downloadresponses', $context)) {
         require_once("$CFG->libdir/odslib.class.php");
diff --git a/mod/choice/tests/behat/include_inactive.feature b/mod/choice/tests/behat/include_inactive.feature
new file mode 100644 (file)
index 0000000..0352f0f
--- /dev/null
@@ -0,0 +1,143 @@
+@mod @mod_choice
+Feature: Include responses from inactive users
+  In order to view responses from inactive or suspended users in choice results
+  As a teacher
+  I need to enable the choice include inactive option
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@asd.com |
+      | student1 | Student | 1 | student1@asd.com |
+      | student2 | Student | 2 | student2@asd.com |
+      | student3 | Student | 3 | student3@asd.com |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+      | student2 | C1 | student |
+      | student3 | C1 | student |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+
+  @javascript
+  Scenario: Enable the choice include inactive option and check that responses from inactive students are visible
+    Given I add a "Choice" to section "1" and I fill the form with:
+      | Choice name | Choice name |
+      | Description | Choice Description |
+      | option[0] | Option 1 |
+      | option[1] | Option 2 |
+      | option[2] | Option 3 |
+      | Include responses from inactive/suspended users | Yes |
+    And I log out
+    And I log in as "student1"
+    And I follow "Course 1"
+    And I choose "Option 1" from "Choice name" choice activity
+    And I log out
+    And I log in as "student2"
+    And I follow "Course 1"
+    And I choose "Option 2" from "Choice name" choice activity
+    And I log out
+    And I log in as "student3"
+    And I follow "Course 1"
+    And I choose "Option 3" from "Choice name" choice activity
+    And I log out
+    And the following "course enrolments" exist:
+      | user | course | role | status |
+      | student1 | C1 | student | 1 |
+    When I log in as "teacher1"
+    And I follow "Course 1"
+    And I follow "Choice name"
+    Then I should see "View 3 responses"
+    And I follow "View 3 responses"
+    And I should see "Student 1"
+    And I should see "Student 2"
+    And I should see "Student 3"
+    And I log out
+    And the following "course enrolments" exist:
+      | user | course | role | timestart |
+      | student2 | C1 | student | 2145830400 |
+    When I log in as "teacher1"
+    And I follow "Course 1"
+    And I follow "Choice name"
+    Then I should see "View 3 responses"
+    And I follow "View 3 responses"
+    And I should see "Student 1"
+    And I should see "Student 2"
+    And I should see "Student 3"
+    And I log out
+    And the following "course enrolments" exist:
+      | user | course | role | timeend |
+      | student3 | C1 | student | 1425168000 |
+    When I log in as "teacher1"
+    And I follow "Course 1"
+    And I follow "Choice name"
+    Then I should see "View 3 responses"
+    And I follow "View 3 responses"
+    And I should see "Student 1"
+    And I should see "Student 2"
+    And I should see "Student 3"
+    And I log out
+
+  @javascript
+  Scenario: Disable the choice include inactive option and check that responses from inactive students are not visible
+    Given I add a "Choice" to section "1" and I fill the form with:
+      | Choice name | Choice name |
+      | Description | Choice Description |
+      | option[0] | Option 1 |
+      | option[1] | Option 2 |
+      | option[2] | Option 3 |
+      | Include responses from inactive/suspended users | No |
+    And I log out
+    And I log in as "student1"
+    And I follow "Course 1"
+    And I choose "Option 1" from "Choice name" choice activity
+    And I log out
+    And I log in as "student2"
+    And I follow "Course 1"
+    And I choose "Option 2" from "Choice name" choice activity
+    And I log out
+    And I log in as "student3"
+    And I follow "Course 1"
+    And I choose "Option 3" from "Choice name" choice activity
+    And I log out
+    And the following "course enrolments" exist:
+      | user | course | role | status |
+      | student1 | C1 | student | 1 |
+    When I log in as "teacher1"
+    And I follow "Course 1"
+    And I follow "Choice name"
+    Then I should see "View 2 responses"
+    And I follow "View 2 responses"
+    And I should not see "Student 1"
+    And I should see "Student 2"
+    And I should see "Student 3"
+    And I log out
+    And the following "course enrolments" exist:
+      | user | course | role | timestart |
+      | student2 | C1 | student | 2145830400 |
+    When I log in as "teacher1"
+    And I follow "Course 1"
+    And I follow "Choice name"
+    Then I should see "View 1 responses"
+    And I follow "View 1 responses"
+    And I should not see "Student 1"
+    And I should not see "Student 2"
+    And I should see "Student 3"
+    And I log out
+    And the following "course enrolments" exist:
+      | user | course | role | timeend |
+      | student3 | C1 | student | 1425168000 |
+    When I log in as "teacher1"
+    And I follow "Course 1"
+    And I follow "Choice name"
+    Then I should see "View 0 responses"
+    And I follow "View 0 responses"
+    And I should not see "Student 1"
+    And I should not see "Student 2"
+    And I should not see "Student 3"
+    And I log out
index 0de84a5..154814c 100644 (file)
@@ -24,7 +24,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2014111001;       // The current module version (Date: YYYYMMDDXX)
+$plugin->version   = 2014111002;       // The current module version (Date: YYYYMMDDXX)
 $plugin->requires  = 2014110400;    // Requires this Moodle version
 $plugin->component = 'mod_choice';     // Full name of the plugin (used for diagnostics)
 $plugin->cron      = 0;
index acc5059..6f6c987 100644 (file)
@@ -111,7 +111,11 @@ if ($groupmode) {
     groups_get_activity_group($cm, true);
     groups_print_activity_menu($cm, $CFG->wwwroot . '/mod/choice/view.php?id='.$id);
 }
-$allresponses = choice_get_response_data($choice, $cm, $groupmode);   // Big function, approx 6 SQL calls per user
+
+// Check if we want to include responses from inactive users.
+$onlyactive = $choice->includeinactive ? false : true;
+
+$allresponses = choice_get_response_data($choice, $cm, $groupmode, $onlyactive);   // Big function, approx 6 SQL calls per user.
 
 
 if (has_capability('mod/choice:readresponses', $context)) {
index 68ab379..589d3d8 100644 (file)
@@ -393,9 +393,12 @@ function feedback_get_recent_mod_activity(&$activities, &$index,
         $sql .= " JOIN {groups_members} gm ON  gm.userid=u.id ";
     }
 
-    $sql .= " WHERE fc.timemodified > ? AND fk.id = ? ";
+    $sql .= " WHERE fc.timemodified > ?
+                AND fk.id = ?
+                AND fc.anonymous_response = ?";
     $sqlargs[] = $timemodified;
     $sqlargs[] = $cm->instance;
+    $sqlargs[] = FEEDBACK_ANONYMOUS_NO;
 
     if ($userid) {
         $sql .= " AND u.id = ? ";
index cb1d131..87783e7 100644 (file)
@@ -77,11 +77,16 @@ if (empty($result)) {
     die();
 }
 
+// Large exports are likely to take their time and memory.
+core_php_time_limit::raise();
+raise_memory_limit(MEMORY_EXTRA);
+
 if ($xml = glossary_read_imported_file($result)) {
     $importedentries = 0;
     $importedcats    = 0;
     $entriesrejected = 0;
     $rejections      = '';
+    $glossarycontext = $context;
 
     if ($data->dest == 'newglossary') {
         // If the user chose to create a new glossary
@@ -147,6 +152,8 @@ if ($xml = glossary_read_imported_file($result)) {
                 echo $OUTPUT->footer();
                 exit;
             } else {
+                $glossarycontext = context_module::instance($glossary->coursemodule);
+                glossary_xml_import_files($xmlglossary, 'INTROFILES', $glossarycontext->id, 'intro', 0);
                 echo $OUTPUT->box(get_string("newglossarycreated","glossary"),'generalbox boxaligncenter boxwidthnormal');
             }
         } else {
@@ -259,6 +266,15 @@ if ($xml = glossary_read_imported_file($result)) {
                     }
                 }
             }
+
+            // Import files embedded in the entry text.
+            glossary_xml_import_files($xmlentry['#'], 'ENTRYFILES', $glossarycontext->id, 'entry', $newentry->id);
+
+            // Import files attached to the entry.
+            if (glossary_xml_import_files($xmlentry['#'], 'ATTACHMENTFILES', $glossarycontext->id, 'attachment', $newentry->id)) {
+                $DB->update_record("glossary_entries", array('id' => $newentry->id, 'attachment' => '1'));
+            }
+
         } else {
             $entriesrejected++;
             if ( $newentry->concept and $newentry->definition ) {
index c7d8c4f..3db0791 100644 (file)
@@ -2262,6 +2262,13 @@ function glossary_generate_export_csv($entries, $aliases, $categories) {
 function glossary_generate_export_file($glossary, $ignored = "", $hook = 0) {
     global $CFG, $DB;
 
+    // Large exports are likely to take their time and memory.
+    core_php_time_limit::raise();
+    raise_memory_limit(MEMORY_EXTRA);
+
+    $cm = get_coursemodule_from_instance('glossary', $glossary->id, $glossary->course);
+    $context = context_module::instance($cm->id);
+
     $co  = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
 
     $co .= glossary_start_tag("GLOSSARY",0,true);
@@ -2279,6 +2286,7 @@ function glossary_generate_export_file($glossary, $ignored = "", $hook = 0) {
         $co .= glossary_full_tag("DEFAULTAPPROVAL",2,false,$glossary->defaultapproval);
         $co .= glossary_full_tag("GLOBALGLOSSARY",2,false,$glossary->globalglossary);
         $co .= glossary_full_tag("ENTBYPAGE",2,false,$glossary->entbypage);
+        $co .= glossary_xml_export_files('INTROFILES', 2, $context->id, 'intro', 0);
 
         if ( $entries = $DB->get_records("glossary_entries", array("glossaryid"=>$glossary->id))) {
             $co .= glossary_start_tag("ENTRIES",2,true);
@@ -2338,6 +2346,12 @@ function glossary_generate_export_file($glossary, $ignored = "", $hook = 0) {
                         $co .= glossary_end_tag("CATEGORIES",4,true);
                     }
 
+                    // Export files embedded in entries.
+                    $co .= glossary_xml_export_files('ENTRYFILES', 4, $context->id, 'entry', $entry->id);
+
+                    // Export attachments.
+                    $co .= glossary_xml_export_files('ATTACHMENTFILES', 4, $context->id, 'attachment', $entry->id);
+
                     $co .= glossary_end_tag("ENTRY",3,true);
                 }
             }
@@ -2420,6 +2434,66 @@ function glossary_full_tag($tag,$level=0,$endline=true,$content) {
         return $st.$co.$et;
 }
 
+/**
+ * Prepares file area to export as part of XML export
+ *
+ * @param string $tag XML tag to use for the group
+ * @param int $taglevel
+ * @param int $contextid
+ * @param string $filearea
+ * @param int $itemid
+ * @return string
+ */
+function glossary_xml_export_files($tag, $taglevel, $contextid, $filearea, $itemid) {
+    $co = '';
+    $fs = get_file_storage();
+    if ($files = $fs->get_area_files(
+        $contextid, 'mod_glossary', $filearea, $itemid, 'itemid,filepath,filename', false)) {
+        $co .= glossary_start_tag($tag, $taglevel, true);
+        foreach ($files as $file) {
+            $co .= glossary_start_tag('FILE', $taglevel + 1, true);
+            $co .= glossary_full_tag('FILENAME', $taglevel + 2, false, $file->get_filename());
+            $co .= glossary_full_tag('FILEPATH', $taglevel + 2, false, $file->get_filepath());
+            $co .= glossary_full_tag('CONTENTS', $taglevel + 2, false, base64_encode($file->get_content()));
+            $co .= glossary_end_tag('FILE', $taglevel + 1);
+        }
+        $co .= glossary_end_tag($tag, $taglevel);
+    }
+    return $co;
+}
+
+/**
+ * Parses files from XML import and inserts them into file system
+ *
+ * @param array $xmlparent parent element in parsed XML tree
+ * @param string $tag
+ * @param int $contextid
+ * @param string $filearea
+ * @param int $itemid
+ * @return int
+ */
+function glossary_xml_import_files($xmlparent, $tag, $contextid, $filearea, $itemid) {
+    $count = 0;
+    if (isset($xmlparent[$tag][0]['#']['FILE'])) {
+        $fs = get_file_storage();
+        $files = $xmlparent[$tag][0]['#']['FILE'];
+        foreach ($files as $file) {
+            $filerecord = array(
+                'contextid' => $contextid,
+                'component' => 'mod_glossary',
+                'filearea'  => $filearea,
+                'itemid'    => $itemid,
+                'filepath'  => $file['#']['FILEPATH'][0]['#'],
+                'filename'  => $file['#']['FILENAME'][0]['#'],
+            );
+            $content =  $file['#']['CONTENTS'][0]['#'];
+            $fs->create_file_from_string($filerecord, base64_decode($content));
+            $count++;
+        }
+    }
+    return $count;
+}
+
 /**
  * How many unrated entries are in the given glossary for a given user?
  *
index cfd8a17..2304398 100644 (file)
@@ -361,3 +361,58 @@ function imscp_page_type_list($pagetype, $parentcontext, $currentcontext) {
     $modulepagetype = array('mod-imscp-*' => get_string('page-mod-imscp-x', 'imscp'));
     return $modulepagetype;
 }
+
+/**
+ * Export imscp resource contents
+ *
+ * @param  stdClass $cm     Course module object
+ * @param  string $baseurl  Base URL for file downloads
+ * @return array of file content
+ */
+function imscp_export_contents($cm, $baseurl) {
+    global $DB;
+
+    $contents = array();
+    $context = context_module::instance($cm->id);
+
+    $imscp = $DB->get_record('imscp', array('id' => $cm->instance), '*', MUST_EXIST);
+
+    // We export the IMSCP structure as json encoded string.
+    $structure = array();
+    $structure['type']         = 'content';
+    $structure['filename']     = 'structure';
+    $structure['filepath']     = '/';
+    $structure['filesize']     = 0;
+    $structure['fileurl']      = null;
+    $structure['timecreated']  = $imscp->timemodified;
+    $structure['timemodified'] = $imscp->timemodified;
+    $structure['content']      = json_encode(unserialize($imscp->structure));
+    $structure['sortorder']    = 0;
+    $structure['userid']       = null;
+    $structure['author']       = null;
+    $structure['license']      = null;
+    $contents[] = $structure;
+
+    // Area files.
+    $fs = get_file_storage();
+    $files = $fs->get_area_files($context->id, 'mod_imscp', 'content', $imscp->revision, 'id ASC', false);
+    foreach ($files as $fileinfo) {
+        $file = array();
+        $file['type']         = 'file';
+        $file['filename']     = $fileinfo->get_filename();
+        $file['filepath']     = $fileinfo->get_filepath();
+        $file['filesize']     = $fileinfo->get_filesize();
+        $file['fileurl']      = moodle_url::make_webservice_pluginfile_url(
+                                    $context->id, 'mod_imscp', 'content', $imscp->revision,
+                                    $fileinfo->get_filepath(), $fileinfo->get_filename())->out(false);
+        $file['timecreated']  = $fileinfo->get_timecreated();
+        $file['timemodified'] = $fileinfo->get_timemodified();
+        $file['sortorder']    = $fileinfo->get_sortorder();
+        $file['userid']       = $fileinfo->get_userid();
+        $file['author']       = $fileinfo->get_author();
+        $file['license']      = $fileinfo->get_license();
+        $contents[] = $file;
+    }
+
+    return $contents;
+}
diff --git a/mod/imscp/tests/lib_test.php b/mod/imscp/tests/lib_test.php
new file mode 100644 (file)
index 0000000..a28f7eb
--- /dev/null
@@ -0,0 +1,67 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for (some of) mod/imscp/lib.php.
+ *
+ * @package    mod_imscp
+ * @category   test
+ * @copyright  2015 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/mod/imscp/lib.php');
+
+/**
+ * Unit tests for (some of) mod/imscp/lib.php.
+ *
+ * @package    mod_imscp
+ * @category   test
+ * @copyright  2015 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mod_imscp_lib_testcase extends advanced_testcase {
+
+    public function test_export_contents() {
+        global $DB, $USER;
+
+        $this->resetAfterTest(true);
+
+        $user = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course();
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $studentrole->id);
+
+        $this->setAdminUser();
+        $imscp = $this->getDataGenerator()->create_module('imscp', array('course' => $course->id));
+        $cm = get_coursemodule_from_id('imscp', $imscp->cmid);
+
+        $this->setUser($user);
+        $contents = imscp_export_contents($cm, '');
+
+        // The test package contains 47 files.
+        $this->assertCount(47, $contents);
+        // The structure is present.
+        $this->assertEquals('structure', $contents[0]['filename']);
+        // The structure is returned and it maches the expected one.
+        $this->assertEquals(json_encode(unserialize($imscp->structure)), $contents[0]['content']);
+
+    }
+}
index eacf10f..ebd105a 100644 (file)
@@ -76,6 +76,7 @@ echo $OUTPUT->heading(format_string($imscp->name));
 // Verify imsmanifest was parsed properly.
 if (!$imscp->structure) {
     echo $OUTPUT->notification(get_string('deploymenterror', 'imscp'), 'notifyproblem');
+    echo $OUTPUT->continue_button(course_get_url($course->id, $cm->section));
     echo $OUTPUT->footer();
     die;
 }
diff --git a/mod/lesson/classes/event/lesson_restarted.php b/mod/lesson/classes/event/lesson_restarted.php
new file mode 100644 (file)
index 0000000..c2c8b67
--- /dev/null
@@ -0,0 +1,75 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The mod_lesson lesson restarted event class.
+ *
+ * @package    mod_lesson
+ * @copyright  2015 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
+ */
+
+namespace mod_lesson\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The mod_lesson lesson restarted event class
+ *
+ * @package    mod_lesson
+ * @since      Moodle 2.9
+ * @copyright  2015 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
+ */
+class lesson_restarted extends \core\event\base {
+
+    /**
+     * Set basic properties for the event.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'lesson';
+        $this->data['crud'] = 'u';
+        $this->data['edulevel'] = self::LEVEL_PARTICIPATING;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventlessonrestarted', 'mod_lesson');
+    }
+
+    /**
+     * Get URL related to the action.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        return new \moodle_url('/mod/lesson/view.php', array('id' => $this->contextinstanceid));
+    }
+
+    /**
+     * Returns non-localised event description with id's for admin use only.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' abandoned their previous incomplete attempt ".
+               "and started a new attempt on the lesson with course module id '$this->contextinstanceid'.";
+    }
+}
diff --git a/mod/lesson/classes/event/lesson_resumed.php b/mod/lesson/classes/event/lesson_resumed.php
new file mode 100644 (file)
index 0000000..30638f4
--- /dev/null
@@ -0,0 +1,75 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The mod_lesson lesson resumed event class.
+ *
+ * @package    mod_lesson
+ * @copyright  2015 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
+ */
+
+namespace mod_lesson\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The mod_lesson lesson resumed event class
+ *
+ * @package    mod_lesson
+ * @since      Moodle 2.9
+ * @copyright  2015 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
+ */
+class lesson_resumed extends \core\event\base {
+
+    /**
+     * Set basic properties for the event.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'lesson';
+        $this->data['crud'] = 'u';
+        $this->data['edulevel'] = self::LEVEL_PARTICIPATING;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventlessonresumed', 'mod_lesson');
+    }
+
+    /**
+     * Get URL related to the action.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        return new \moodle_url('/mod/lesson/view.php', array('id' => $this->contextinstanceid));
+    }
+
+    /**
+     * Returns non-localised event description with id's for admin use only.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' resumed their previous incomplete attempt on".
+               " the lesson with course module id '$this->contextinstanceid'.";
+    }
+}
diff --git a/mod/lesson/classes/event/page_moved.php b/mod/lesson/classes/event/page_moved.php
new file mode 100644 (file)
index 0000000..91ce667
--- /dev/null
@@ -0,0 +1,108 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The mod_lesson page_moved event class.
+ *
+ * @package    mod_lesson
+ * @copyright  2015 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
+ */
+
+namespace mod_lesson\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The mod_lesson page_moved event class.
+ *
+ * @property-read array $other {
+ * Extra information about event.
+ *
+ * - string pagetype: the name of the pagetype as defined in the individual page class
+ * - int prevpageid: the id of the previous lesson page
+ * - int nextpageid: the id of the next lesson page
+ * }
+ *
+ * @package    mod_lesson
+ * @since      Moodle 2.9
+ * @copyright  2015 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
+ */
+class page_moved extends \core\event\base {
+
+    /**
+     * Set basic properties for the event.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'lesson_pages';
+        $this->data['crud'] = 'u';
+        $this->data['edulevel'] = self::LEVEL_TEACHING;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventpagemoved', 'mod_lesson');
+    }
+
+    /**
+     * Get URL related to the action.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        return new \moodle_url('/mod/lesson/view.php', array('id' => $this->contextinstanceid, 'pageid' => $this->objectid));
+    }
+
+    /**
+     * Returns non-localised event description with id's for admin use only.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' has moved a ".$this->other['pagetype']." page with the ".
+                "id '$this->objectid' to the slot after the page with the id '".$this->other['prevpageid'].
+                "' and before the page with the id '".$this->other['nextpageid'].
+                "' in the lesson activity with course module id '$this->contextinstanceid'.";
+    }
+
+    /**
+     * Custom validations.
+     *
+     * @throws \coding_exception when validation fails.
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+        // Make sure this class is never used without proper object details.
+        if (!$this->contextlevel === CONTEXT_MODULE) {
+            throw new \coding_exception('Context level must be CONTEXT_MODULE.');
+        }
+        if (!isset($this->other['pagetype'])) {
+            throw new \coding_exception('The \'pagetype\' value must be set in other.');
+        }
+        if (!isset($this->other['prevpageid'])) {
+            throw new \coding_exception('The \'prevpageid\' value must be set in other.');
+        }
+        if (!isset($this->other['nextpageid'])) {
+            throw new \coding_exception('The \'nextpageid\' value must be set in other.');
+        }
+    }
+}
index 8df4e5d..38a2d96 100644 (file)
@@ -32,6 +32,11 @@ $pageid = required_param('pageid', PARAM_INT);
 $id     = required_param('id', PARAM_INT);         // Course Module ID
 $qtype  = optional_param('qtype', 0, PARAM_INT);
 $edit   = optional_param('edit', false, PARAM_BOOL);
+$returnto = optional_param('returnto', null, PARAM_URL);
+if (empty($returnto)) {
+    $returnto = new moodle_url('/mod/lesson/edit.php', array('id' => $id));
+    $returnto->set_anchor('lesson-' . $pageid);
+}
 
 $cm = get_coursemodule_from_id('lesson', $id, 0, false, MUST_EXIST);
 $course = $DB->get_record('course', array('id' => $cm->course), '*', MUST_EXIST);
@@ -66,17 +71,31 @@ $editoroptions = array('noclean'=>true, 'maxfiles'=>EDITOR_UNLIMITED_FILES, 'max
 // the Question type selection was cancelled. For this reason, a dummy form
 // is created here solely to check whether the selection was cancelled.
 if ($qtype) {
-    $mformdummy = $manager->get_page_form(0, array('editoroptions'=>$editoroptions, 'jumpto'=>$jumpto, 'lesson'=>$lesson, 'edit'=>$edit, 'maxbytes'=>$PAGE->course->maxbytes));
+    $mformdummy = $manager->get_page_form(0, array(
+        'editoroptions' => $editoroptions,
+        'jumpto'        => $jumpto,
+        'lesson'        => $lesson,
+        'edit'          => $edit,
+        'maxbytes'      => $PAGE->course->maxbytes,
+        'returnto'      => $returnto
+    ));
     if ($mformdummy->is_cancelled()) {
-        redirect("$CFG->wwwroot/mod/lesson/edit.php?id=$id");
+        redirect($returnto);
         exit;
     }
 }
 
-$mform = $manager->get_page_form($qtype, array('editoroptions'=>$editoroptions, 'jumpto'=>$jumpto, 'lesson'=>$lesson, 'edit'=>$edit, 'maxbytes'=>$PAGE->course->maxbytes));
+$mform = $manager->get_page_form($qtype, array(
+    'editoroptions' => $editoroptions,
+    'jumpto'        => $jumpto,
+    'lesson'        => $lesson,
+    'edit'          => $edit,
+    'maxbytes'      => $PAGE->course->maxbytes,
+    'returnto'      => $returnto
+));
 
 if ($mform->is_cancelled()) {
-    redirect("$CFG->wwwroot/mod/lesson/edit.php?id=$id");
+    redirect($returnto);
     exit;
 }
 
@@ -155,7 +174,7 @@ if ($data = $mform->get_data()) {
     } else {
         $editpage = lesson_page::create($data, $lesson, $context, $PAGE->course->maxbytes);
     }
-    redirect(new moodle_url('/mod/lesson/edit.php', array('id'=>$cm->id)));
+    redirect($returnto);
 }
 
 $lessonoutput = $PAGE->get_renderer('mod_lesson');
index 8fc15a4..3e2b032 100644 (file)
@@ -182,8 +182,11 @@ $string['eventessayattemptviewed'] = 'Essay attempt viewed';
 $string['eventhighscoreadded'] = 'Highscore added';
 $string['eventhighscoresviewed'] = 'Highscores viewed';
 $string['eventlessonended'] = 'Lesson ended';
+$string['eventlessonrestarted'] = 'Lesson restarted';
+$string['eventlessonresumed'] = 'Lesson resumed';
 $string['eventlessonstarted'] = 'Lesson started';
 $string['eventpagecreated'] = 'Page created';
+$string['eventpagemoved'] = 'Page moved';
 $string['eventpageupdated'] = 'Page updated';
 $string['eventpagedeleted'] = 'Page deleted';
 $string['eventquestionanswered'] = 'Question answered';
index 8832444..f272c52 100644 (file)
@@ -577,7 +577,12 @@ function lesson_add_header_buttons($cm, $context, $extraeditbuttons=false, $less
             print_error('invalidpageid', 'lesson');
         }
         if (!empty($lessonpageid) && $lessonpageid != LESSON_EOL) {
-            $url = new moodle_url('/mod/lesson/editpage.php', array('id'=>$cm->id, 'pageid'=>$lessonpageid, 'edit'=>1));
+            $url = new moodle_url('/mod/lesson/editpage.php', array(
+                'id'       => $cm->id,
+                'pageid'   => $lessonpageid,
+                'edit'     => 1,
+                'returnto' => $PAGE->url->out(false)
+            ));
             $PAGE->set_button($OUTPUT->single_button($url, get_string('editpagecontent', 'lesson')));
         }
     }
@@ -700,6 +705,11 @@ abstract class lesson_add_page_form_base extends moodleform {
 
         $mform->addElement('header', 'qtypeheading', get_string('createaquestionpage', 'lesson', get_string($this->qtypestring, 'lesson')));
 
+        if (!empty($this->_customdata['returnto'])) {
+            $mform->addElement('hidden', 'returnto', $this->_customdata['returnto']);
+            $mform->setType('returnto', PARAM_URL);
+        }
+
         $mform->addElement('hidden', 'id');
         $mform->setType('id', PARAM_INT);
 
@@ -1256,6 +1266,9 @@ class lesson extends lesson_base {
      */
     public function update_timer($restart=false, $continue=false, $endreached =false) {
         global $USER, $DB;
+
+        $cm = get_coursemodule_from_instance('lesson', $this->properties->id, $this->properties->course);
+
         // clock code
         // get time information for this user
         $params = array("lessonid" => $this->properties->id, "userid" => $USER->id);
@@ -1269,9 +1282,27 @@ class lesson extends lesson_base {
             if ($continue) {
                 // continue a previous test, need to update the clock  (think this option is disabled atm)
                 $timer->starttime = time() - ($timer->lessontime - $timer->starttime);
+
+                // Trigger lesson resumed event.
+                $event = \mod_lesson\event\lesson_resumed::create(array(
+                    'objectid' => $this->properties->id,
+                    'context' => context_module::instance($cm->id),
+                    'courseid' => $this->properties->course
+                ));
+                $event->trigger();
+
             } else {
                 // starting over, so reset the clock
                 $timer->starttime = time();
+
+                // Trigger lesson restarted event.
+                $event = \mod_lesson\event\lesson_restarted::create(array(
+                    'objectid' => $this->properties->id,
+                    'context' => context_module::instance($cm->id),
+                    'courseid' => $this->properties->course
+                ));
+                $event->trigger();
+
             }
         }
 
@@ -1991,6 +2022,22 @@ abstract class lesson_page extends lesson_base {
         $obj->prevpageid = $prevpageid;
         $obj->nextpageid = $nextpageid;
         $DB->update_record('lesson_pages', $obj);
+
+        $cm = get_coursemodule_from_instance('lesson', $this->lesson->id, $this->lesson->course);
+        $context = context_module::instance($cm->id);
+
+        // Trigger an event: page moved.
+        $eventparams = array(
+            'context' => $context,
+            'objectid' => $this->properties->id,
+            'other' => array(
+                'pagetype' => $this->get_typestring(),
+                'prevpageid' => $prevpageid,
+                'nextpageid' => $nextpageid
+                )
+            );
+        $event = \mod_lesson\event\page_moved::create($eventparams);
+        $event->trigger();
     }
 
     /**
index a41e0d7..0a8dd03 100644 (file)
@@ -224,7 +224,12 @@ class mod_lesson_renderer extends plugin_renderer_base {
         while ($pageid != 0) {
             $page = $lesson->load_page($pageid);
             $data = array();
-            $data[] = "<a href=\"$CFG->wwwroot/mod/lesson/edit.php?id=".$this->page->cm->id."&amp;mode=single&amp;pageid=".$page->id."\">".format_string($page->title,true).'</a>';
+            $url = new moodle_url('/mod/lesson/edit.php', array(
+                'id'     => $this->page->cm->id,
+                'mode'   => 'single',
+                'pageid' => $page->id
+            ));
+            $data[] = html_writer::link($url, format_string($page->title, true), array('id' => 'lesson-' . $page->id));
             $data[] = $qtypes[$page->qtype];
             $data[] = implode("<br />\n", $page->jumps);
             if ($canedit) {
@@ -274,6 +279,7 @@ class mod_lesson_renderer extends plugin_renderer_base {
             $pagetable->cellspacing = 0;
             $pagetable->cellpadding = '5px';
             $pagetable->data = array();
+            $pagetable->id = 'lesson-' . $pageid;
 
             $pageheading = new html_table_cell();
 
index 85d776e..012e550 100644 (file)
@@ -77,6 +77,36 @@ class mod_lesson_events_testcase extends advanced_testcase {
         $this->assertDebuggingNotCalled();
     }
 
+    /**
+     * Test the page created event.
+     *
+     */
+    public function test_page_moved() {
+
+        // Set up a generator to create content.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_lesson');
+        $pagerecord1 = $generator->create_content($this->lesson);
+        $page1 = $this->lesson->load_page($pagerecord1->id);
+        $pagerecord2 = $generator->create_content($this->lesson);
+        $page2 = $this->lesson->load_page($pagerecord2->id);
+        $pagerecord3 = $generator->create_content($this->lesson);
+        $page3 = $this->lesson->load_page($pagerecord3->id);
+        // Trigger and capture the event.
+        $sink = $this->redirectEvents();
+        $page3->move($pagerecord2->id, $pagerecord1->id);
+        // Get our event event.
+        $events = $sink->get_events();
+        $event = reset($events);
+
+        // Check that the event data is valid.
+        $this->assertInstanceOf('\mod_lesson\event\page_moved', $event);
+        $this->assertEquals($page3->id, $event->objectid);
+        $this->assertEquals($pagerecord2->id, $event->other['nextpageid']);
+        $this->assertEquals($pagerecord1->id, $event->other['prevpageid']);
+        $this->assertEventContextNotUsed($event);
+        $this->assertDebuggingNotCalled();
+    }
+
     /**
      * Test the page deleted event.
      *
@@ -259,6 +289,51 @@ class mod_lesson_events_testcase extends advanced_testcase {
         $this->assertEventContextNotUsed($event);
     }
 
+    /**
+     * Test the lesson restarted event.
+     */
+    public function test_lesson_restarted() {
+
+        // Initialize timer.
+        $this->lesson->start_timer();
+        // Trigger and capture the event.
+        $sink = $this->redirectEvents();
+        $this->lesson->update_timer(true);
+        $events = $sink->get_events();
+        $event = reset($events);
+
+        // Check that the event data is valid.
+        $this->assertInstanceOf('\mod_lesson\event\lesson_restarted', $event);
+        $this->assertEquals(context_module::instance($this->lesson->properties()->cmid), $event->get_context());
+        $expected = array($this->course->id, 'lesson', 'start', 'view.php?id=' . $this->lesson->properties()->cmid,
+            $this->lesson->properties()->id, $this->lesson->properties()->cmid);
+        $this->assertEventContextNotUsed($event);
+        $this->assertDebuggingNotCalled();
+
+    }
+
+    /**
+     * Test the lesson restarted event.
+     */
+    public function test_lesson_resumed() {
+
+        // Initialize timer.
+        $this->lesson->start_timer();
+        // Trigger and capture the event.
+        $sink = $this->redirectEvents();
+        $this->lesson->update_timer(true, true);
+        $events = $sink->get_events();
+        $event = reset($events);
+
+        // Check that the event data is valid.
+        $this->assertInstanceOf('\mod_lesson\event\lesson_resumed', $event);
+        $this->assertEquals(context_module::instance($this->lesson->properties()->cmid), $event->get_context());
+        $expected = array($this->course->id, 'lesson', 'start', 'view.php?id=' . $this->lesson->properties()->cmid,
+            $this->lesson->properties()->id, $this->lesson->properties()->cmid);
+        $this->assertEventContextNotUsed($event);
+        $this->assertDebuggingNotCalled();
+
+    }
     /**
      * Test the lesson ended event.
      */
index c087633..1bd6fe5 100644 (file)
@@ -24,7 +24,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2015031500;       // The current module version (Date: YYYYMMDDXX)
+$plugin->version   = 2015032401;       // The current module version (Date: YYYYMMDDXX)
 $plugin->requires  = 2014110400;    // Requires this Moodle version
 $plugin->component = 'mod_lesson'; // Full name of the plugin (used for diagnostics)
 $plugin->cron      = 0;
index 29dfe7e..1769984 100644 (file)
@@ -394,6 +394,7 @@ $string['gradingdetailspenalty'] = 'This submission attracted a penalty of {$a}.
 $string['gradingdetailszeropenalty'] = 'You were not penalized for this submission.';
 $string['gradingmethod'] = 'Grading method: {$a}';
 $string['groupoverrides'] = 'Group overrides';
+$string['groupoverridesdeleted'] = 'Group overrides deleted';
 $string['groupsnone'] = 'There are no groups in this course';
 $string['guestsno'] = 'Sorry, guests cannot see or attempt quizzes';
 $string['hidebreaks'] = 'Hide page breaks';
@@ -695,7 +696,9 @@ $string['regradenotallowed'] = 'You do not have permission to regrade this quiz'
 $string['regradingquestion'] = 'Regrading "{$a}".';
 $string['regradingquiz'] = 'Regrading quiz "{$a}"';
 $string['remove'] = 'Remove';
+$string['removeallgroupoverrides'] = 'Delete all group overrides';
 $string['removeallquizattempts'] = 'Delete all quiz attempts';
+$string['removealluseroverrides'] = 'Delete all user overrides';
 $string['removeemptypage'] = 'Remove empty page';
 $string['removepagebreak'] = 'Remove page break';
 $string['removeselected'] = 'Remove selected';
@@ -900,6 +903,7 @@ $string['upgradingveryoldquizattempts'] = 'Upgrading very old quiz attempts: {$a
 $string['url'] = 'URL';
 $string['usedcategorymoved'] = 'This category has been preserved and moved to the site level because it is a published category still in use by other courses.';
 $string['useroverrides'] = 'User overrides';
+$string['useroverridesdeleted'] = 'User overrides deleted';
 $string['usersnone'] = 'No students have access to this quiz';
 $string['validate'] = 'Validate';
 $string['viewallanswers'] = 'View {$a} quiz attempts';
index aecb018..de427cf 100644 (file)
@@ -1322,6 +1322,10 @@ function quiz_reset_course_form_definition($mform) {
     $mform->addElement('header', 'quizheader', get_string('modulenameplural', 'quiz'));
     $mform->addElement('advcheckbox', 'reset_quiz_attempts',
             get_string('removeallquizattempts', 'quiz'));
+    $mform->addElement('advcheckbox', 'reset_quiz_user_overrides',
+            get_string('removealluseroverrides', 'quiz'));
+    $mform->addElement('advcheckbox', 'reset_quiz_group_overrides',
+            get_string('removeallgroupoverrides', 'quiz'));
 }
 
 /**
@@ -1329,7 +1333,9 @@ function quiz_reset_course_form_definition($mform) {
  * @return array the defaults.
  */
 function quiz_reset_course_form_defaults($course) {
-    return array('reset_quiz_attempts' => 1);
+    return array('reset_quiz_attempts' => 1,
+                 'reset_quiz_group_overrides' => 1,
+                 'reset_quiz_user_overrides' => 1);
 }
 
 /**
@@ -1396,6 +1402,25 @@ function quiz_reset_userdata($data) {
             'error' => false);
     }
 
+    // Remove user overrides.
+    if (!empty($data->reset_quiz_user_overrides)) {
+        $DB->delete_records_select('quiz_overrides',
+                'quiz IN (SELECT id FROM {quiz} WHERE course = ?) AND userid IS NOT NULL', array($data->courseid));
+        $status[] = array(
+            'component' => $componentstr,
+            'item' => get_string('useroverridesdeleted', 'quiz'),
+            'error' => false);
+    }
+    // Remove group overrides.
+    if (!empty($data->reset_quiz_group_overrides)) {
+        $DB->delete_records_select('quiz_overrides',
+                'quiz IN (SELECT id FROM {quiz} WHERE course = ?) AND groupid IS NOT NULL', array($data->courseid));
+        $status[] = array(
+            'component' => $componentstr,
+            'item' => get_string('groupoverridesdeleted', 'quiz'),
+            'error' => false);
+    }
+
     // Updating dates - shift may be negative too.
     if ($data->timeshift) {
         $DB->execute("UPDATE {quiz_overrides}
diff --git a/mod/quiz/tests/behat/quiz_reset.feature b/mod/quiz/tests/behat/quiz_reset.feature
new file mode 100644 (file)
index 0000000..16c2324
--- /dev/null
@@ -0,0 +1,97 @@
+@mod @mod_quiz
+Feature: Quiz reset
+  In order to reuse past quizzes
+  As a teacher
+  I need to remove all previous data.
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email               |
+      | teacher1 | Terry1    | Teacher1 | teacher1@moodle.com |
+      | student1 | Sam1      | Student1 | student1@moodle.com |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | student1 | C1     | student        |
+    And the following "groups" exist:
+      | name    | course | idnumber |
+      | Group 1 | C1     | G1       |
+      | Group 2 | C1     | G2       |
+    And the following "question categories" exist:
+      | contextlevel | reference | name           |
+      | Course       | C1        | Test questions |
+    And the following "questions" exist:
+      | questioncategory | qtype     | name | questiontext   |
+      | Test questions   | truefalse | TF1  | First question |
+    And the following "activities" exist:
+      | activity | name           | intro                 | course | idnumber |
+      | quiz     | Test quiz name | Test quiz description | C1     | quiz1    |
+    And quiz "Test quiz name" contains the following questions:
+      | question | page |
+      | TF1      | 1    |
+
+  Scenario: Use course reset to clear all attempt data
+    When I log in as "student1"
+    And I follow "Course 1"
+    And I follow "Test quiz name"
+    And I press "Attempt quiz now"
+    And I set the field "True" to "1"
+    And I press "Next"
+    And I press "Submit all and finish"
+    And I log out
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I navigate to "Reset" node in "Course administration"
+    And I set the following fields to these values:
+        | Delete all quiz attempts | 1  |
+    And I press "Reset course"
+    And I press "Continue"
+    And I follow "Course 1"
+    And I follow "Test quiz name"
+    And I navigate to "Results" node in "Quiz administration"
+    Then I should see "Attempts: 0"
+
+  Scenario: Use course reset to remove user overrides.
+    When I log in as "teacher1"
+    And I follow "Course 1"
+    And I follow "Test quiz name"
+    And I navigate to "User overrides" node in "Quiz administration"
+    And I press "Add user override"
+    And I set the following fields to these values:
+        | Override user    | Student1  |
+        | Attempts allowed | 2 |
+    And I press "Save"
+    And I should see "Sam1 Student1"
+    And I navigate to "Reset" node in "Course administration"
+    And I set the following fields to these values:
+        | Delete all user overrides | 1  |
+    And I press "Reset course"
+    And I press "Continue"
+    And I follow "Course 1"
+    And I follow "Test quiz name"
+    And I navigate to "User overrides" node in "Quiz administration"
+    Then I should not see "Sam1 Student1"
+
+Scenario: Use course reset to remove group overrides.
+    When I log in as "teacher1"
+    And I follow "Course 1"
+    And I follow "Test quiz name"
+    And I navigate to "Group overrides" node in "Quiz administration"
+    And I press "Add group override"
+    And I set the following fields to these values:
+        | Override group    | Group 1  |
+        | Attempts allowed | 2 |
+    And I press "Save"
+    And I should see "Group 1"
+    And I navigate to "Reset" node in "Course administration"
+    And I set the following fields to these values:
+        | Delete all group overrides | 1  |
+    And I press "Reset course"
+    And I press "Continue"
+    And I follow "Course 1"
+    And I follow "Test quiz name"
+    And I navigate to "Group overrides" node in "Quiz administration"
+    Then I should not see "Group 1"
index d380281..7985d9d 100644 (file)
@@ -1,6 +1,9 @@
 This files describes API changes in /mod/* - activity modules,
 information provided here is intended especially for developers.
 
+=== 2.9 ===
+* Added Grade to pass field to mod_form for activities that support grading.
+
 === 2.8 ===
 
 * Constant FEATURE_GROUPMEMBERSONLY is deprecated. Modules should remove this
index d7085c8..a3cda79 100644 (file)
@@ -154,6 +154,8 @@ $string['gradeover'] = 'Override grade for submission';
 $string['gradesreport'] = 'Workshop grades report';
 $string['gradereceivedfrom'] = '&lt;';
 $string['gradeinfo'] = 'Grade: {$a->received} of {$a->max}';
+$string['gradetopasssubmission'] = 'Submission grade to pass';
+$string['gradetopassgrading'] = 'Assessment grade to pass';
 $string['gradinggrade'] = 'Grade for assessment';
 $string['gradinggrade_help'] = 'This setting specifies the maximum grade that may be obtained for submission assessment.';
 $string['gradinggradecalculated'] = 'Calculated grade for assessment';
index faa01f0..9e8e1f6 100644 (file)
@@ -1140,10 +1140,20 @@ function workshop_grade_item_category_update($workshop) {
     if (!empty($gradeitems)) {
         foreach ($gradeitems as $gradeitem) {
             if ($gradeitem->itemnumber == 0) {
+                if (isset($workshop->submissiongradepass) &&
+                        $gradeitem->gradepass != $workshop->submissiongradepass) {
+                    $gradeitem->gradepass = $workshop->submissiongradepass;
+                    $gradeitem->update();
+                }
                 if ($gradeitem->categoryid != $workshop->gradecategory) {
                     $gradeitem->set_parent($workshop->gradecategory);
                 }
             } else if ($gradeitem->itemnumber == 1) {
+                if (isset($workshop->gradinggradepass) &&
+                        $gradeitem->gradepass != $workshop->gradinggradepass) {
+                    $gradeitem->gradepass = $workshop->gradinggradepass;
+                    $gradeitem->update();
+                }
                 if ($gradeitem->categoryid != $workshop->gradinggradecategory) {
                     $gradeitem->set_parent($workshop->gradinggradecategory);
                 }
index 8489fe7..8a0597b 100644 (file)
@@ -97,6 +97,12 @@ class mod_workshop_mod_form extends moodleform_mod {
         $mform->setDefault('grade', $workshopconfig->grade);
         $mform->addHelpButton('submissiongradegroup', 'submissiongrade', 'workshop');
 
+        $mform->addElement('text', 'submissiongradepass', get_string('gradetopasssubmission', 'workshop'));
+        $mform->addHelpButton('submissiongradepass', 'gradepass', 'grades');
+        $mform->setDefault('submissiongradepass', '');
+        $mform->setType('submissiongradepass', PARAM_FLOAT);
+        $mform->addRule('submissiongradepass', null, 'numeric', null, 'client');
+
         $label = get_string('gradinggrade', 'workshop');
         $mform->addGroup(array(
             $mform->createElement('select', 'gradinggrade', '', $grades),
@@ -105,6 +111,12 @@ class mod_workshop_mod_form extends moodleform_mod {
         $mform->setDefault('gradinggrade', $workshopconfig->gradinggrade);
         $mform->addHelpButton('gradinggradegroup', 'gradinggrade', 'workshop');
 
+        $mform->addElement('text', 'gradinggradepass', get_string('gradetopassgrading', 'workshop'));
+        $mform->addHelpButton('gradinggradepass', 'gradepass', 'grades');
+        $mform->setDefault('gradinggradepass', '');
+        $mform->setType('gradinggradepass', PARAM_FLOAT);
+        $mform->addRule('gradinggradepass', null, 'numeric', null, 'client');
+
         $options = array();
         for ($i=5; $i>=0; $i--) {
             $options[$i] = $i;
@@ -296,7 +308,10 @@ class mod_workshop_mod_form extends moodleform_mod {
                 foreach ($gradeitems as $gradeitem) {
                     // here comes really crappy way how to set the value of the fields
                     // gradecategory and gradinggradecategory - grrr QuickForms
+                    $decimalpoints = $gradeitem->get_decimals();
                     if ($gradeitem->itemnumber == 0) {
+                        $submissiongradepass = $mform->getElement('submissiongradepass');
+                        $submissiongradepass->setValue(format_float($gradeitem->gradepass, $decimalpoints));
                         $group = $mform->getElement('submissiongradegroup');
                         $elements = $group->getElements();
                         foreach ($elements as $element) {
@@ -305,6 +320,8 @@ class mod_workshop_mod_form extends moodleform_mod {
                             }
                         }
                     } else if ($gradeitem->itemnumber == 1) {
+                        $gradinggradepass = $mform->getElement('gradinggradepass');
+                        $gradinggradepass->setValue(format_float($gradeitem->gradepass, $decimalpoints));
                         $group = $mform->getElement('gradinggradegroup');
                         $elements = $group->getElements();
                         foreach ($elements as $element) {
@@ -355,6 +372,13 @@ class mod_workshop_mod_form extends moodleform_mod {
             }
         }
 
+        if ($data['submissiongradepass'] > $data['grade']) {
+            $errors['submissiongradepass'] = get_string('gradepassgreaterthangrade', 'grades', $data['grade']);
+        }
+        if ($data['gradinggradepass'] > $data['gradinggrade']) {
+            $errors['gradinggradepass'] = get_string('gradepassgreaterthangrade', 'grades', $data['gradinggrade']);
+        }
+
         return $errors;
     }
 }
index 0eb2418..a701073 100644 (file)
@@ -1128,20 +1128,34 @@ class core_user_external extends external_api {
             return $warnings;
         }
 
-        $userdevice = new stdclass;
-        $userdevice->userid     = $USER->id;
-        $userdevice->appid      = $params['appid'];
-        $userdevice->name       = $params['name'];
-        $userdevice->model      = $params['model'];
-        $userdevice->platform   = $params['platform'];
-        $userdevice->version    = $params['version'];
-        $userdevice->pushid     = $params['pushid'];
-        $userdevice->uuid       = $params['uuid'];
-        $userdevice->timecreated  = time();
-        $userdevice->timemodified = $userdevice->timecreated;
-
-        if (!$DB->insert_record('user_devices', $userdevice)) {
-            throw new moodle_exception("There was a problem saving in the database the device with key: " . $params['pushid']);
+        // Notice that we can have multiple devices because previously it was allowed to have repeated ones.
+        // Since we don't have a clear way to decide which one is the more appropiate, we update all.
+        if ($userdevices = $DB->get_records('user_devices', array('uuid' => $params['uuid'],
+                'appid' => $params['appid'], 'userid' => $USER->id))) {
+
+            foreach ($userdevices as $userdevice) {
+                $userdevice->version    = $params['version'];   // Maybe the user upgraded the device.
+                $userdevice->pushid     = $params['pushid'];
+                $userdevice->timemodified  = time();
+                $DB->update_record('user_devices', $userdevice);
+            }
+
+        } else {
+            $userdevice = new stdclass;
+            $userdevice->userid     = $USER->id;
+            $userdevice->appid      = $params['appid'];
+            $userdevice->name       = $params['name'];
+            $userdevice->model      = $params['model'];
+            $userdevice->platform   = $params['platform'];
+            $userdevice->version    = $params['version'];
+            $userdevice->pushid     = $params['pushid'];
+            $userdevice->uuid       = $params['uuid'];
+            $userdevice->timecreated  = time();
+            $userdevice->timemodified = $userdevice->timecreated;
+
+            if (!$DB->insert_record('user_devices', $userdevice)) {
+                throw new moodle_exception("There was a problem saving in the database the device with key: " . $params['pushid']);
+            }
         }
 
         return $warnings;
index 32b4132..173d5f2 100644 (file)
@@ -749,6 +749,29 @@ class core_user_externallib_testcase extends externallib_advanced_testcase {
         $created = (array) $created;
 
         $this->assertEquals($device, array_intersect_key((array)$created, $device));
+
+        // Test reuse the same pushid value.
+        $warnings = core_user_external::add_user_device($device['appid'], $device['name'], $device['model'], $device['platform'],
+                                                        $device['version'], $device['pushid'], $device['uuid']);
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $warnings = external_api::clean_returnvalue(core_user_external::add_user_device_returns(), $warnings);
+        $this->assertCount(1, $warnings);
+
+        // Test update an existing device.
+        $device['pushid'] = 'different than before';
+        $warnings = core_user_external::add_user_device($device['appid'], $device['name'], $device['model'], $device['platform'],
+                                                        $device['version'], $device['pushid'], $device['uuid']);
+
+        $this->assertEquals(1, $DB->count_records('user_devices'));
+        $updated = $DB->get_record('user_devices', array('pushid' => $device['pushid']));
+        $this->assertEquals($device, array_intersect_key((array)$updated, $device));
+
+        // Test creating a new device just changing the uuid.
+        $device['uuid'] = 'newuidforthesameuser';
+        $device['pushid'] = 'new different than before';
+        $warnings = core_user_external::add_user_device($device['appid'], $device['name'], $device['model'], $device['platform'],
+                                                        $device['version'], $device['pushid'], $device['uuid']);
+        $this->assertEquals(2, $DB->count_records('user_devices'));
     }
 
     /**