MDL-44070 Conditional availability enhancements (4): backup
authorsam marshall <s.marshall@open.ac.uk>
Thu, 13 Mar 2014 17:59:48 +0000 (17:59 +0000)
committersam marshall <s.marshall@open.ac.uk>
Mon, 7 Apr 2014 19:11:41 +0000 (20:11 +0100)
Implements backup and restore for the new conditional availability
data. This includes:

* Backup and restore of new field.
* Restore updates IDs in conditions, as required.
* Restore converts availability data from legacy (Moodle 2.6) backups.
* Unit tests for all this.

backup/converter/moodle1/handlerlib.php
backup/moodle2/backup_stepslib.php
backup/moodle2/restore_final_task.class.php
backup/moodle2/restore_stepslib.php
backup/moodle2/tests/fixtures/availability_26_format.mbz [new file with mode: 0644]
backup/moodle2/tests/moodle2_test.php [new file with mode: 0644]

index 3973a8b..5265fa0 100644 (file)
@@ -799,10 +799,7 @@ class moodle1_course_outline_handler extends moodle1_xml_handler {
                         'completiongradeitemnumber' => null,
                         'completionview'            => 0,
                         'completionexpected'        => 0,
-                        'availablefrom'             => 0,
-                        'availableuntil'            => 0,
-                        'showavailability'          => 0,
-                        'availability_info'         => array(),
+                        'availability'              => null,
                         'visibleold'                => 1,
                         'showdescription'           => 0,
                     ),
index 5ec6bad..b488274 100644 (file)
@@ -323,13 +323,7 @@ class backup_module_structure_step extends backup_structure_step {
             'added', 'score', 'indent', 'visible',
             'visibleold', 'groupmode', 'groupingid', 'groupmembersonly',
             'completion', 'completiongradeitemnumber', 'completionview', 'completionexpected',
-            'availablefrom', 'availableuntil', 'showavailability', 'showdescription'));
-
-        $availinfo = new backup_nested_element('availability_info');
-        $availability = new backup_nested_element('availability', array('id'), array(
-            'sourcecmid', 'requiredcompletion', 'gradeitemid', 'grademin', 'grademax'));
-        $availabilityfield = new backup_nested_element('availability_field', array('id'), array(
-            'userfield', 'customfield', 'customfieldtype', 'operator', 'value'));
+            'availability', 'showdescription'));
 
         // attach format plugin structure to $module element, only one allowed
         $this->add_plugin_structure('format', $module, false);
