MDL-68241 mod_h5pactivity: add grading attempts options
authorFerran Recio <ferran@moodle.com>
Thu, 9 Apr 2020 16:55:36 +0000 (18:55 +0200)
committerFerran Recio <ferran@moodle.com>
Thu, 14 May 2020 07:13:56 +0000 (09:13 +0200)
20 files changed:
mod/h5pactivity/backup/moodle2/backup_h5pactivity_stepslib.php
mod/h5pactivity/classes/local/attempt.php
mod/h5pactivity/classes/local/grader.php [new file with mode: 0644]
mod/h5pactivity/classes/local/manager.php [new file with mode: 0644]
mod/h5pactivity/classes/xapi/handler.php
mod/h5pactivity/db/install.xml
mod/h5pactivity/db/upgrade.php
mod/h5pactivity/lang/en/h5pactivity.php
mod/h5pactivity/lib.php
mod/h5pactivity/mod_form.php
mod/h5pactivity/tests/behat/define_settings.feature [new file with mode: 0644]
mod/h5pactivity/tests/behat/grading_attempts.feature [new file with mode: 0644]
mod/h5pactivity/tests/generator/lib.php
mod/h5pactivity/tests/generator_test.php
mod/h5pactivity/tests/local/attempt_test.php
mod/h5pactivity/tests/local/grader_test.php [new file with mode: 0644]
mod/h5pactivity/tests/local/manager_test.php [new file with mode: 0644]
mod/h5pactivity/tests/restore_test.php
mod/h5pactivity/version.php
mod/h5pactivity/view.php

index 107a023..76a4522 100644 (file)
@@ -41,7 +41,7 @@ class backup_h5pactivity_activity_structure_step extends backup_activity_structu
         // Replace with the attributes and final elements that the element will handle.
         $attributes = ['id'];
         $finalelements = ['name', 'timecreated', 'timemodified', 'intro',
-                'introformat', 'grade', 'displayoptions'];
+                'introformat', 'grade', 'displayoptions', 'enabletracking', 'grademethod'];
         $root = new backup_nested_element('h5pactivity', $attributes, $finalelements);
 
         $attempts = new backup_nested_element('attempts');