@@ -341,11 +335,6 @@ class backup_module_structure_step extends backup_structure_step {
         // attach local plugin structure to $module, multiple allowed
         $this->add_plugin_structure('local', $module, true);
 
-        // Define the tree
-        $module->add_child($availinfo);
-        $availinfo->add_child($availability);
-        $availinfo->add_child($availabilityfield);
-
         // Set the sources
         $concat = $DB->sql_concat("'mod_'", 'm.name');
         $module->set_source_sql("
@@ -356,13 +345,6 @@ class backup_module_structure_step extends backup_structure_step {
               JOIN {course_sections} s ON s.id = cm.section
              WHERE cm.id = ?", array(backup::VAR_MODID));
 
-        $availability->set_source_table('course_modules_availability', array('coursemoduleid' => backup::VAR_MODID));
-        $availabilityfield->set_source_sql('
-            SELECT cmaf.*, uif.shortname AS customfield, uif.datatype AS customfieldtype
-              FROM {course_modules_avail_fields} cmaf
-         LEFT JOIN {user_info_field} uif ON uif.id = cmaf.customfieldid
-             WHERE cmaf.coursemoduleid = ?', array(backup::VAR_MODID));
-
         // Define annotations
         $module->annotate_ids('grouping', 'groupingid');
 
@@ -383,7 +365,7 @@ class backup_section_structure_step extends backup_structure_step {
 
         $section = new backup_nested_element('section', array('id'), array(
                 'number', 'name', 'summary', 'summaryformat', 'sequence', 'visible',
-                'availablefrom', 'availableuntil', 'showavailability', 'groupingid'));
+                'availabilityjson'));
 
         // attach format plugin structure to $section element, only one allowed
         $this->add_plugin_structure('format', $section, false);
@@ -391,27 +373,19 @@ class backup_section_structure_step extends backup_structure_step {
         // attach local plugin structure to $section element, multiple allowed
         $this->add_plugin_structure('local', $section, true);
 
-        // Add nested elements for _availability table
-        $avail = new backup_nested_element('availability', array('id'), array(
-                'sourcecmid', 'requiredcompletion', 'gradeitemid', 'grademin', 'grademax'));
-        $availfield = new backup_nested_element('availability_field', array('id'), array(
-            'userfield', 'operator', 'value', 'customfield', 'customfieldtype'));
-        $section->add_child($avail);
-        $section->add_child($availfield);
-
         // Add nested elements for course_format_options table
         $formatoptions = new backup_nested_element('course_format_options', array('id'), array(
             'format', 'name', 'value'));
         $section->add_child($formatoptions);
 
-        // Define sources
-        $section->set_source_table('course_sections', array('id' => backup::VAR_SECTIONID));
-        $avail->set_source_table('course_sections_availability', array('coursesectionid' => backup::VAR_SECTIONID));
-        $availfield->set_source_sql('
-            SELECT csaf.*, uif.shortname AS customfield, uif.datatype AS customfieldtype
-              FROM {course_sections_avail_fields} csaf
-         LEFT JOIN {user_info_field} uif ON uif.id = csaf.customfieldid
-             WHERE csaf.coursesectionid = ?', array(backup::VAR_SECTIONID));
+        // Define sources.
+        // The 'availability' field needs to be renamed because it clashes with
+        // the old nested element structure for availability data.
+        $section->set_source_sql("
+                SELECT *, availability AS availabilityjson
+                  FROM {course_sections} WHERE id = ?",
+                array('id' => backup::VAR_SECTIONID));
+
         $formatoptions->set_source_sql('SELECT cfo.id, cfo.format, cfo.name, cfo.value
               FROM {course} c
               JOIN {course_format_options} cfo
@@ -423,7 +397,6 @@ class backup_section_structure_step extends backup_structure_step {
         $section->set_source_alias('section', 'number');
 
         // Set annotations
-        $section->annotate_ids('grouping', 'groupingid');
         $section->annotate_files('course', 'section', 'id');
 
         return $section;
index 71371cc..985cd72 100644 (file)
@@ -71,10 +71,15 @@ class restore_final_task extends restore_task {
             $this->add_step(new restore_badges_structure_step('course_badges', 'badges.xml'));
         }
 
-        // Review all the module_availability records in backup_ids in order
-        // to match them with existing modules / grade items.
+        // Review all the legacy module_availability records in backup_ids in
+        // order to match them with existing modules / grade items and convert
+        // into the new system.
         $this->add_step(new restore_process_course_modules_availability('process_modules_availability'));
 
+        // Update restored availability data to account for changes in IDs
+        // during backup/restore.
+        $this->add_step(new restore_update_availability('update_availability'));
+
         // Decode all the interlinks
         $this->add_step(new restore_decode_interlinks('decode_interlinks'));
 
index c56dccd..faff785 100644 (file)
@@ -532,11 +532,94 @@ class restore_review_pending_block_positions extends restore_execution_step {
     }
 }
 
+
+/**
+ * Updates the availability data for course modules and sections.
+ *
+ * Runs after the restore of all course modules, sections, and grade items has
+ * completed. This is necessary in order to update IDs that have changed during
+ * restore.
+ *
+ * @package core_backup
+ * @copyright 2014 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class restore_update_availability extends restore_execution_step {
+
+    protected function define_execution() {
+        global $CFG, $DB;
+
+        // Note: This code runs even if availability is disabled when restoring.
+        // That will ensure that if you later turn availability on for the site,
+        // there will be no incorrect IDs. (It doesn't take long if the restored
+        // data does not contain any availability information.)
+
+        // Get modinfo with all data after resetting cache.
+        rebuild_course_cache($this->get_courseid(), true);
+        $modinfo = get_fast_modinfo($this->get_courseid());
+
+        // Update all sections that were restored.
+        $params = array('backupid' => $this->get_restoreid(), 'itemname' => 'course_section');
+        $rs = $DB->get_recordset('backup_ids_temp', $params, '', 'newitemid');
+        $sectionsbyid = null;
+        foreach ($rs as $rec) {
+            if (is_null($sectionsbyid)) {
+                $sectionsbyid = array();
+                foreach ($modinfo->get_section_info_all() as $section) {
+                    $sectionsbyid[$section->id] = $section;
+                }
+            }
+            if (!array_key_exists($rec->newitemid, $sectionsbyid)) {
+                // If the section was not fully restored for some reason
+                // (e.g. due to an earlier error), skip it.
+                $this->get_logger()->process('Section not fully restored: id ' .
+                        $rec->newitemid, backup::LOG_WARNING);
+                continue;
+            }
+            $section = $sectionsbyid[$rec->newitemid];
+            if (!is_null($section->availability)) {
+                $info = new \core_availability\info_section($section);
+                $info->update_after_restore($this->get_restoreid(),
+                        $this->get_courseid(), $this->get_logger());
+            }
+        }
+        $rs->close();
+
+        // Update all modules that were restored.
+        $params = array('backupid' => $this->get_restoreid(), 'itemname' => 'course_module');
+        $rs = $DB->get_recordset('backup_ids_temp', $params, '', 'newitemid');
+        foreach ($rs as $rec) {
+            if (!array_key_exists($rec->newitemid, $modinfo->cms)) {
+                // If the module was not fully restored for some reason
+                // (e.g. due to an earlier error), skip it.
+                $this->get_logger()->process('Module not fully restored: id ' .
+                        $rec->newitemid, backup::LOG_WARNING);
+                continue;
+            }
+            $cm = $modinfo->get_cm($rec->newitemid);
+            if (!is_null($cm->availability)) {
+                $info = new \core_availability\info_module($cm);
+                $info->update_after_restore($this->get_restoreid(),
+                        $this->get_courseid(), $this->get_logger());
+            }
+        }
+        $rs->close();
+    }
+}
+
+
 /**
- * Process all the saved module availability records in backup_ids, matching
- * course modules and grade item id once all them have been already restored.
- * only if all matchings are satisfied the availability condition will be created.
+ * Process legacy module availability records in backup_ids.
+ *
+ * Matches course modules and grade item id once all them have been already restored.
+ * Only if all matchings are satisfied the availability condition will be created.
  * At the same time, it is required for the site to have that functionality enabled.
+ *
+ * This step is included only to handle legacy backups (2.6 and before). It does not
+ * do anything for newer backups.
+ *
+ * @copyright 2014 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
  */
 class restore_process_course_modules_availability extends restore_execution_step {
 
@@ -548,34 +631,39 @@ class restore_process_course_modules_availability extends restore_execution_step
             return;
         }
 
-        // Get all the module_availability objects to process
-        $params = array('backupid' => $this->get_restoreid(), 'itemname' => 'module_availability');
-        $rs = $DB->get_recordset('backup_ids_temp', $params, '', 'itemid, info');
-        // Process availabilities, creating them if everything matches ok
-        foreach($rs as $availrec) {
-            $allmatchesok = true;
-            // Get the complete availabilityobject
-            $availability = backup_controller_dbops::decode_backup_temp_info($availrec->info);
-            // Map the sourcecmid if needed and possible
-            if (!empty($availability->sourcecmid)) {
-                $newcm = restore_dbops::get_backup_ids_record($this->get_restoreid(), 'course_module', $availability->sourcecmid);
-                if ($newcm) {
-                    $availability->sourcecmid = $newcm->newitemid;
-                } else {
-                    $allmatchesok = false; // Failed matching, we won't create this availability rule
-                }
-            }
-            // Map the gradeitemid if needed and possible
-            if (!empty($availability->gradeitemid)) {
-                $newgi = restore_dbops::get_backup_ids_record($this->get_restoreid(), 'grade_item', $availability->gradeitemid);
-                if ($newgi) {
-                    $availability->gradeitemid = $newgi->newitemid;
-                } else {
-                    $allmatchesok = false; // Failed matching, we won't create this availability rule
+        // Do both modules and sections.
+        foreach (array('module', 'section') as $table) {
+            // Get all the availability objects to process.
+            $params = array('backupid' => $this->get_restoreid(), 'itemname' => $table . '_availability');
+            $rs = $DB->get_recordset('backup_ids_temp', $params, '', 'itemid, info');
+            // Process availabilities, creating them if everything matches ok.
+            foreach ($rs as $availrec) {
+                $allmatchesok = true;
+                // Get the complete legacy availability object.
+                $availability = backup_controller_dbops::decode_backup_temp_info($availrec->info);
+
+                // Note: This code used to update IDs, but that is now handled by the
+                // current code (after restore) instead of this legacy code.
+
+                // Get showavailability option.
+                $thingid = ($table === 'module') ? $availability->coursemoduleid :
+                        $availability->coursesectionid;
+                $showrec = restore_dbops::get_backup_ids_record($this->get_restoreid(),
+                        $table . '_showavailability', $thingid);
+                if (!$showrec) {
+                    // Should not happen.
+                    throw new coding_exception('No matching showavailability record');
                 }
-            }
-            if ($allmatchesok) { // Everything ok, create the availability rule
-                $DB->insert_record('course_modules_availability', $availability);
+                $show = $showrec->info->showavailability;
+
+                // The $availability object is now in the format used in the old
+                // system. Interpret this and convert to new system.
+                $currentvalue = $DB->get_field('course_' . $table . 's', 'availability',
+                        array('id' => $thingid), MUST_EXIST);
+                $newvalue = \core_availability\info::add_legacy_availability_condition(
+                        $currentvalue, $availability, $show);
+                $DB->set_field('course_' . $table . 's', 'availability', $newvalue,
+                        array('id' => $thingid));
             }
         }
         $rs->close();
@@ -1159,16 +1247,14 @@ class restore_section_structure_step extends restore_structure_step {
             $section->sequence = '';
             $section->visible = $data->visible;
             if (empty($CFG->enableavailability)) { // Process availability information only if enabled.
-                $section->availablefrom = 0;
-                $section->availableuntil = 0;
-                $section->showavailability = 0;
+                $section->availability = null;
             } else {
-                $section->availablefrom = isset($data->availablefrom) ? $this->apply_date_offset($data->availablefrom) : 0;
-                $section->availableuntil = isset($data->availableuntil) ? $this->apply_date_offset($data->availableuntil) : 0;
-                $section->showavailability = isset($data->showavailability) ? $data->showavailability : 0;
-            }
-            if (!empty($CFG->enablegroupmembersonly)) { // Only if enablegroupmembersonly is enabled
-                $section->groupingid = isset($data->groupingid) ? $this->get_mappingid('grouping', $data->groupingid) : 0;
+                $section->availability = isset($data->availabilityjson) ? $data->availabilityjson : null;
+                // Include legacy [<2.7] availability data if provided.
+                if (is_null($section->availability)) {
+                    $section->availability = \core_availability\info::convert_legacy_fields(
+                            $data, true);
+                }
             }
             $newitemid = $DB->insert_record('course_sections', $section);
             $restorefiles = true;
@@ -1184,15 +1270,9 @@ class restore_section_structure_step extends restore_structure_step {
                 $section->summaryformat = $data->summaryformat;
                 $restorefiles = true;
             }
-            if (empty($secrec->groupingid)) {
-                if (!empty($CFG->enablegroupmembersonly)) { // Only if enablegroupmembersonly is enabled
-                    $section->groupingid = isset($data->groupingid) ? $this->get_mappingid('grouping', $data->groupingid) : 0;
-                }
-            }
 
-            // Don't update available from, available until, or show availability
-            // (I didn't see a useful way to define whether existing or new one should
-            // take precedence).
+            // Don't update availability (I didn't see a useful way to define
+            // whether existing or new one should take precedence).
 
             $DB->update_record('course_sections', $section);
             $newitemid = $secrec->id;
@@ -1204,6 +1284,14 @@ class restore_section_structure_step extends restore_structure_step {
         // set the new course_section id in the task
         $this->task->set_sectionid($newitemid);
 
+        // If there is the legacy showavailability data, store this for later use.
+        // (This data is not present when restoring 'new' backups.)
+        if (isset($data->showavailability)) {
+            // Cache the showavailability flag using the backup_ids data field.
+            restore_dbops::set_backup_ids_record($this->get_restoreid(),
+                    'section_showavailability', $newitemid, 0, null,
+                    (object)array('showavailability' => $data->showavailability));
+        }
 
         // Commented out. We never modify course->numsections as far as that is used
         // by a lot of people to "hide" sections on purpose (so this remains as used to be in Moodle 1.x)
@@ -1217,24 +1305,28 @@ class restore_section_structure_step extends restore_structure_step {
         //}
     }
 
+    /**
+     * Process the legacy availability table record. This table does not exist
+     * in Moodle 2.7+ but we still support restore.
+     *
+     * @param stdClass $data Record data
+     */
     public function process_availability($data) {
-        global $DB;
         $data = (object)$data;
-
+        // Simply going to store the whole availability record now, we'll process
+        // all them later in the final task (once all activities have been restored)
+        // Let's call the low level one to be able to store the whole object.
         $data->coursesectionid = $this->task->get_sectionid();
-
-        // NOTE: Other values in $data need updating, but these (cm,
-        // grade items) have not yet been restored, so are done later.
-
-        $newid = $DB->insert_record('course_sections_availability', $data);
-
-        // We do not need to map between old and new id but storing a mapping
-        // means it gets added to the backup_ids table to record which ones
-        // need updating. The mapping is stored with $newid => $newid for
-        // convenience.
-        $this->set_mapping('course_sections_availability', $newid, $newid);
+        restore_dbops::set_backup_ids_record($this->get_restoreid(),
+                'section_availability', $data->id, 0, null, $data);
     }
 
+    /**
+     * Process the legacy availability fields table record. This table does not
+     * exist in Moodle 2.7+ but we still support restore.
+     *
+     * @param stdClass $data Record data
+     */
     public function process_availability_field($data) {
         global $DB;
         $data = (object)$data;
@@ -1262,7 +1354,24 @@ class restore_section_structure_step extends restore_structure_step {
             $availfield->customfieldid = $customfieldid;
             $availfield->operator = $data->operator;
             $availfield->value = $data->value;
-            $DB->insert_record('course_sections_avail_fields', $availfield);
+
+            // Get showavailability option.
+            $showrec = restore_dbops::get_backup_ids_record($this->get_restoreid(),
+                    'section_showavailability', $availfield->coursesectionid);
+            if (!$showrec) {
+                // Should not happen.
+                throw new coding_exception('No matching showavailability record');
+            }
+            $show = $showrec->info->showavailability;
+
+            // The $availfield object is now in the format used in the old
+            // system. Interpret this and convert to new system.
+            $currentvalue = $DB->get_field('course_sections', 'availability',
+                    array('id' => $availfield->coursesectionid), MUST_EXIST);
+            $newvalue = \core_availability\info::add_legacy_availability_field_condition(
+                    $currentvalue, $availfield, $show);
+            $DB->set_field('course_sections', 'availability', $newvalue,
+                    array('id' => $availfield->coursesectionid));
         }
     }
 
@@ -1281,46 +1390,8 @@ class restore_section_structure_step extends restore_structure_step {
         // Add section related files, with 'course_section' itemid to match
         $this->add_related_files('course', 'section', 'course_section');
     }
-
-    public function after_restore() {
-        global $DB;
-
-        $sectionid = $this->get_task()->get_sectionid();
-
-        // Get data object for current section availability (if any).
-        $records = $DB->get_records('course_sections_availability',
-                array('coursesectionid' => $sectionid), 'id, sourcecmid, gradeitemid');
-
-        // If it exists, update mappings.
-        foreach ($records as $data) {
-            // Only update mappings for entries which are created by this restore.
-            // Otherwise, when you restore to an existing course, it will mess up
-            // existing section availability entries.
-            if (!$this->get_mappingid('course_sections_availability', $data->id, false)) {
-                continue;
-            }
-
-            // Update source cmid / grade id to new value.
-            $data->sourcecmid = $this->get_mappingid('course_module', $data->sourcecmid);
-            if (!$data->sourcecmid) {
-                $data->sourcecmid = null;
-            }
-            $data->gradeitemid = $this->get_mappingid('grade_item', $data->gradeitemid);
-            if (!$data->gradeitemid) {
-                $data->gradeitemid = null;
-            }
-
-            // Delete the record if the condition wasn't found, otherwise update it.
-            if ($data->sourcecmid === null && $data->gradeitemid === null) {
-                $DB->delete_records('course_sections_availability', array('id' => $data->id));
-            } else {
-                $DB->update_record('course_sections_availability', $data);
-            }
-        }
-    }
 }
 
-
 /**
  * Structure step that will read the course.xml file, loading it and performing
  * various actions depending of the site/restore settings. Note that target
@@ -3030,9 +3101,6 @@ class restore_module_structure_step extends restore_structure_step {
             $data->section = $DB->insert_record('course_sections', $sectionrec); // section 1
         }
         $data->groupingid= $this->get_mappingid('grouping', $data->groupingid);      // grouping
-        if (!$CFG->enablegroupmembersonly) {                                         // observe groupsmemberonly
-            $data->groupmembersonly = 0;
-        }
         if (!grade_verify_idnumber($data->idnumber, $this->get_courseid())) {        // idnumber uniqueness
             $data->idnumber = '';
         }
@@ -3045,12 +3113,7 @@ class restore_module_structure_step extends restore_structure_step {
             $data->completionexpected = $this->apply_date_offset($data->completionexpected);
         }
         if (empty($CFG->enableavailability)) {
-            $data->availablefrom = 0;
-            $data->availableuntil = 0;
-            $data->showavailability = 0;
-        } else {
-            $data->availablefrom = $this->apply_date_offset($data->availablefrom);
-            $data->availableuntil= $this->apply_date_offset($data->availableuntil);
+            $data->availability = null;
         }
         // Backups that did not include showdescription, set it to default 0
         // (this is not totally necessary as it has a db default, but just to
@@ -3060,6 +3123,15 @@ class restore_module_structure_step extends restore_structure_step {
         }
         $data->instance = 0; // Set to 0 for now, going to create it soon (next step)
 
+        // If there are legacy availablility data fields (and no new format data),
+        // convert the old fields.
+        if (empty($data->availability)) {
+            // If groupmembersonly is disabled on this system, convert the
+            // groupmembersonly option into the new API. Otherwise don't.
+            $data->availability = \core_availability\info::convert_legacy_fields(
+                    $data, false, !$CFG->enablegroupmembersonly);
+        }
+
         // course_module record ready, insert it
         $newitemid = $DB->insert_record('course_modules', $data);
         // save mapping
@@ -3077,8 +3149,23 @@ class restore_module_structure_step extends restore_structure_step {
             $sequence = $newitemid;
         }
         $DB->set_field('course_sections', 'sequence', $sequence, array('id' => $data->section));
+
+        // If there is the legacy showavailability data, store this for later use.
+        // (This data is not present when restoring 'new' backups.)
+        if (isset($data->showavailability)) {
+            // Cache the showavailability flag using the backup_ids data field.
+            restore_dbops::set_backup_ids_record($this->get_restoreid(),
+                    'module_showavailability', $newitemid, 0, null,
+                    (object)array('showavailability' => $data->showavailability));
+        }
     }
 
+    /**
+     * Process the legacy availability table record. This table does not exist
+     * in Moodle 2.7+ but we still support restore.
+     *
+     * @param stdClass $data Record data
+     */
     protected function process_availability($data) {
         $data = (object)$data;
         // Simply going to store the whole availability record now, we'll process
@@ -3088,6 +3175,12 @@ class restore_module_structure_step extends restore_structure_step {
         restore_dbops::set_backup_ids_record($this->get_restoreid(), 'module_availability', $data->id, 0, null, $data);
     }
 
+    /**
+     * Process the legacy availability fields table record. This table does not
+     * exist in Moodle 2.7+ but we still support restore.
+     *
+     * @param stdClass $data Record data
+     */
     protected function process_availability_field($data) {
         global $DB;
         $data = (object)$data;
@@ -3115,8 +3208,24 @@ class restore_module_structure_step extends restore_structure_step {
             $availfield->customfieldid = $customfieldid;
             $availfield->operator = $data->operator;
             $availfield->value = $data->value;
-            // Insert into the database
-            $DB->insert_record('course_modules_avail_fields', $availfield);
+
+            // Get showavailability option.
+            $showrec = restore_dbops::get_backup_ids_record($this->get_restoreid(),
+                    'module_showavailability', $availfield->coursemoduleid);
+            if (!$showrec) {
+                // Should not happen.
+                throw new coding_exception('No matching showavailability record');
+            }
+            $show = $showrec->info->showavailability;
+
+            // The $availfieldobject is now in the format used in the old
+            // system. Interpret this and convert to new system.
+            $currentvalue = $DB->get_field('course_modules', 'availability',
+                    array('id' => $availfield->coursemoduleid), MUST_EXIST);
+            $newvalue = \core_availability\info::add_legacy_availability_field_condition(
+                    $currentvalue, $availfield, $show);
+            $DB->set_field('course_modules', 'availability', $newvalue,
+                    array('id' => $availfield->coursemoduleid));
         }
     }
 }
diff --git a/backup/moodle2/tests/fixtures/availability_26_format.mbz b/backup/moodle2/tests/fixtures/availability_26_format.mbz
new file mode 100644 (file)
index 0000000..8dd0d31
Binary files /dev/null and b/backup/moodle2/tests/fixtures/availability_26_format.mbz differ
diff --git a/backup/moodle2/tests/moodle2_test.php b/backup/moodle2/tests/moodle2_test.php
new file mode 100644 (file)
index 0000000..9ae0486
--- /dev/null
@@ -0,0 +1,412 @@
+<?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/>.
+
+/**
+ * Tests for Moodle 2 format backup operation.
+ *
+ * @package core_backup
+ * @copyright 2014 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
+require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
+require_once($CFG->libdir . '/completionlib.php');
+
+/**
+ * Tests for Moodle 2 format backup operation.
+ *
+ * @package core_backup
+ * @copyright 2014 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_backup_moodle2_testcase extends advanced_testcase {
+
+    /**
+     * Tests the availability field on modules and sections is correctly
+     * backed up and restored.
+     */
+    public function test_backup_availability() {
+        global $DB, $CFG;
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $CFG->enableavailability = true;
+        $CFG->enablecompletion = true;
+
+        // Create a course with some availability data set.
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course(
+                array('format' => 'topics', 'numsections' => 3,
+                    'enablecompletion' => COMPLETION_ENABLED),
+                array('createsections' => true));
+        $forum = $generator->create_module('forum', array(
+                'course' => $course->id));
+        $forum2 = $generator->create_module('forum', array(
+                'course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL));
+
+        // We need a grade, easiest is to add an assignment.
+        $assignrow = $generator->create_module('assign', array(
+                'course' => $course->id));
+        $assign = new assign(context_module::instance($assignrow->cmid), false, false);
+        $item = $assign->get_grade_item();
+
+        // Make a test grouping as well.
+        $grouping = $generator->create_grouping(array('courseid' => $course->id,
+                'name' => 'Grouping!'));
+
+        $availability = '{"op":"|","show":false,"c":[' .
+                '{"type":"completion","cm":' . $forum2->cmid .',"e":1},' .
+                '{"type":"grade","id":' . $item->id . ',"min":4,"max":94},' .
+                '{"type":"grouping","id":' . $grouping->id . '}' .
+                ']}';
+        $DB->set_field('course_modules', 'availability', $availability, array(
+                'id' => $forum->cmid));
+        $DB->set_field('course_sections', 'availability', $availability, array(
+                'course' => $course->id, 'section' => 1));
+
+        // Backup and restore it.
+        $newcourseid = $this->backup_and_restore($course);
+
+        // Check settings in new course.
+        $modinfo = get_fast_modinfo($newcourseid);
+        $forums = array_values($modinfo->get_instances_of('forum'));
+        $assigns = array_values($modinfo->get_instances_of('assign'));
+        $newassign = new assign(context_module::instance($assigns[0]->id), false, false);
+        $newitem = $newassign->get_grade_item();
+        $newgroupingid = $DB->get_field('groupings', 'id', array('courseid' => $newcourseid));
+
+        // Expected availability should have new ID for the forum, grade, and grouping.
+        $newavailability = str_replace(
+                '"grouping","id":' . $grouping->id,
+                '"grouping","id":' . $newgroupingid,
+                str_replace(
+                    '"grade","id":' . $item->id,
+                    '"grade","id":' . $newitem->id,
+                    str_replace(
+                        '"cm":' . $forum2->cmid,
+                        '"cm":' . $forums[1]->id,
+                        $availability)));
+
+        $this->assertEquals($newavailability, $forums[0]->availability);
+        $this->assertNull($forums[1]->availability);
+        $this->assertEquals($newavailability, $modinfo->get_section_info(1, MUST_EXIST)->availability);
+        $this->assertNull($modinfo->get_section_info(2, MUST_EXIST)->availability);
+    }
+
+    /**
+     * The availability data format was changed in Moodle 2.7. This test
+     * ensures that a Moodle 2.6 backup with this data can still be correctly
+     * restored.
+     */
+    public function test_restore_legacy_availability() {
+        global $DB, $USER, $CFG;
+        require_once($CFG->dirroot . '/grade/querylib.php');
+        require_once($CFG->libdir . '/completionlib.php');
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $CFG->enableavailability = true;
+        $CFG->enablecompletion = true;
+
+        // Extract backup file.
+        $backupid = 'abc';
+        $backuppath = $CFG->tempdir . '/backup/' . $backupid;
+        check_dir_exists($backuppath);
+        get_file_packer('application/vnd.moodle.backup')->extract_to_pathname(
+                __DIR__ . '/fixtures/availability_26_format.mbz', $backuppath);
+
+        // Do restore to new course with default settings.
+        $generator = $this->getDataGenerator();
+        $categoryid = $DB->get_field_sql("SELECT MIN(id) FROM {course_categories}");
+        $newcourseid = restore_dbops::create_new_course(
+                'Test fullname', 'Test shortname', $categoryid);
+        $rc = new restore_controller($backupid, $newcourseid,
+                backup::INTERACTIVE_NO, backup::MODE_GENERAL, $USER->id,
+                backup::TARGET_NEW_COURSE);
+        $thrown = null;
+        try {
+            $this->assertTrue($rc->execute_precheck());
+            $rc->execute_plan();
+            $rc->destroy();
+        } catch (Exception $e) {
+            $thrown = $e;
+            // Because of the PHPUnit exception behaviour in this situation, we
+            // will not see this message unless it is explicitly echoed (just
+            // using it in a fail() call or similar will not work).
+            echo "\n\nEXCEPTION: " . $thrown->getMessage() . '[' .
+                    $thrown->getFile() . ':' . $thrown->getLine(). "]\n\n";
+        }
+
+        // Must set restore_controller variable to null so that php
+        // garbage-collects it; otherwise the file will be left open and
+        // attempts to delete it will cause a permission error on Windows
+        // systems, breaking unit tests.
+        $rc = null;
+        $this->assertNull($thrown);
+
+        // Get information about the resulting course and check that it is set
+        // up correctly.
+        $modinfo = get_fast_modinfo($newcourseid);
+        $pages = array_values($modinfo->get_instances_of('page'));
+        $forums = array_values($modinfo->get_instances_of('forum'));
+        $quizzes = array_values($modinfo->get_instances_of('quiz'));
+        $grouping = $DB->get_record('groupings', array('courseid' => $newcourseid));
+
+        // FROM date.
+        $this->assertEquals(
+                '{"op":"&","showc":[true],"c":[{"type":"date","d":">=","t":1893456000}]}',
+                $pages[1]->availability);
+        // UNTIL date.
+        $this->assertEquals(
+                '{"op":"&","showc":[false],"c":[{"type":"date","d":"<","t":1393977600}]}',
+                $pages[2]->availability);
+        // FROM and UNTIL.
+        $this->assertEquals(
+                '{"op":"&","showc":[true,false],"c":[' .
+                '{"type":"date","d":">=","t":1449705600},' .
+                '{"type":"date","d":"<","t":1893456000}' .
+                ']}',
+                $pages[3]->availability);
+        // Grade >= 75%.
+        $grades = array_values(grade_get_grade_items_for_activity($quizzes[0], true));
+        $gradeid = $grades[0]->id;
+        $coursegrade = grade_item::fetch_course_item($newcourseid);
+        $this->assertEquals(
+                '{"op":"&","showc":[true],"c":[{"type":"grade","id":' . $gradeid . ',"min":75}]}',
+                $pages[4]->availability);
+        // Grade < 25%.
+        $this->assertEquals(
+                '{"op":"&","showc":[true],"c":[{"type":"grade","id":' . $gradeid . ',"max":25}]}',
+                $pages[5]->availability);
+        // Grade 90-100%.
+        $this->assertEquals(
+                '{"op":"&","showc":[true],"c":[{"type":"grade","id":' . $gradeid . ',"min":90,"max":100}]}',
+                $pages[6]->availability);
+        // Email contains frog.
+        $this->assertEquals(
+                '{"op":"&","showc":[true],"c":[{"type":"profile","op":"contains","sf":"email","v":"frog"}]}',
+                $pages[7]->availability);
+        // Page marked complete..
+        $this->assertEquals(
+                '{"op":"&","showc":[true],"c":[{"type":"completion","cm":' . $pages[0]->id .
+                ',"e":' . COMPLETION_COMPLETE . '}]}',
+                $pages[8]->availability);
+        // Quiz complete but failed.
+        $this->assertEquals(
+                '{"op":"&","showc":[true],"c":[{"type":"completion","cm":' . $quizzes[0]->id .
+                ',"e":' . COMPLETION_COMPLETE_FAIL . '}]}',
+                $pages[9]->availability);
+        // Quiz complete and succeeded.
+        $this->assertEquals(
+                '{"op":"&","showc":[true],"c":[{"type":"completion","cm":' . $quizzes[0]->id .
+                ',"e":' . COMPLETION_COMPLETE_PASS. '}]}',
+                $pages[10]->availability);
+        // Quiz not complete.
+        $this->assertEquals(
+                '{"op":"&","showc":[true],"c":[{"type":"completion","cm":' . $quizzes[0]->id .
+                ',"e":' . COMPLETION_INCOMPLETE . '}]}',
+                $pages[11]->availability);
+        // Grouping.
+        $this->assertEquals(
+                '{"op":"&","showc":[false],"c":[{"type":"grouping","id":' . $grouping->id . '}]}',
+                $pages[12]->availability);
+
+        // All the options.
+        $this->assertEquals('{"op":"&",' .
+                '"showc":[false,true,false,true,true,true,true,true,true],' .
+                '"c":[' .
+                '{"type":"grouping","id":' . $grouping->id . '},' .
+                '{"type":"date","d":">=","t":1488585600},' .
+                '{"type":"date","d":"<","t":1709510400},' .
+                '{"type":"profile","op":"contains","sf":"email","v":"@"},' .
+                '{"type":"profile","op":"contains","sf":"city","v":"Frogtown"},' .
+                '{"type":"grade","id":' . $gradeid . ',"min":30,"max":35},' .
+                '{"type":"grade","id":' . $coursegrade->id . ',"min":5,"max":10},' .
+                '{"type":"completion","cm":' . $pages[0]->id . ',"e":' . COMPLETION_COMPLETE . '},' .
+                '{"type":"completion","cm":' . $quizzes[0]->id .',"e":' . COMPLETION_INCOMPLETE . '}' .
+                ']}', $pages[13]->availability);
+
+        // Group members only forum.
+        $this->assertEquals(
+                '{"op":"&","showc":[false],"c":[{"type":"group"}]}',
+                $forums[0]->availability);
+
+        // Section with lots of conditions.
+        $this->assertEquals(
+                '{"op":"&","showc":[false,false,false,false],"c":[' .
+                '{"type":"date","d":">=","t":1417737600},' .
+                '{"type":"profile","op":"contains","sf":"email","v":"@"},' .
+                '{"type":"grade","id":' . $gradeid . ',"min":20},' .
+                '{"type":"completion","cm":' . $pages[0]->id . ',"e":' . COMPLETION_COMPLETE . '}]}',
+                $modinfo->get_section_info(3)->availability);
+
+        // Section with grouping.
+        $this->assertEquals(
+                '{"op":"&","showc":[false],"c":[{"type":"grouping","id":' . $grouping->id . '}]}',
+                $modinfo->get_section_info(4)->availability);
+    }
+
+    /**
+     * Tests the backup and restore of single activity to same course (duplicate)
+     * when it contains availability conditions that depend on other items in
+     * course. 
+     */
+    public function test_duplicate_availability() {
+        global $DB, $CFG;
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $CFG->enableavailability = true;
+        $CFG->enablecompletion = true;
+
+        // Create a course with completion enabled and 2 forums.
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course(
+                array('format' => 'topics', 'enablecompletion' => COMPLETION_ENABLED));
+        $forum = $generator->create_module('forum', array(
+                'course' => $course->id));
+        $forum2 = $generator->create_module('forum', array(
+                'course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL));
+
+        // We need a grade, easiest is to add an assignment.
+        $assignrow = $generator->create_module('assign', array(
+                'course' => $course->id));
+        $assign = new assign(context_module::instance($assignrow->cmid), false, false);
+        $item = $assign->get_grade_item();
+
+        // Make a test group and grouping as well.
+        $group = $generator->create_group(array('courseid' => $course->id,
+                'name' => 'Group!'));
+        $grouping = $generator->create_grouping(array('courseid' => $course->id,
+                'name' => 'Grouping!'));
+
+        // Set the forum to have availability conditions on all those things,
+        // plus some that don't exist or are special values.
+        $availability = '{"op":"|","show":false,"c":[' .
+                '{"type":"completion","cm":' . $forum2->cmid .',"e":1},' .
+                '{"type":"completion","cm":99999999,"e":1},' .
+                '{"type":"grade","id":' . $item->id . ',"min":4,"max":94},' .
+                '{"type":"grade","id":99999998,"min":4,"max":94},' .
+                '{"type":"grouping","id":' . $grouping->id . '},' .
+                '{"type":"grouping","id":99999997},' .
+                '{"type":"group","id":' . $group->id . '},' .
+                '{"type":"group"},' .
+                '{"type":"group","id":99999996}' .
+                ']}';
+        $DB->set_field('course_modules', 'availability', $availability, array(
+                'id' => $forum->cmid));
+
+        // Duplicate it.
+        $newcmid = $this->duplicate($course, $forum->cmid);
+
+        // For those which still exist on the course we expect it to keep using
+        // the real ID. For those which do not exist on the course any more
+        // (e.g. simulating backup/restore of single activity between 2 courses)
+        // we expect the IDs to be replaced with marker value: 0 for cmid
+        // and grade, -1 for group/grouping.
+        $expected = str_replace(
+                array('99999999', '99999998', '99999997', '99999996'),
+                array(0, 0, -1, -1),
+                $availability);
+
+        // Check settings in new activity.
+        $actual = $DB->get_field('course_modules', 'availability', array('id' => $newcmid));
+        $this->assertEquals($expected, $actual);
+    }
+
+    /**
+     * Backs a course up and restores it.
+     *
+     * @param stdClass $course Course object to backup
+     * @return int ID of newly restored course
+     */
+    protected function backup_and_restore($course) {
+        global $USER, $CFG;
+
+        // Turn off file logging, otherwise it can't delete the file (Windows).
+        $CFG->backup_file_logger_level = backup::LOG_NONE;
+
+        // Do backup with default settings. MODE_IMPORT means it will just
+        // create the directory and not zip it.
+        $bc = new backup_controller(backup::TYPE_1COURSE, $course->id,
+                backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_IMPORT,
+                $USER->id);
+        $backupid = $bc->get_backupid();
+        $bc->execute_plan();
+        $bc->destroy();
+
+        // Do restore to new course with default settings.
+        $newcourseid = restore_dbops::create_new_course(
+                $course->fullname, $course->shortname . '_2', $course->category);
+        $rc = new restore_controller($backupid, $newcourseid,
+                backup::INTERACTIVE_NO, backup::MODE_GENERAL, $USER->id,
+                backup::TARGET_NEW_COURSE);
+        $this->assertTrue($rc->execute_precheck());
+        $rc->execute_plan();
+        $rc->destroy();
+
+        return $newcourseid;
+    }
+
+    /**
+     * Duplicates a single activity within a course.
+     *
+     * This is based on the code from course/modduplicate.php, but reduced for
+     * simplicity.
+     *
+     * @param stdClass $course Course object
+     * @param int $cmid Activity to duplicate
+     * @return int ID of new activity
+     */
+    protected function duplicate($course, $cmid) {
+        global $USER;
+
+        // Do backup.
+        $bc = new backup_controller(backup::TYPE_1ACTIVITY, $cmid, backup::FORMAT_MOODLE,
+                backup::INTERACTIVE_NO, backup::MODE_IMPORT, $USER->id);
+        $backupid = $bc->get_backupid();
+        $bc->execute_plan();
+        $bc->destroy();
+
+        // Do restore.
+        $rc = new restore_controller($backupid, $course->id,
+                backup::INTERACTIVE_NO, backup::MODE_IMPORT, $USER->id, backup::TARGET_CURRENT_ADDING);
+        $this->assertTrue($rc->execute_precheck());
+        $rc->execute_plan();
+
+        // Find cmid.
+        $tasks = $rc->get_plan()->get_tasks();
+        $cmcontext = context_module::instance($cmid);
+        $newcmid = 0;
+        foreach ($tasks as $task) {
+            if (is_subclass_of($task, 'restore_activity_task')) {
+                if ($task->get_old_contextid() == $cmcontext->id) {
+                    $newcmid = $task->get_moduleid();
+                    break;
+                }
+            }
+        }
+        $rc->destroy();
+        if (!$newcmid) {
+            throw new coding_exception('Unexpected: failure to find restored cmid');
+        }
+        return $newcmid;
+    }
+}