index e4221fb..3b24875 100644 (file)
@@ -36,18 +36,22 @@ defined('MOODLE_INTERNAL') || die();
  * @package    mod_h5pactivity
  * @since      Moodle 3.9
  * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class attempt {
 
     /** @var stdClass the h5pactivity_attempts record. */
     private $record;
 
+    /** @var boolean if the DB statement has been updated. */
+    private $scoreupdated = false;
+
     /**
      * Create a new attempt object.
      *
      * @param stdClass $record the h5pactivity_attempts record
      */
-    protected function __construct(stdClass $record) {
+    public function __construct(stdClass $record) {
         $this->record = $record;
         $this->results = null;
     }
@@ -199,12 +203,15 @@ class attempt {
         }
 
         // If no subcontent provided, results are propagated to the attempt itself.
-        if (empty($subcontent) && $record->rawscore) {
-            $this->record->rawscore = $record->rawscore;
-            $this->record->maxscore = $record->maxscore;
-            $this->record->duration = $record->duration;
-            $this->record->completion = $record->completion ?? null;
-            $this->record->success = $record->success ?? null;
+        if (empty($subcontent)) {
+            $this->set_duration($record->duration);
+            $this->set_completion($record->completion ?? null);
+            $this->set_success($record->success ?? null);
+            // If Maxscore is not empty means that the rawscore is valid (even if it's 0)
+            // and scaled score can be calculated.
+            if ($record->maxscore) {
+                $this->set_score($record->rawscore, $record->maxscore);
+            }
         }
         // Refresh current attempt.
         return $this->save();
@@ -218,9 +225,56 @@ class attempt {
     public function save(): bool {
         global $DB;
         $this->record->timemodified = time();
+        // Calculate scaled score.
+        if ($this->scoreupdated) {
+            if (empty($this->record->maxscore)) {
+                $this->record->scaled = 0;
+            } else {
+                $this->record->scaled = $this->record->rawscore / $this->record->maxscore;
+            }
+        }
         return $DB->update_record('h5pactivity_attempts', $this->record);
     }
 
+    /**
+     * Set the attempt score.
+     *
+     * @param int|null $rawscore the attempt rawscore
+     * @param int|null $maxscore the attempt maxscore
+     */
+    public function set_score(?int $rawscore, ?int $maxscore): void {
+        $this->record->rawscore = $rawscore;
+        $this->record->maxscore = $maxscore;
+        $this->scoreupdated = true;
+    }
+
+    /**
+     * Set the attempt duration.
+     *
+     * @param int|null $duration the attempt duration
+     */
+    public function set_duration(?int $duration): void {
+        $this->record->duration = $duration;
+    }
+
+    /**
+     * Set the attempt completion.
+     *
+     * @param int|null $completion the attempt completion
+     */
+    public function set_completion(?int $completion): void {
+        $this->record->completion = $completion;
+    }
+
+    /**
+     * Set the attempt success.
+     *
+     * @param int|null $success the attempt success
+     */
+    public function set_success(?int $success): void {
+        $this->record->success = $success;
+    }
+
     /**
      * Delete the current attempt results from the DB.
      */
@@ -376,15 +430,15 @@ class attempt {
      * @return int the rawscore value
      */
     public function get_rawscore(): int {
-        return $this->record->maxscore;
+        return $this->record->rawscore;
     }
 
     /**
      * Return the attempt duration.
      *
-     * @return int the duration value
+     * @return int|null the duration value
      */
-    public function get_duration(): int {
+    public function get_duration(): ?int {
         return $this->record->duration;
     }
 
@@ -405,4 +459,16 @@ class attempt {
     public function get_success(): ?int {
         return $this->record->success;
     }
+
+    /**
+     * Return if the attempt has been modified.
+     *
+     * Note: adding a result only add track information unless the statement does
+     * not specify subcontent. In this case this will update also the statement.
+     *
+     * @return bool if the attempt score have been modified
+     */
+    public function get_scoreupdated(): bool {
+        return $this->scoreupdated;
+    }
 }
diff --git a/mod/h5pactivity/classes/local/grader.php b/mod/h5pactivity/classes/local/grader.php
new file mode 100644 (file)
index 0000000..85feba7
--- /dev/null
@@ -0,0 +1,214 @@
+<?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/>.
+
+/**
+ * H5P activity grader class.
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\local;
+
+use context_module;
+use cm_info;
+use moodle_recordset;
+use stdClass;
+
+/**
+ * Class for handling H5P activity grading.
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class grader {
+
+    /** @var stdClass course_module record. */
+    private $instance;
+
+    /** @var string idnumber course_modules idnumber. */
+    private $idnumber;
+
+    /**
+     * Class contructor.
+     *
+     * @param stdClass $instance H5Pactivity instance object
+     * @param string $idnumber course_modules idnumber
+     */
+    public function __construct(stdClass $instance, string $idnumber = '') {
+        $this->instance = $instance;
+        $this->idnumber = $idnumber;
+    }
+
+    /**
+     * Delete grade item for given mod_h5pactivity instance.
+     *
+     * @return int Returns GRADE_UPDATE_OK, GRADE_UPDATE_FAILED, GRADE_UPDATE_MULTIPLE or GRADE_UPDATE_ITEM_LOCKED
+     */
+    public function grade_item_delete(): ?int {
+        global $CFG;
+        require_once($CFG->libdir.'/gradelib.php');
+
+        return grade_update('mod/h5pactivity', $this->instance->course, 'mod', 'h5pactivity',
+                $this->instance->id, 0, null, ['deleted' => 1]);
+    }
+
+    /**
+     * Creates or updates grade item for the given mod_h5pactivity instance.
+     *
+     * @param mixed $grades optional array/object of grade(s); 'reset' means reset grades in gradebook
+     * @return int 0 if ok, error code otherwise
+     */
+    public function grade_item_update($grades = null): int {
+        global $CFG;
+        require_once($CFG->libdir.'/gradelib.php');
+
+        $item = [];
+        $item['itemname'] = clean_param($this->instance->name, PARAM_NOTAGS);
+        $item['gradetype'] = GRADE_TYPE_VALUE;
+        if (!empty($this->idnumber)) {
+            $item['idnumber'] = $this->idnumber;
+        }
+
+        if ($this->instance->grade > 0) {
+            $item['gradetype'] = GRADE_TYPE_VALUE;
+            $item['grademax']  = $this->instance->grade;
+            $item['grademin']  = 0;
+        } else if ($this->instance->grade < 0) {
+            $item['gradetype'] = GRADE_TYPE_SCALE;
+            $item['scaleid']   = -$this->instance->grade;
+        } else {
+            $item['gradetype'] = GRADE_TYPE_NONE;
+        }
+
+        if ($grades === 'reset') {
+            $item['reset'] = true;
+            $grades = null;
+        }
+
+        return grade_update('mod/h5pactivity', $this->instance->course, 'mod',
+                'h5pactivity', $this->instance->id, 0, $grades, $item);
+    }
+
+    /**
+     * Update grades in the gradebook.
+     *
+     * @param int $userid Update grade of specific user only, 0 means all participants.
+     */
+    public function update_grades(int $userid = 0): void {
+        // Scaled and none grading doesn't have grade calculation.
+        if ($this->instance->grade <= 0) {
+            $this->grade_item_update();
+            return;
+        }
+        // Populate array of grade objects indexed by userid.
+        $grades = $this->get_user_grades_for_gradebook($userid);
+
+        if (!empty($grades)) {
+            $this->grade_item_update($grades);
+        } else {
+            $this->grade_item_update();
+        }
+    }
+
+    /**
+     * Get an updated list of user grades and feedback for the gradebook.
+     *
+     * @param int $userid int or 0 for all users
+     * @return array of grade data formated for the gradebook api
+     *         The data required by the gradebook api is userid,
+     *                                                   rawgrade,
+     *                                                   feedback,
+     *                                                   feedbackformat,
+     *                                                   usermodified,
+     *                                                   dategraded,
+     *                                                   datesubmitted
+     */
+    private function get_user_grades_for_gradebook(int $userid = 0): array {
+        $grades = [];
+
+        // In case of using manual grading this update must delete previous automatic gradings.
+        if ($this->instance->grademethod == manager::GRADEMANUAL || !$this->instance->enabletracking) {
+            return $this->get_user_grades_for_deletion($userid);
+        }
+
+        $manager = manager::create_from_instance($this->instance);
+
+        $scores = $manager->get_users_scaled_score($userid);
+        if (!$scores) {
+            return $grades;
+        }
+
+        // Maxgrade depends on the type of grade used:
+        // - grade > 0: regular quantitative grading.
+        // - grade = 0: no grading.
+        // - grade < 0: scale used.
+        $maxgrade = floatval($this->instance->grade);
+
+        // Convert scaled scores into gradebok compatible objects.
+        foreach ($scores as $userid => $score) {
+            $grades[$userid] = [
+                'userid' => $userid,
+                'rawgrade' => $maxgrade * $score->scaled,
+                'dategraded' => $score->timemodified,
+                'datesubmitted' => $score->timemodified,
+            ];
+        }
+
+        return $grades;
+    }
+
+    /**
+     * Get an deletion list of user grades and feedback for the gradebook.
+     *
+     * This method is used to delete all autmatic gradings when grading method is set to manual.
+     *
+     * @param int $userid int or 0 for all users
+     * @return array of grade data formated for the gradebook api
+     *         The data required by the gradebook api is userid,
+     *                                                   rawgrade (null to delete),
+     *                                                   dategraded,
+     *                                                   datesubmitted
+     */
+    private function get_user_grades_for_deletion (int $userid = 0): array {
+        $grades = [];
+
+        if ($userid) {
+            $grades[$userid] = [
+                'userid' => $userid,
+                'rawgrade' => null,
+                'dategraded' => time(),
+                'datesubmitted' => time(),
+            ];
+        } else {
+            $manager = manager::create_from_instance($this->instance);
+            $users = get_enrolled_users($manager->get_context(), 'mod/h5pactivity:submit');
+            foreach ($users as $user) {
+                $grades[$user->id] = [
+                    'userid' => $user->id,
+                    'rawgrade' => null,
+                    'dategraded' => time(),
+                    'datesubmitted' => time(),
+                ];
+            }
+        }
+        return $grades;
+    }
+}
diff --git a/mod/h5pactivity/classes/local/manager.php b/mod/h5pactivity/classes/local/manager.php
new file mode 100644 (file)
index 0000000..466995a
--- /dev/null
@@ -0,0 +1,237 @@
+<?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/>.
+
+/**
+ * H5P activity manager class
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\local;
+
+use context_module;
+use cm_info;
+use moodle_recordset;
+use stdClass;
+
+/**
+ * Class manager for H5P activity
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class manager {
+
+    /** No automathic grading using attempt results. */
+    const GRADEMANUAL = 0;
+
+    /** Use highest attempt results for grading. */
+    const GRADEHIGHESTATTEMPT = 1;
+
+    /** Use average attempt results for grading. */
+    const GRADEAVERAGEATTEMPT = 2;
+
+    /** Use last attempt results for grading. */
+    const GRADELASTATTEMPT = 3;
+
+    /** Use first attempt results for grading. */
+    const GRADEFIRSTATTEMPT = 4;
+
+    /** @var stdClass course_module record. */
+    private $instance;
+
+    /** @var context_module the current context. */
+    private $context;
+
+    /** @var cm_info course_modules record. */
+    private $coursemodule;
+
+    /**
+     * Class contructor.
+     *
+     * @param cm_info $coursemodule course module info object
+     * @param stdClass $instance H5Pactivity instance object.
+     */
+    public function __construct(cm_info $coursemodule, stdClass $instance) {
+        $this->coursemodule = $coursemodule;
+        $this->instance = $instance;
+        $this->context = context_module::instance($coursemodule->id);
+        $this->instance->cmidnumber = $coursemodule->idnumber;
+    }
+
+    /**
+     * Create a manager instance from an instance record.
+     *
+     * @param stdClass $instance a h5pactivity record
+     * @return manager
+     */
+    public static function create_from_instance(stdClass $instance): self {
+        $coursemodule = get_coursemodule_from_instance('h5pactivity', $instance->id);
+        // Ensure that $this->coursemodule is a cm_info object.
+        $coursemodule = cm_info::create($coursemodule);
+        return new self($coursemodule, $instance);
+    }
+
+    /**
+     * Create a manager instance from an course_modules record.
+     *
+     * @param stdClass|cm_info $coursemodule a h5pactivity record
+     * @return manager
+     */
+    public static function create_from_coursemodule($coursemodule): self {
+        global $DB;
+        // Ensure that $this->coursemodule is a cm_info object.
+        $coursemodule = cm_info::create($coursemodule);
+        $instance = $DB->get_record('h5pactivity', ['id' => $coursemodule->instance], '*', MUST_EXIST);
+        return new self($coursemodule, $instance);
+    }
+
+    /**
+     * Return the available grading methods.
+     * @return string[] an array "option value" => "option description"
+     */
+    public static function get_grading_methods(): array {
+        return [
+            self::GRADEHIGHESTATTEMPT => get_string('grade_highest_attempt', 'mod_h5pactivity'),
+            self::GRADEAVERAGEATTEMPT => get_string('grade_average_attempt', 'mod_h5pactivity'),
+            self::GRADELASTATTEMPT => get_string('grade_last_attempt', 'mod_h5pactivity'),
+            self::GRADEFIRSTATTEMPT => get_string('grade_first_attempt', 'mod_h5pactivity'),
+            self::GRADEMANUAL => get_string('grade_manual', 'mod_h5pactivity'),
+        ];
+    }
+
+    /**
+     * Check if tracking is enabled in a particular h5pactivity for a specific user.
+     *
+     * @param stdClass|null $user user record (default $USER)
+     * @return bool if tracking is enabled in this activity
+     */
+    public function is_tracking_enabled(stdClass $user = null): bool {
+        global $USER;
+        if (!$this->instance->enabletracking) {
+            return false;
+        }
+        if (empty($user)) {
+            $user = $USER;
+        }
+        return has_capability('mod/h5pactivity:submit', $this->context, $user, false);
+    }
+
+    /**
+     * Return a relation of userid and the valid attempt's scaled score.
+     *
+     * The returned elements contain a record
+     * of userid, scaled value, attemptid and timemodified. In case the grading method is "GRADEAVERAGEATTEMPT"
+     * the attemptid will be zero. In case that tracking is disabled or grading method is "GRADEMANUAL"
+     * the method will return null.
+     *
+     * @param int $userid a specific userid or 0 for all user attempts.
+     * @return array|null of userid, scaled value and, if exists, the attempt id
+     */
+    public function get_users_scaled_score(int $userid = 0): ?array {
+        global $DB;
+
+        $scaled = [];
+        if (!$this->instance->enabletracking) {
+            return null;
+        }
+
+        if ($this->instance->grademethod == self::GRADEMANUAL) {
+            return null;
+        }
+
+        $sql = '';
+
+        // General filter.
+        $where = 'a.h5pactivityid = :h5pactivityid';
+        $params['h5pactivityid'] = $this->instance->id;
+
+        if ($userid) {
+            $where .= ' AND a.userid = :userid';
+            $params['userid'] = $userid;
+        }
+
+        // Average grading needs aggregation query.
+        if ($this->instance->grademethod == self::GRADEAVERAGEATTEMPT) {
+            $sql = "SELECT a.userid, AVG(a.scaled) AS scaled, 0 AS attemptid, MAX(timemodified) AS timemodified
+                      FROM {h5pactivity_attempts} a
+                     WHERE $where AND a.completion = 1
+                  GROUP BY a.userid";
+        }
+
+        if (empty($sql)) {
+            // Decide which attempt is used for the calculation.
+            $condition = [
+                self::GRADEHIGHESTATTEMPT => "a.scaled < b.scaled",
+                self::GRADELASTATTEMPT => "a.attempt < b.attempt",
+                self::GRADEFIRSTATTEMPT => "a.attempt > b.attempt",
+            ];
+            $join = $condition[$this->instance->grademethod] ?? $condition[self::GRADEHIGHESTATTEMPT];
+
+            $sql = "SELECT a.userid, a.scaled, MAX(a.id) AS attemptid, MAX(a.timemodified) AS timemodified
+                      FROM {h5pactivity_attempts} a
+                 LEFT JOIN {h5pactivity_attempts} b ON a.h5pactivityid = b.h5pactivityid
+                           AND a.userid = b.userid AND b.completion = 1
+                           AND $join
+                     WHERE $where AND b.id IS NULL AND a.completion = 1
+                  GROUP BY a.userid, a.scaled";
+        }
+
+        return $DB->get_records_sql($sql, $params);
+    }
+
+    /**
+     * Return the current context.
+     *
+     * @return context_module
+     */
+    public function get_context(): context_module {
+        return $this->context;
+    }
+
+    /**
+     * Return the current context.
+     *
+     * @return stdClass the instance record
+     */
+    public function get_instance(): stdClass {
+        return $this->instance;
+    }
+
+    /**
+     * Return the current cm_info.
+     *
+     * @return cm_info the course module
+     */
+    public function get_coursemodule(): cm_info {
+        return $this->coursemodule;
+    }
+
+    /**
+     * Return the specific grader object for this activity.
+     *
+     * @return grader
+     */
+    public function get_grader(): grader {
+        $idnumber = $this->coursemodule->idnumber ?? '';
+        return new grader($this->instance, $idnumber);
+    }
+}
index 69667de..4001e90 100644 (file)
@@ -26,6 +26,7 @@
 namespace mod_h5pactivity\xapi;
 
 use mod_h5pactivity\local\attempt;
+use mod_h5pactivity\local\manager;
 use mod_h5pactivity\event\statement_received;
 use core_xapi\local\statement;
 use core_xapi\handler as handler_base;
@@ -34,6 +35,8 @@ use context_module;
 
 defined('MOODLE_INTERNAL') || die();
 
+require_once($CFG->dirroot.'/mod/h5pactivity/lib.php');
+
 /**
  * Class xapi_handler for H5P statements.
  *
@@ -94,15 +97,18 @@ class handler extends handler_base {
         if (!has_capability('mod/h5pactivity:view', $context, $user)) {
             return null;
         }
-        if (!has_capability('mod/h5pactivity:submit', $context, $user, false)) {
-            return null;
-        }
 
         $cm = get_coursemodule_from_id('h5pactivity', $context->instanceid, 0, false);
         if (!$cm) {
             return null;
         }
 
+        $manager = manager::create_from_coursemodule($cm);
+
+        if (!$manager->is_tracking_enabled($user)) {
+            return null;
+        }
+
         // For now, attempts are only processed on a single batch starting with the final "completed"
         // and "answered" statements (this could change in the future). This initial statement have no
         // subcontent defined as they are the main finishing statement. For this reason, this statement
@@ -122,7 +128,11 @@ class handler extends handler_base {
             return null;
         }
 
-        // TODO: update grading if necessary.
+        // Update activity if necessary.
+        if ($attempt->get_scoreupdated()) {
+            $grader = $manager->get_grader();
+            $grader->update_grades($user->id);
+        }
 
         // Convert into a Moodle event.
         $minstatement = $statement->minify();
index 549df46..0484154 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="mod/h5pactivity/db" VERSION="20200414" COMMENT="XMLDB file for Moodle mod_h5pactivity"
+<XMLDB PATH="mod/h5pactivity/db" VERSION="20200422" COMMENT="XMLDB file for Moodle mod_h5pactivity"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
 >
@@ -15,6 +15,8 @@
         <FIELD NAME="introformat" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The format of the intro field."/>
         <FIELD NAME="grade" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="displayoptions" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="H5P Button display options"/>
+        <FIELD NAME="enabletracking" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="1" SEQUENCE="false" COMMENT="Enable xAPI tracking"/>
+        <FIELD NAME="grademethod" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="1" SEQUENCE="false" COMMENT="Which H5P attempt is used for grading"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
@@ -31,6 +33,7 @@
         <FIELD NAME="attempt" TYPE="int" LENGTH="6" NOTNULL="true" DEFAULT="1" SEQUENCE="false" COMMENT="Attempt number"/>
         <FIELD NAME="rawscore" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="maxscore" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="scaled" TYPE="number" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" DECIMALS="5" COMMENT="Number 0..1 that reflects the performance of the learner"/>
         <FIELD NAME="duration" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false" COMMENT="Number of second inverted in that attempt (provided by the statement)"/>
         <FIELD NAME="completion" TYPE="int" LENGTH="1" NOTNULL="false" SEQUENCE="false" COMMENT="Store the xAPI tracking completion result."/>
         <FIELD NAME="success" TYPE="int" LENGTH="1" NOTNULL="false" SEQUENCE="false" COMMENT="Store the xAPI tracking success result."/>
index a5932e3..4724f65 100644 (file)
@@ -190,5 +190,48 @@ function xmldb_h5pactivity_upgrade($oldversion) {
         upgrade_mod_savepoint(true, 2020041400, 'h5pactivity');
     }
 
+    if ($oldversion < 2020041401) {
+
+        // Define field enabletracking to be added to h5pactivity.
+        $table = new xmldb_table('h5pactivity');
+        $field = new xmldb_field('enabletracking', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '1', 'displayoptions');
+
+        // Conditionally launch add field enabletracking.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Define field grademethod to be added to h5pactivity.
+        $field = new xmldb_field('grademethod', XMLDB_TYPE_INTEGER, '4', null, XMLDB_NOTNULL, null, '1', 'enabletracking');
+
+        // Conditionally launch add field grademethod.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Define field scaled to be added to h5pactivity_attempts.
+        $table = new xmldb_table('h5pactivity_attempts');
+        $field = new xmldb_field('scaled', XMLDB_TYPE_NUMBER, '10, 5', null, XMLDB_NOTNULL, null, '0', 'maxscore');
+
+        // Conditionally launch add field scaled.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Calculate all scaled values from current attempts.
+        $rs = $DB->get_recordset('h5pactivity_attempts');
+        foreach ($rs as $record) {
+            if (empty($record->maxscore)) {
+                continue;
+            }
+            $record->scaled = $record->rawscore / $record->maxscore;
+            $DB->update_record('h5pactivity_attempts', $record);
+        }
+        $rs->close();
+
+        // H5pactivity savepoint reached.
+        upgrade_mod_savepoint(true, 2020041401, 'h5pactivity');
+    }
+
     return true;
 }
index c76316d..c855830 100644 (file)
@@ -27,16 +27,32 @@ defined('MOODLE_INTERNAL') || die();
 
 $string['areapackage'] = 'Package file';
 $string['attempt'] = 'Attempt';
+$string['attempts'] = 'Attempts';
 $string['deleteallattempts'] = 'Delete all H5P attempts';
 $string['displayexport'] = 'Allow download';
 $string['displayembed'] = 'Embed button';
 $string['displaycopyright'] = 'Copyright button';
+$string['enabletracking'] = 'Enable attempt tracking';
+$string['grade_grademethod'] = 'Grading method';
+$string['grade_grademethod_help'] = 'When using point grading, the following methods are available for calculating the final grade:
+
+* Highest grade of all attempts
+* Average (mean) grade of all attempts
+* First attempt (all other attempts are ignored)
+* Last attempt (all other attempts are ignored)
+* Don\'t use attempts for grading (disable grading calculation)';
+$string['grade_manual'] = 'Don\'t calculate a grade';
+$string['grade_highest_attempt'] = 'Highest grade';
+$string['grade_average_attempt'] = 'Average grade';
+$string['grade_last_attempt'] = 'Last attempt';
+$string['grade_first_attempt'] = 'First attempt';
 $string['h5pactivity:addinstance'] = 'Add a new H5P';
 $string['h5pactivity:submit'] = 'Submit H5P attempts';
 $string['h5pactivity:view'] = 'View H5P';
 $string['h5pactivityfieldset'] = 'H5P settings';
 $string['h5pactivityname'] = 'H5P';
 $string['h5pactivitysettings'] = 'Settings';
+$string['h5pattempts'] = 'Attempt options';
 $string['h5pdisplay'] = 'H5P options';
 $string['modulename'] = 'H5P';
 $string['modulename_help'] = 'H5P is an abbreviation for HTML5 Package - interactive content such as presentations, videos and other multimedia, questions, quizzes, games and more. The H5P activity enables H5P to be uploaded and added to a course.
@@ -59,4 +75,5 @@ $string['privacy:metadata:userid'] = 'The ID of the user who accessed the H5P ac
 $string['privacy:metadata:xapi_track'] = 'Attempt tracking information';
 $string['privacy:metadata:xapi_track_results'] = 'Attempt results tracking information';
 $string['statement_received'] = 'xAPI statement received';
+$string['tracking_messages'] = 'Some H5P provide attempt tracking data for advanced reporting such as number of attempts, responses and grades. Note: Some H5P don\'t provide attempt tracking data. In such cases, the following settings will have no effect.';
 $string['view'] = 'View';
index 5f2fff8..48f97a4 100644 (file)
@@ -22,6 +22,9 @@
  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+use mod_h5pactivity\local\manager;
+use mod_h5pactivity\local\grader;
+
 defined('MOODLE_INTERNAL') || die();
 
 /**
@@ -113,10 +116,15 @@ function h5pactivity_update_instance(stdClass $data, mod_h5pactivity_mod_form $m
 
     h5pactivity_set_mainfile($data);
 
-    // Extra fields required in grade related functions.
+    // Update gradings if grading method or tracking are modified.
     $data->cmid = $data->coursemodule;
-    h5pactivity_grade_item_update($data);
-    h5pactivity_update_grades($data);
+    $moduleinstance = $DB->get_record('h5pactivity', ['id' => $data->id]);
+    if (($moduleinstance->grademethod != $data->grademethod)
+            || $data->enabletracking != $moduleinstance->enabletracking) {
+        h5pactivity_update_grades($data);
+    } else {
+        h5pactivity_grade_item_update($data);
+    }
 
     return $DB->update_record('h5pactivity', $data);
 }
@@ -169,33 +177,10 @@ function h5pactivity_scale_used_anywhere(int $scaleid): bool {
  * @param mixed $grades optional array/object of grade(s); 'reset' means reset grades in gradebook
  * @return int int 0 if ok, error code otherwise
  */
-function h5pactivity_grade_item_update(stdClass $moduleinstance, $grades=null): int {
-    global $CFG;
-    require_once($CFG->libdir.'/gradelib.php');
-
-    $item = [];
-    $item['itemname'] = clean_param($moduleinstance->name, PARAM_NOTAGS);
-    $item['gradetype'] = GRADE_TYPE_VALUE;
-    if (isset($moduleinstance->cmidnumber)) {
-        $item['idnumber'] = $moduleinstance->cmidnumber;
-    }
-
-    if ($moduleinstance->grade > 0) {
-        $item['gradetype'] = GRADE_TYPE_VALUE;
-        $item['grademax']  = $moduleinstance->grade;
-        $item['grademin']  = 0;
-    } else if ($moduleinstance->grade < 0) {
-        $item['gradetype'] = GRADE_TYPE_SCALE;
-        $item['scaleid']   = -$moduleinstance->grade;
-    } else {
-        $item['gradetype'] = GRADE_TYPE_NONE;
-    }
-    if ($grades === 'reset') {
-        $params['reset'] = true;
-        $grades = null;
-    }
-    return grade_update('mod/h5pactivity', $moduleinstance->course, 'mod',
-            'h5pactivity', $moduleinstance->id, 0, null, $item);
+function h5pactivity_grade_item_update(stdClass $moduleinstance, $grades = null): int {
+    $idnumber = $moduleinstance->idnumber ?? '';
+    $grader = new grader($moduleinstance, $idnumber);
+    return $grader->grade_item_update($grades);
 }
 
 /**
@@ -205,11 +190,9 @@ function h5pactivity_grade_item_update(stdClass $moduleinstance, $grades=null):
  * @return int Returns GRADE_UPDATE_OK, GRADE_UPDATE_FAILED, GRADE_UPDATE_MULTIPLE or GRADE_UPDATE_ITEM_LOCKED
  */
 function h5pactivity_grade_item_delete(stdClass $moduleinstance): ?int {
-    global $CFG;
-    require_once($CFG->libdir.'/gradelib.php');
-
-    return grade_update('mod/h5pactivity', $moduleinstance->course, 'mod', 'h5pactivity',
-            $moduleinstance->id, 0, null, ['deleted' => 1]);
+    $idnumber = $moduleinstance->idnumber ?? '';
+    $grader = new grader($moduleinstance, $idnumber);
+    return $grader->grade_item_delete();
 }
 
 /**
@@ -221,13 +204,29 @@ function h5pactivity_grade_item_delete(stdClass $moduleinstance): ?int {
  * @param int $userid Update grade of specific user only, 0 means all participants.
  */
 function h5pactivity_update_grades(stdClass $moduleinstance, int $userid = 0): void {
-    global $CFG;
-    require_once($CFG->libdir.'/gradelib.php');
+    $idnumber = $moduleinstance->idnumber ?? '';
+    $grader = new grader($moduleinstance, $idnumber);
+    $grader->update_grades($userid);
+}
 
-    // Populate array of grade objects indexed by userid.
-    $grades = [];
-    grade_update('mod/h5pactivity', $moduleinstance->course, 'mod',
-            'h5pactivity', $moduleinstance->id, 0, $grades);
+/**
+ * Rescale all grades for this activity and push the new grades to the gradebook.
+ *
+ * @param stdClass $course Course db record
+ * @param stdClass $cm Course module db record
+ * @param float $oldmin
+ * @param float $oldmax
+ * @param float $newmin
+ * @param float $newmax
+ * @return bool true if reescale is successful
+ */
+function h5pactivity_rescale_activity_grades(stdClass $course, stdClass $cm, float $oldmin,
+        float $oldmax, float $newmin, float $newmax): bool {
+
+    $manager = manager::create_from_coursemodule($cm);
+    $grader = $manager->get_grader();
+    $grader->update_grades();
+    return true;
 }
 
 /**
@@ -306,7 +305,7 @@ function h5pactivity_reset_gradebook(int $courseid, string $type=''): void {
 
     if ($activities = $DB->get_records_sql($sql, [$courseid])) {
         foreach ($activities as $activity) {
-            h5pactivity_grade_item_update($activity, true);
+            h5pactivity_grade_item_update($activity, 'reset');
         }
     }
 }
index 3965174..94b7605 100644 (file)
@@ -22,6 +22,8 @@
  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+use mod_h5pactivity\local\manager;
+
 defined('MOODLE_INTERNAL') || die();
 
 require_once($CFG->dirroot.'/course/moodleform_mod.php');
@@ -85,6 +87,22 @@ class mod_h5pactivity_mod_form extends moodleform_mod {
         // Add standard grading elements.
         $this->standard_grading_coursemodule_elements();
 
+        // Attempt options.
+        $mform->addElement('header', 'h5pattempts', get_string('h5pattempts', 'mod_h5pactivity'));
+
+        $mform->addElement('static', 'trackingwarning', '', get_string('tracking_messages', 'mod_h5pactivity'));
+
+        $options = [1 => get_string('yes'), 0 => get_string('no')];
+        $mform->addElement('select', 'enabletracking', get_string('enabletracking', 'mod_h5pactivity'), $options);
+        $mform->setDefault('enabletracking', 1);
+
+        $options = manager::get_grading_methods();
+        $mform->addElement('select', 'grademethod', get_string('grade_grademethod', 'mod_h5pactivity'), $options);
+        $mform->setType('grademethod', PARAM_INT);
+        $mform->hideIf('grademethod', 'enabletracking', 'neq', 1);
+        $mform->disabledIf('grademethod', 'grade[modgrade_type]', 'neq', 'point');
+        $mform->addHelpButton('grademethod', 'grade_grademethod', 'mod_h5pactivity');
+
         // Add standard elements.
         $this->standard_coursemodule_elements();
 
@@ -178,5 +196,9 @@ class mod_h5pactivity_mod_form extends moodleform_mod {
             $config = \core_h5p\helper::decode_display_options($core);
         }
         $data->displayoptions = \core_h5p\helper::get_display_options($core, $config);
+
+        if (!isset($data->enabletracking)) {
+            $data->enabletracking = 0;
+        }
     }
 }
diff --git a/mod/h5pactivity/tests/behat/define_settings.feature b/mod/h5pactivity/tests/behat/define_settings.feature
new file mode 100644 (file)
index 0000000..4346224
--- /dev/null
@@ -0,0 +1,50 @@
+@mod @mod_h5pactivity @core_h5p @_file_upload @_switch_iframe @javascript
+Feature: Set up attempt grading options into H5P activity
+  In order to use automatic grading in H5P activity
+  As a teacher
+  I need to be able to configure the attempt settings
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.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 |
+    And the following "permission overrides" exist:
+      | capability                 | permission | role           | contextlevel | reference |
+      | moodle/h5p:updatelibraries | Allow      | editingteacher | System       |           |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add a "H5P" to section "1"
+
+  Scenario: Default values should have tracking and grading
+    When the field "Type" matches value "Point"
+    Then the "Grading method" "select" should be enabled
+
+  Scenario: Scale grading should not have a grading method.
+    When I set the following fields to these values:
+          | Name        | Awesome H5P package |
+          | Type        | Scale               |
+    Then the "Grading method" "select" should be disabled
+
+  Scenario: None grading should not have a grading method.
+    When I set the following fields to these values:
+          | Name        | Awesome H5P package |
+          | Type        | None                |
+    Then the "Grading method" "select" should be disabled
+
+  Scenario: Point grading should have a grading method.
+    When I set the following fields to these values:
+          | Name        | Awesome H5P package |
+          | Type        | Point               |
+    Then the "Grading method" "select" should be enabled
+
+  Scenario: Disable tracking should make grading method disappear.
+    When I set the following fields to these values:
+          | Name                    | Awesome H5P package |
+          | Enable attempt tracking | No                   |
+    Then "Grading method" "field" should not be visible
diff --git a/mod/h5pactivity/tests/behat/grading_attempts.feature b/mod/h5pactivity/tests/behat/grading_attempts.feature
new file mode 100644 (file)
index 0000000..a381891
--- /dev/null
@@ -0,0 +1,174 @@
+@mod @mod_h5pactivity @core_h5p @_file_upload @_switch_iframe
+Feature: Change grading options in an H5P activity
+  In order to let students do a H5P attempt
+  As a teacher
+  I need to define what students attempts are used for grading
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student1@example.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 "permission overrides" exist:
+      | capability                 | permission | role           | contextlevel | reference |
+      | moodle/h5p:updatelibraries | Allow      | editingteacher | System       |           |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add a "H5P" to section "1"
+    And I set the following fields to these values:
+      | Name        | Awesome H5P package |
+      | Description | Description         |
+    And I upload "h5p/tests/fixtures/multiple-choice-2-6.h5p" file to "Package file" filemanager
+    And I click on "Save and display" "button"
+    And I log out
+    And I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I follow "Awesome H5P package"
+    And I switch to "h5p-player" class iframe
+    And I switch to "h5p-iframe" class iframe
+    And I click on "Wrong one" "text" in the ".h5p-question-content" "css_element"
+    And I click on "Check" "button" in the ".h5p-question-buttons" "css_element"
+    And I click on "Retry" "button" in the ".h5p-question-buttons" "css_element"
+    And I click on "Correct one" "text" in the ".h5p-question-content" "css_element"
+    And I click on "Check" "button" in the ".h5p-question-buttons" "css_element"
+    And I switch to the main frame
+    # H5P does not allow to Retry if the user checks the correct answer, we need to refresh the page.
+    And I reload the page
+    And I switch to "h5p-player" class iframe
+    And I switch to "h5p-iframe" class iframe
+    And I click on "Wrong one" "text" in the ".h5p-question-content" "css_element"
+    And I click on "Check" "button" in the ".h5p-question-buttons" "css_element"
+    And I switch to the main frame
+    And I log out
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Awesome H5P package"
+
+  @javascript
+  Scenario: Default grading is max attempt grade
+    When I navigate to "View > User report" in the course gradebook
+    And I set the field "Select all or one user" to "Student 1"
+    Then the following should exist in the "user-grade" table:
+      | Grade item          | Grade  | Percentage |
+      | Awesome H5P package | 100.00 | 100.00 %   |
+
+  @javascript
+  Scenario: Change setting to first attempt
+    When I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Grading method | First attempt |
+    And I click on "Save and return to course" "button"
+    And I navigate to "View > User report" in the course gradebook
+    And I set the field "Select all or one user" to "Student 1"
+    Then the following should exist in the "user-grade" table:
+      | Grade item          | Grade | Percentage |
+      | Awesome H5P package | 0.00  | 0.00 %     |
+
+  @javascript
+  Scenario: Change setting to first attempt
+    When I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Grading method | Last attempt |
+    And I click on "Save and return to course" "button"
+    And I navigate to "View > User report" in the course gradebook
+    And I set the field "Select all or one user" to "Student 1"
+    Then the following should exist in the "user-grade" table:
+      | Grade item          | Grade | Percentage |
+      | Awesome H5P package | 0.00  | 0.00 %     |
+
+  @javascript
+  Scenario: Change setting to average attempt
+    When I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Grading method | Average grade |
+    And I click on "Save and return to course" "button"
+    And I navigate to "View > User report" in the course gradebook
+    And I set the field "Select all or one user" to "Student 1"
+    Then the following should exist in the "user-grade" table:
+      | Grade item          | Grade | Percentage  |
+      | Awesome H5P package | 33.33 | 33.33 %     |
+
+  @javascript
+  Scenario: Change setting to manual grading
+    When I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Grading method | Don't calculate a grade |
+    And I click on "Save and return to course" "button"
+    And I navigate to "View > User report" in the course gradebook
+    And I set the field "Select all or one user" to "Student 1"
+    Then the following should exist in the "user-grade" table:
+      | Grade item          | Grade | Percentage |
+      | Awesome H5P package | -     | -          |
+
+  @javascript
+  Scenario: Disable tracking
+    When I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Enable attempt tracking | No |
+    And I click on "Save and return to course" "button"
+    And I navigate to "View > User report" in the course gradebook
+    And I set the field "Select all or one user" to "Student 1"
+    Then the following should exist in the "user-grade" table:
+      | Grade item          | Grade | Percentage |
+      | Awesome H5P package | -     | -          |
+
+  @javascript
+  Scenario: Reescale existing grades changing the maximum grade
+    # First we set to average and recalculate grades.
+    When I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Grading method | Average grade |
+    And I click on "Save and return to course" "button"
+    And I navigate to "View > User report" in the course gradebook
+    And I set the field "Select all or one user" to "Student 1"
+    Then the following should exist in the "user-grade" table:
+      | Grade item          | Grade | Range | Percentage  |
+      | Awesome H5P package | 33.33 | 0–100 | 33.33 %     |
+
+    # Now we modify the maximum grade with rescaling.
+    When I am on "Course 1" course homepage
+    And I follow "Awesome H5P package"
+    And I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Rescale existing grades | Yes |
+      | Maximum grade           | 50  |
+    And I click on "Save and return to course" "button"
+    And I navigate to "View > User report" in the course gradebook
+    And I set the field "Select all or one user" to "Student 1"
+    Then the following should exist in the "user-grade" table:
+      | Grade item          | Grade | Range | Percentage  |
+      | Awesome H5P package | 16.67 | 0–50  | 33.33 %     |
+
+  @javascript
+  Scenario: Change maximum grade without rescaling grade
+    # First we set to average and recalculate grades.
+    When I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Grading method | Average grade |
+    And I click on "Save and return to course" "button"
+    And I navigate to "View > User report" in the course gradebook
+    And I set the field "Select all or one user" to "Student 1"
+    Then the following should exist in the "user-grade" table:
+      | Grade item          | Grade | Range | Percentage  |
+      | Awesome H5P package | 33.33 | 0–100 | 33.33 %     |
+
+    # Now we modify the maximum grade with rescaling.
+    When I am on "Course 1" course homepage
+    And I follow "Awesome H5P package"
+    And I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Rescale existing grades | No |
+      | Maximum grade           | 50 |
+    And I click on "Save and return to course" "button"
+    And I navigate to "View > User report" in the course gradebook
+    And I set the field "Select all or one user" to "Student 1"
+    Then the following should exist in the "user-grade" table:
+      | Grade item          | Grade | Range | Percentage  |
+      | Awesome H5P package | 33.33 | 0–50  | 66.67 %     |
index cbcf84d..b12ffad 100644 (file)
@@ -22,6 +22,8 @@
  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+use mod_h5pactivity\local\manager;
+
 defined('MOODLE_INTERNAL') || die();
 
 
@@ -63,6 +65,12 @@ class mod_h5pactivity_generator extends testing_module_generator {
             $config = \core_h5p\helper::decode_display_options($core);
             $record->displayoptions = \core_h5p\helper::get_display_options($core, $config);
         }
+        if (!isset($record->enabletracking)) {
+            $record->enabletracking = 1;
+        }
+        if (!isset($record->grademethod)) {
+            $record->grademethod = manager::GRADEHIGHESTATTEMPT;
+        }
 
         // The 'packagefile' value corresponds to the draft file area ID. If not specified, create from packagefilepath.
         if (empty($record->packagefile)) {
index 1f2cad8..0cd72c3 100644 (file)
@@ -23,6 +23,8 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+use mod_h5pactivity\local\manager;
+
 defined('MOODLE_INTERNAL') || die();
 
 /**
@@ -54,10 +56,15 @@ class mod_h5pactivity_generator_testcase extends advanced_testcase {
         $this->assertTrue(array_key_exists($activity->id, $records));
 
         // Create a second one with different name and dusplay options.
-        $params = ['course' => $course->id, 'name' => 'Another h5pactivity', 'displayoptions' => 6];
+        $params = [
+            'course' => $course->id, 'name' => 'Another h5pactivity', 'displayoptions' => 6,
+            'enabletracking' => 0, 'grademethod' => manager::GRADELASTATTEMPT,
+        ];
         $activity = $this->getDataGenerator()->create_module('h5pactivity', $params);
         $records = $DB->get_records('h5pactivity', ['course' => $course->id], 'id');
         $this->assertEquals(6, $activity->displayoptions);
+        $this->assertEquals(0, $activity->enabletracking);
+        $this->assertEquals(manager::GRADELASTATTEMPT, $activity->grademethod);
         $this->assertEquals(2, count($records));
         $this->assertEquals('Another h5pactivity', $records[$activity->id]->name);
 
index 1af4c92..494d20e 100644 (file)
@@ -15,7 +15,7 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * mod_h5pactivity generator tests
+ * mod_h5pactivity attempt tests
  *
  * @package    mod_h5pactivity
  * @category   test
@@ -138,6 +138,7 @@ class attempt_testcase extends \advanced_testcase {
         $this->assertEquals(0, $attempt->get_duration());
         $this->assertNull($attempt->get_completion());
         $this->assertNull($attempt->get_success());
+        $this->assertFalse($attempt->get_scoreupdated());
 
         $statement = $this->generate_statement($hasdefinition, $hasresult);
         $result = $attempt->save_statement($statement, $subcontent);
@@ -148,6 +149,11 @@ class attempt_testcase extends \advanced_testcase {
         $this->assertEquals($results[4], $attempt->get_duration());
         $this->assertEquals($results[5], $attempt->get_completion());
         $this->assertEquals($results[6], $attempt->get_success());
+        if ($results[5]) {
+            $this->assertTrue($attempt->get_scoreupdated());
+        } else {
+            $this->assertFalse($attempt->get_scoreupdated());
+        }
     }
 
     /**
@@ -298,6 +304,110 @@ class attempt_testcase extends \advanced_testcase {
         ];
     }
 
+    /**
+     * Test set_score method.
+     *
+     */
+    public function test_set_score(): void {
+        global $DB;
+
+        list($cm, $student, $course) = $this->generate_testing_scenario();
+
+        // Generate one attempt.
+        $attempt = $this->generate_full_attempt($student, $cm);
+
+        $dbattempt = $DB->get_record('h5pactivity_attempts', ['id' => $attempt->get_id()]);
+        $this->assertEquals($dbattempt->rawscore, $attempt->get_rawscore());
+        $this->assertEquals(2, $dbattempt->rawscore);
+        $this->assertEquals($dbattempt->maxscore, $attempt->get_maxscore());
+        $this->assertEquals(2, $dbattempt->maxscore);
+        $this->assertEquals(1, $dbattempt->scaled);
+
+        // Set attempt score.
+        $attempt->set_score(5, 10);
+
+        $this->assertEquals(5, $attempt->get_rawscore());
+        $this->assertEquals(10, $attempt->get_maxscore());
+        $this->assertTrue($attempt->get_scoreupdated());
+
+        // Save new score into DB.
+        $attempt->save();
+
+        $dbattempt = $DB->get_record('h5pactivity_attempts', ['id' => $attempt->get_id()]);
+        $this->assertEquals($dbattempt->rawscore, $attempt->get_rawscore());
+        $this->assertEquals(5, $dbattempt->rawscore);
+        $this->assertEquals($dbattempt->maxscore, $attempt->get_maxscore());
+        $this->assertEquals(10, $dbattempt->maxscore);
+        $this->assertEquals(0.5, $dbattempt->scaled);
+    }
+
+    /**
+     * Test set_duration method.
+     *
+     * @dataProvider basic_setters_data
+     * @param string $attribute the stribute to test
+     * @param int $oldvalue attribute old value
+     * @param int $newvalue attribute new expected value
+     */
+    public function test_basic_setters(string $attribute, int $oldvalue, int $newvalue): void {
+        global $DB;
+
+        list($cm, $student, $course) = $this->generate_testing_scenario();
+
+        // Generate one attempt.
+        $attempt = $this->generate_full_attempt($student, $cm);
+
+        $setmethod = 'set_'.$attribute;
+        $getmethod = 'get_'.$attribute;
+
+        $dbattempt = $DB->get_record('h5pactivity_attempts', ['id' => $attempt->get_id()]);
+        $this->assertEquals($dbattempt->$attribute, $attempt->$getmethod());
+        $this->assertEquals($oldvalue, $dbattempt->$attribute);
+
+        // Set attempt attribute.
+        $attempt->$setmethod($newvalue);
+
+        $this->assertEquals($newvalue, $attempt->$getmethod());
+
+        // Save new score into DB.
+        $attempt->save();
+
+        $dbattempt = $DB->get_record('h5pactivity_attempts', ['id' => $attempt->get_id()]);
+        $this->assertEquals($dbattempt->$attribute, $attempt->$getmethod());
+        $this->assertEquals($newvalue, $dbattempt->$attribute);
+
+        // Set null $attribute.
+        $attempt->$setmethod(null);
+
+        $this->assertNull($attempt->$getmethod());
+
+        // Save new score into DB.
+        $attempt->save();
+
+        $dbattempt = $DB->get_record('h5pactivity_attempts', ['id' => $attempt->get_id()]);
+        $this->assertEquals($dbattempt->$attribute, $attempt->$getmethod());
+        $this->assertNull($dbattempt->$attribute);
+    }
+
+    /**
+     * Data provider for testing basic setters.
+     *
+     * @return array
+     */
+    public function basic_setters_data(): array {
+        return [
+            'Set attempt duration' => [
+                'duration', 25, 35
+            ],
+            'Set attempt completion' => [
+                'completion', 1, 0
+            ],
+            'Set attempt success' => [
+                'success', 1, 0
+            ],
+        ];
+    }
+
     /**
      * Generate a fake attempt with two results.
      *
diff --git a/mod/h5pactivity/tests/local/grader_test.php b/mod/h5pactivity/tests/local/grader_test.php
new file mode 100644 (file)
index 0000000..ffa9763
--- /dev/null
@@ -0,0 +1,360 @@
+<?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/>.
+
+/**
+ * mod_h5pactivity grader tests
+ *
+ * @package    mod_h5pactivity
+ * @category   test
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\local;
+
+use grade_item;
+use stdClass;
+
+/**
+ * Grader tests class for mod_h5pactivity.
+ *
+ * @package    mod_h5pactivity
+ * @category   test
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class grader_testcase extends \advanced_testcase {
+
+    /**
+     * Setup to ensure that fixtures are loaded.
+     */
+    public static function setupBeforeClass(): void {
+        global $CFG;
+        require_once($CFG->libdir.'/gradelib.php');
+    }
+
+    /**
+     * Test for grade item delete.
+     */
+    public function test_grade_item_delete() {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        $grader = new grader($activity);
+
+        // Force a user grade.
+        $this->generate_fake_attempt($activity, $user, 5, 10);
+        $grader->update_grades($user->id);
+
+        $gradeinfo = grade_get_grades($course->id, 'mod', 'h5pactivity', $activity->id, $user->id);
+        $this->assertNotEquals(0, count($gradeinfo->items));
+        $this->assertArrayHasKey($user->id, $gradeinfo->items[0]->grades);
+
+        $grader->grade_item_delete();
+
+        $gradeinfo = grade_get_grades($course->id, 'mod', 'h5pactivity', $activity->id, $user->id);
+        $this->assertEquals(0, count($gradeinfo->items));
+    }
+
+    /**
+     * Test for grade item update.
+     *
+     * @dataProvider grade_item_update_data
+     * @param int $newgrade new activity grade
+     * @param bool $reset if has to reset grades
+     * @param string $idnumber the new idnumber
+     */
+    public function test_grade_item_update(int $newgrade, bool $reset, string $idnumber) {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        // Force a user initial grade.
+        $grader = new grader($activity);
+        $this->generate_fake_attempt($activity, $user, 5, 10);
+        $grader->update_grades($user->id);
+
+        $gradeinfo = grade_get_grades($course->id, 'mod', 'h5pactivity', $activity->id, $user->id);
+        $this->assertNotEquals(0, count($gradeinfo->items));
+        $item = array_shift($gradeinfo->items);
+        $this->assertArrayHasKey($user->id, $item->grades);
+        $this->assertEquals(50, round($item->grades[$user->id]->grade));
+
+        // Module grade value determine the way gradebook acts. That means that the expected
+        // result depends on this value.
+        // - Grade > 0: regular max grade value.
+        // - Grade = 0: no grading is used (but grademax remains the same).
+        // - Grade < 0: a scaleid is used (value = -scaleid).
+        if ($newgrade > 0) {
+            $grademax = $newgrade;
+            $scaleid = null;
+            $usergrade = ($newgrade > 50) ? 50 : $newgrade;
+        } else if ($newgrade == 0) {
+            $grademax = 100;
+            $scaleid = null;
+            $usergrade = null; // No user grades expected.
+        } else if ($newgrade < 0) {
+            $scale = $this->getDataGenerator()->create_scale(array("scale" => "value1, value2, value3"));
+            $newgrade = -1 * $scale->id;
+            $grademax = 3;
+            $scaleid = $scale->id;
+            $usergrade = 3; // 50 value will ve converted to "value 3" on scale.
+        }
+
+        // Update grade item.
+        $activity->grade = $newgrade;
+
+        // In case a reset is need, usergrade will be empty.
+        if ($reset) {
+            $param = 'reset';
+            $usergrade = null;
+        } else {
+            // Individual user gradings will be tested as a subcall of update_grades.
+            $param = null;
+        }
+
+        $grader = new grader($activity, $idnumber);
+        $grader->grade_item_update($param);
+
+        // Check new grade item and grades.
+        $gradeinfo = grade_get_grades($course->id, 'mod', 'h5pactivity', $activity->id, $user->id);
+        $item = array_shift($gradeinfo->items);
+        $this->assertEquals($scaleid, $item->scaleid);
+        $this->assertEquals($grademax, $item->grademax);
+        $this->assertArrayHasKey($user->id, $item->grades);
+        if ($usergrade) {
+            $this->assertEquals($usergrade, round($item->grades[$user->id]->grade));
+        } else {
+            $this->assertEmpty($item->grades[$user->id]->grade);
+        }
+        if (!empty($idnumber)) {
+            $gradeitem = grade_item::fetch(['idnumber' => $idnumber, 'courseid' => $course->id]);
+            $this->assertInstanceOf('grade_item', $gradeitem);
+        }
+    }
+
+    /**
+     * Data provider for test_grade_item_update.
+     *
+     * @return array
+     */
+    public function grade_item_update_data(): array {
+        return [
+            'Change idnumber' => [
+                100, false, 'newidnumber'
+            ],
+            'Increase max grade to 110' => [
+                110, false, ''
+            ],
+            'Decrease max grade to 80' => [
+                40, false, ''
+            ],
+            'Decrease max grade to 40 (less than actual grades)' => [
+                40, false, ''
+            ],
+            'Reset grades' => [
+                100, true, ''
+            ],
+            'Disable grades' => [
+                0, false, ''
+            ],
+            'Use scales' => [
+                -1, false, ''
+            ],
+            'Use scales with reset' => [
+                -1, true, ''
+            ],
+        ];
+    }
+
+    /**
+     * Test for grade update.
+     *
+     * @dataProvider update_grades_data
+     * @param int $newgrade the new activity grade
+     * @param bool $all if has to be applied to all students or just to one
+     * @param int $completion 1 all student have the activity completed, 0 one have incompleted
+     * @param array $results expected results (user1 grade, user2 grade)
+     */
+    public function test_update_grades(int $newgrade, bool $all, int $completion, array $results) {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+        $user1 = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $user2 = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        // Force a user initial grade.
+        $grader = new grader($activity);
+        $this->generate_fake_attempt($activity, $user1, 5, 10);
+        $this->generate_fake_attempt($activity, $user2, 3, 12, $completion);
+        $grader->update_grades();
+
+        $gradeinfo = grade_get_grades($course->id, 'mod', 'h5pactivity', $activity->id, [$user1->id, $user2->id]);
+        $this->assertNotEquals(0, count($gradeinfo->items));
+        $item = array_shift($gradeinfo->items);
+        $this->assertArrayHasKey($user1->id, $item->grades);
+        $this->assertArrayHasKey($user2->id, $item->grades);
+        $this->assertEquals(50, $item->grades[$user1->id]->grade);
+        // Uncompleted attempts does not generate grades.
+        if ($completion) {
+            $this->assertEquals(25, $item->grades[$user2->id]->grade);
+        } else {
+            $this->assertNull($item->grades[$user2->id]->grade);
+
+        }
+
+        // Module grade value determine the way gradebook acts. That means that the expected
+        // result depends on this value.
+        // - Grade > 0: regular max grade value.
+        // - Grade <= 0: no grade calculation is used (scale and no grading).
+        if ($newgrade < 0) {
+            $scale = $this->getDataGenerator()->create_scale(array("scale" => "value1, value2, value3"));
+            $activity->grade = -1 * $scale->id;
+        } else {
+            $activity->grade = $newgrade;
+        }
+
+        $userid = ($all) ? 0 : $user1->id;
+
+        $grader = new grader($activity);
+        $grader->update_grades($userid);
+
+        // Check new grade item and grades.
+        $gradeinfo = grade_get_grades($course->id, 'mod', 'h5pactivity', $activity->id, [$user1->id, $user2->id]);
+        $item = array_shift($gradeinfo->items);
+        $this->assertArrayHasKey($user1->id, $item->grades);
+        $this->assertArrayHasKey($user2->id, $item->grades);
+        $this->assertEquals($results[0], $item->grades[$user1->id]->grade);
+        $this->assertEquals($results[1], $item->grades[$user2->id]->grade);
+    }
+
+    /**
+     * Data provider for test_grade_item_update.
+     *
+     * @return array
+     */
+    public function update_grades_data(): array {
+        return [
+            // Quantitative grade, all attempts completed.
+            'Same grademax, all users, all completed' => [
+                100, true, 1, [50, 25]
+            ],
+            'Same grademax, one user, all completed' => [
+                100, false, 1, [50, 25]
+            ],
+            'Increade max, all users, all completed' => [
+                200, true, 1, [100, 50]
+            ],
+            'Increade max, one user, all completed' => [
+                200, false, 1, [100, 25]
+            ],
+            'Decrease max, all users, all completed' => [
+                50, true, 1, [25, 12.5]
+            ],
+            'Decrease max, one user, all completed' => [
+                50, false, 1, [25, 25]
+            ],
+            // Quantitative grade, some attempts not completed.
+            'Same grademax, all users, not completed' => [
+                100, true, 0, [50, null]
+            ],
+            'Same grademax, one user, not completed' => [
+                100, false, 0, [50, null]
+            ],
+            'Increade max, all users, not completed' => [
+                200, true, 0, [100, null]
+            ],
+            'Increade max, one user, not completed' => [
+                200, false, 0, [100, null]
+            ],
+            'Decrease max, all users, not completed' => [
+                50, true, 0, [25, null]
+            ],
+            'Decrease max, one user, not completed' => [
+                50, false, 0, [25, null]
+            ],
+            // No grade (no grade will be used).
+            'No grade, all users, all completed' => [
+                0, true, 1, [null, null]
+            ],
+            'No grade, one user, all completed' => [
+                0, false, 1, [null, null]
+            ],
+            'No grade, all users, not completed' => [
+                0, true, 0, [null, null]
+            ],
+            'No grade, one user, not completed' => [
+                0, false, 0, [null, null]
+            ],
+            // Scale (grate item will updated but without regrading).
+            'Scale, all users, all completed' => [
+                -1, true, 1, [3, 3]
+            ],
+            'Scale, one user, all completed' => [
+                -1, false, 1, [3, 3]
+            ],
+            'Scale, all users, not completed' => [
+                -1, true, 0, [3, null]
+            ],
+            'Scale, one user, not completed' => [
+                -1, false, 0, [3, null]
+            ],
+        ];
+    }
+
+    /**
+     * Create a fake attempt for a specific user.
+     *
+     * @param stdClass $activity activity instance record.
+     * @param stdClass $user user record
+     * @param int $rawscore score obtained
+     * @param int $maxscore attempt max score
+     * @param int $completion 1 for activity completed, 0 for not completed yet
+     * @return stdClass the attempt record
+     */
+    private function generate_fake_attempt(stdClass $activity, stdClass $user,
+            int $rawscore, int $maxscore, int $completion = 1): stdClass {
+        global $DB;
+
+        $attempt = (object)[
+            'h5pactivityid' => $activity->id,
+            'userid' => $user->id,
+            'timecreated' => 10,
+            'timemodified' => 20,
+            'attempt' => 1,
+            'rawscore' => $rawscore,
+            'maxscore' => $maxscore,
+            'duration' => 2,
+            'completion' => $completion,
+            'success' => 0,
+        ];
+        $attempt->scaled = $attempt->rawscore / $attempt->maxscore;
+        $attempt->id = $DB->insert_record('h5pactivity_attempts', $attempt);
+        return $attempt;
+    }
+}
diff --git a/mod/h5pactivity/tests/local/manager_test.php b/mod/h5pactivity/tests/local/manager_test.php
new file mode 100644 (file)
index 0000000..e1b63f8
--- /dev/null
@@ -0,0 +1,356 @@
+<?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/>.
+
+/**
+ * mod_h5pactivity manager tests
+ *
+ * @package    mod_h5pactivity
+ * @category   test
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\local;
+use context_module;
+use stdClass;
+
+/**
+ * Manager tests class for mod_h5pactivity.
+ *
+ * @package    mod_h5pactivity
+ * @category   test
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class manager_testcase extends \advanced_testcase {
+
+    /**
+     * Test for static create methods.
+     */
+    public function test_create() {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+        $cm = get_coursemodule_from_id('h5pactivity', $activity->cmid, 0, false, MUST_EXIST);
+        $context = context_module::instance($cm->id);
+
+        $manager = manager::create_from_instance($activity);
+        $manageractivity = $manager->get_instance();
+        $this->assertEquals($activity->id, $manageractivity->id);
+        $managercm = $manager->get_coursemodule();
+        $this->assertEquals($cm->id, $managercm->id);
+        $managercontext = $manager->get_context();
+        $this->assertEquals($context->id, $managercontext->id);
+
+        $manager = manager::create_from_coursemodule($cm);
+        $manageractivity = $manager->get_instance();
+        $this->assertEquals($activity->id, $manageractivity->id);
+        $managercm = $manager->get_coursemodule();
+        $this->assertEquals($cm->id, $managercm->id);
+        $managercontext = $manager->get_context();
+        $this->assertEquals($context->id, $managercontext->id);
+    }
+
+    /**
+     * Test for is_tracking_enabled.
+     *
+     * @dataProvider is_tracking_enabled_data
+     * @param bool $login if the user is logged in
+     * @param string $role user role in course
+     * @param int $enabletracking if tracking is enabled
+     * @param bool $expected expected result
+     */
+    public function test_is_tracking_enabled(bool $login, string $role, int $enabletracking, bool $expected) {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $activity = $this->getDataGenerator()->create_module('h5pactivity',
+                ['course' => $course, 'enabletracking' => $enabletracking]);
+
+        $user = $this->getDataGenerator()->create_and_enrol($course, $role);
+        if ($login) {
+            $this->setUser($user);
+            $param = null;
+        } else {
+            $param = $user;
+        }
+
+        $manager = manager::create_from_instance($activity);
+        $this->assertEquals($expected, $manager->is_tracking_enabled($param));
+    }
+
+    /**
+     * Data provider for is_tracking_enabled.
+     *
+     * @return array
+     */
+    public function is_tracking_enabled_data(): array {
+        return [
+            'Logged student, tracking enabled' => [
+                true, 'student', 1, true
+            ],
+            'Logged student, tracking disabled' => [
+                true, 'student', 0, false
+            ],
+            'Logged teacher, tracking enabled' => [
+                true, 'editingteacher', 1, false
+            ],
+            'Logged teacher, tracking disabled' => [
+                true, 'editingteacher', 0, false
+            ],
+            'No logged student, tracking enabled' => [
+                true, 'student', 1, true
+            ],
+            'No logged student, tracking disabled' => [
+                true, 'student', 0, false
+            ],
+            'No logged teacher, tracking enabled' => [
+                true, 'editingteacher', 1, false
+            ],
+            'No logged teacher, tracking disabled' => [
+                true, 'editingteacher', 0, false
+            ],
+        ];
+    }
+
+    /**
+     * Test for get_users_scaled_score.
+     *
+     * @dataProvider get_users_scaled_score_data
+     * @param int $enabletracking if tracking is enabled
+     * @param int $gradingmethod new grading method
+     * @param array $result1 student 1 results (scaled, timemodified, attempt number)
+     * @param array $result2 student 2 results (scaled, timemodified, attempt number)
+     */
+    public function test_get_users_scaled_score(int $enabletracking, int $gradingmethod, array $result1, array $result2) {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $activity = $this->getDataGenerator()->create_module('h5pactivity',
+                ['course' => $course, 'enabletracking' => $enabletracking, 'grademethod' => $gradingmethod]);
+
+        // Generate two users with 4 attempts each.
+        $user1 = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $this->generate_fake_attempts($activity, $user1, 1);
+        $user2 = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $this->generate_fake_attempts($activity, $user2, 2);
+
+        $manager = manager::create_from_instance($activity);
+
+        // Get all users scaled scores.
+        $scaleds = $manager->get_users_scaled_score();
+
+        // No results will be returned if tracking is dsabled or manual grading method is defined.
+        if (empty($result1)) {
+            $this->assertNull($scaleds);
+            return;
+        }
+
+        $this->assertCount(2, $scaleds);
+
+        // Check expected user1 scaled score.
+        $scaled = $scaleds[$user1->id];
+        $this->assertEquals($user1->id, $scaled->userid);
+        $this->assertEquals($result1[0], $scaled->scaled);
+        $this->assertEquals($result1[1], $scaled->timemodified);
+        if ($result1[2]) {
+            $attempt = $DB->get_record('h5pactivity_attempts', ['id' => $scaled->attemptid]);
+            $this->assertEquals($attempt->h5pactivityid, $activity->id);
+            $this->assertEquals($attempt->userid, $scaled->userid);
+            $this->assertEquals($attempt->scaled, round($scaled->scaled, 5));
+            $this->assertEquals($attempt->timemodified, $scaled->timemodified);
+            $this->assertEquals($result1[2], $attempt->attempt);
+        } else {
+            $this->assertEquals(0, $scaled->attemptid);
+        }
+
+        // Check expected user2 scaled score.
+        $scaled = $scaleds[$user2->id];
+        $this->assertEquals($user2->id, $scaled->userid);
+        $this->assertEquals($result2[0], round($scaled->scaled, 5));
+        $this->assertEquals($result2[1], $scaled->timemodified);
+        if ($result2[2]) {
+            $attempt = $DB->get_record('h5pactivity_attempts', ['id' => $scaled->attemptid]);
+            $this->assertEquals($attempt->h5pactivityid, $activity->id);
+            $this->assertEquals($attempt->userid, $scaled->userid);
+            $this->assertEquals($attempt->scaled, $scaled->scaled);
+            $this->assertEquals($attempt->timemodified, $scaled->timemodified);
+            $this->assertEquals($result2[2], $attempt->attempt);
+        } else {
+            $this->assertEquals(0, $scaled->attemptid);
+        }
+
+        // Now check a single user record.
+        $scaleds = $manager->get_users_scaled_score($user2->id);
+        $this->assertCount(1, $scaleds);
+        $scaled2 = $scaleds[$user2->id];
+        $this->assertEquals($scaled->userid, $scaled2->userid);
+        $this->assertEquals($scaled->scaled, $scaled2->scaled);
+        $this->assertEquals($scaled->attemptid, $scaled2->attemptid);
+        $this->assertEquals($scaled->timemodified, $scaled2->timemodified);
+    }
+
+    /**
+     * Data provider for get_users_scaled_score.
+     *
+     * @return array
+     */
+    public function get_users_scaled_score_data(): array {
+        return [
+            'Tracking with max attempt method' => [
+                1, manager::GRADEHIGHESTATTEMPT, [1.00000, 31, 2], [0.66667, 32, 2]
+            ],
+            'Tracking with average attempt method' => [
+                1, manager::GRADEAVERAGEATTEMPT, [0.61111, 51, 0], [0.52222, 52, 0]
+            ],
+            'Tracking with last attempt method' => [
+                1, manager::GRADELASTATTEMPT, [0.33333, 51, 3], [0.40000, 52, 3]
+            ],
+            'Tracking with first attempt method' => [
+                1, manager::GRADEFIRSTATTEMPT, [0.50000, 11, 1], [0.50000, 12, 1]
+            ],
+            'Tracking with manual attempt grading' => [
+                1, manager::GRADEMANUAL, [], []
+            ],
+            'No tracking with max attempt method' => [
+                0, manager::GRADEHIGHESTATTEMPT, [], []
+            ],
+            'No tracking with average attempt method' => [
+                0, manager::GRADEAVERAGEATTEMPT, [], []
+            ],
+            'No tracking with last attempt method' => [
+                0, manager::GRADELASTATTEMPT, [], []
+            ],
+            'No tracking with first attempt method' => [
+                0, manager::GRADEFIRSTATTEMPT, [], []
+            ],
+            'No tracking with manual attempt grading' => [
+                0, manager::GRADEMANUAL, [], []
+            ],
+        ];
+    }
+
+    /**
+     * Test static get_grading_methods.
+     */
+    public function test_get_grading_methods() {
+        $methods = manager::get_grading_methods();
+        $this->assertCount(5, $methods);
+        $this->assertNotEmpty($methods[manager::GRADEHIGHESTATTEMPT]);
+        $this->assertNotEmpty($methods[manager::GRADEAVERAGEATTEMPT]);
+        $this->assertNotEmpty($methods[manager::GRADELASTATTEMPT]);
+        $this->assertNotEmpty($methods[manager::GRADEFIRSTATTEMPT]);
+        $this->assertNotEmpty($methods[manager::GRADEMANUAL]);
+    }
+
+    /**
+     * Test get_grader method.
+     */
+    public function test_get_grader() {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+        $cm = get_coursemodule_from_id('h5pactivity', $activity->cmid, 0, false, MUST_EXIST);
+        $context = context_module::instance($cm->id);
+
+        $manager = manager::create_from_instance($activity);
+        $grader = $manager->get_grader();
+
+        $this->assertInstanceOf('mod_h5pactivity\local\grader', $grader);
+    }
+
+    /**
+     * Insert fake attempt data into h5pactiviyt_attempts.
+     *
+     * This function insert 4 attempts. 3 of them finished with different gradings
+     * and timestamps and 1 unfinished.
+     *
+     * @param stdClass $activity the activity record
+     * @param stdClass $user user record
+     * @param int $basescore a score to be used to generate all attempts
+     */
+    private function generate_fake_attempts(stdClass $activity, stdClass $user, int $basescore) {
+        global $DB;
+
+        $attempt = (object)[
+            'h5pactivityid' => $activity->id,
+            'userid' => $user->id,
+            'timecreated' => $basescore,
+            'timemodified' => ($basescore + 10),
+            'attempt' => 1,
+            'rawscore' => $basescore,
+            'maxscore' => ($basescore + $basescore),
+            'duration' => $basescore,
+            'completion' => 1,
+            'success' => 1,
+        ];
+        $attempt->scaled = $attempt->rawscore / $attempt->maxscore;
+        $DB->insert_record('h5pactivity_attempts', $attempt);
+
+        $attempt = (object)[
+            'h5pactivityid' => $activity->id,
+            'userid' => $user->id,
+            'timecreated' => ($basescore + 20),
+            'timemodified' => ($basescore + 30),
+            'attempt' => 2,
+            'rawscore' => $basescore,
+            'maxscore' => ($basescore + $basescore - 1),
+            'duration' => $basescore,
+            'completion' => 1,
+            'success' => 1,
+        ];
+        $attempt->scaled = $attempt->rawscore / $attempt->maxscore;
+        $DB->insert_record('h5pactivity_attempts', $attempt);
+
+        $attempt = (object)[
+            'h5pactivityid' => $activity->id,
+            'userid' => $user->id,
+            'timecreated' => ($basescore + 40),
+            'timemodified' => ($basescore + 50),
+            'attempt' => 3,
+            'rawscore' => $basescore,
+            'maxscore' => ($basescore + $basescore + 1),
+            'duration' => $basescore,
+            'completion' => 1,
+            'success' => 0,
+        ];
+        $attempt->scaled = $attempt->rawscore / $attempt->maxscore;
+        $DB->insert_record('h5pactivity_attempts', $attempt);
+
+        // Unfinished attempt.
+        $attempt = (object)[
+            'h5pactivityid' => $activity->id,
+            'userid' => $user->id,
+            'timecreated' => ($basescore + 60),
+            'timemodified' => ($basescore + 60),
+            'attempt' => 4,
+            'rawscore' => $basescore,
+            'maxscore' => $basescore,
+            'duration' => $basescore,
+        ];
+        $DB->insert_record('h5pactivity_attempts', $attempt);
+    }
+}
index ca3f0da..53dc802 100644 (file)
@@ -111,6 +111,8 @@ class mod_h5pactivity_restore_testcase extends advanced_testcase {
         $this->assertEquals($activity->introformat, $activity2->introformat);
         $this->assertEquals($activity->grade, $activity2->grade);
         $this->assertEquals($activity->displayoptions, $activity2->displayoptions);
+        $this->assertEquals($activity->enabletracking, $activity2->enabletracking);
+        $this->assertEquals($activity->grademethod, $activity2->grademethod);
 
         // Compare attempts.
         if ($content && $userdata) {
index b200f90..2d8caf8 100644 (file)
@@ -25,5 +25,5 @@
 defined('MOODLE_INTERNAL') || die();
 
 $plugin->component = 'mod_h5pactivity';
-$plugin->version = 2020041400;
+$plugin->version = 2020041401;
 $plugin->requires = 2020013000;
index be2c11d..7a47edb 100644 (file)
  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+use mod_h5pactivity\local\manager;
+use mod_h5pactivity\event\course_module_viewed;
+use core_h5p\factory;
+use core_h5p\player;
+use core_h5p\helper;
+
 require(__DIR__.'/../../config.php');
 require_once(__DIR__.'/lib.php');
 require_once($CFG->libdir.'/completionlib.php');
@@ -32,11 +38,13 @@ list ($course, $cm) = get_course_and_cm_from_cmid($id, 'h5pactivity');
 
 require_login($course, true, $cm);
 
-$moduleinstance = $DB->get_record('h5pactivity', ['id' => $cm->instance], '*', MUST_EXIST);
+$manager = manager::create_from_coursemodule($cm);
+
+$moduleinstance = $manager->get_instance();
 
-$context = context_module::instance($cm->id);
+$context = $manager->get_context();
 
-$event = \mod_h5pactivity\event\course_module_viewed::create([
+$event = course_module_viewed::create([
     'objectid' => $moduleinstance->id,
     'context' => $context
 ]);
@@ -49,9 +57,9 @@ $completion = new completion_info($course);
 $completion->set_module_viewed($cm);
 
 // Convert display options to a valid object.
-$factory = new \core_h5p\factory();
+$factory = new factory();
 $core = $factory->get_core();
-$config = \core_h5p\helper::decode_display_options($core, $moduleinstance->displayoptions);
+$config = core_h5p\helper::decode_display_options($core, $moduleinstance->displayoptions);
 
 // Instantiate player.
 $fs = get_file_storage();
@@ -73,7 +81,7 @@ $PAGE->set_context($context);
 echo $OUTPUT->header();
 echo $OUTPUT->heading(format_string($moduleinstance->name));
 
-if (has_capability('mod/h5pactivity:submit', $context, null, false)) {
+if ($manager->is_tracking_enabled()) {
     $trackcomponent = 'mod_h5pactivity';
 } else {
     $trackcomponent = '';
@@ -81,6 +89,6 @@ if (has_capability('mod/h5pactivity:submit', $context, null, false)) {
     echo $OUTPUT->notification($message, \core\output\notification::NOTIFY_WARNING);
 }
 
-echo \core_h5p\player::display($fileurl, $config, true, $trackcomponent);
+echo player::display($fileurl, $config, true, $trackcomponent);
 
 echo $OUTPUT->footer();