Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

Differences Between: [Versions 310 and 403] [Versions 311 and 403] [Versions 39 and 403] [Versions 400 and 403] [Versions 401 and 403]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * H5P activity attempt object
  19   *
  20   * @package    mod_h5pactivity
  21   * @since      Moodle 3.9
  22   * @copyright  2020 Ferran Recio <ferran@moodle.com>
  23   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  24   */
  25  
  26  namespace mod_h5pactivity\local;
  27  
  28  use core_xapi\handler;
  29  use stdClass;
  30  use core_xapi\local\statement;
  31  
  32  /**
  33   * Class attempt for H5P activity
  34   *
  35   * @package    mod_h5pactivity
  36   * @since      Moodle 3.9
  37   * @copyright  2020 Ferran Recio <ferran@moodle.com>
  38   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  39   */
  40  class attempt {
  41  
  42      /** @var stdClass the h5pactivity_attempts record. */
  43      private $record;
  44  
  45      /** @var boolean if the DB statement has been updated. */
  46      private $scoreupdated = false;
  47  
  48      /**
  49       * Create a new attempt object.
  50       *
  51       * @param stdClass $record the h5pactivity_attempts record
  52       */
  53      public function __construct(stdClass $record) {
  54          $this->record = $record;
  55      }
  56  
  57      /**
  58       * Create a new user attempt in a specific H5P activity.
  59       *
  60       * @param stdClass $user a user record
  61       * @param stdClass $cm a course_module record
  62       * @return attempt|null a new attempt object or null if fail
  63       */
  64      public static function new_attempt(stdClass $user, stdClass $cm): ?attempt {
  65          global $DB;
  66          $record = new stdClass();
  67          $record->h5pactivityid = $cm->instance;
  68          $record->userid = $user->id;
  69          $record->timecreated = time();
  70          $record->timemodified = $record->timecreated;
  71          $record->rawscore = 0;
  72          $record->maxscore = 0;
  73          $record->duration = 0;
  74          $record->completion = null;
  75          $record->success = null;
  76  
  77          // Get last attempt number.
  78          $conditions = ['h5pactivityid' => $cm->instance, 'userid' => $user->id];
  79          $countattempts = $DB->count_records('h5pactivity_attempts', $conditions);
  80          $record->attempt = $countattempts + 1;
  81  
  82          $record->id = $DB->insert_record('h5pactivity_attempts', $record);
  83          if (!$record->id) {
  84              return null;
  85          }
  86          // Remove any xAPI State associated to this attempt.
  87          $context = \context_module::instance($cm->id);
  88          $xapihandler = handler::create('mod_h5pactivity');
  89          $xapihandler->wipe_states($context->id, $user->id);
  90  
  91          return new attempt($record);
  92      }
  93  
  94      /**
  95       * Get the last user attempt in a specific H5P activity.
  96       *
  97       * If no previous attempt exists, it generates a new one.
  98       *
  99       * @param stdClass $user a user record
 100       * @param stdClass $cm a course_module record
 101       * @return attempt|null a new attempt object or null if some problem accured
 102       */
 103      public static function last_attempt(stdClass $user, stdClass $cm): ?attempt {
 104          global $DB;
 105          $conditions = ['h5pactivityid' => $cm->instance, 'userid' => $user->id];
 106          $records = $DB->get_records('h5pactivity_attempts', $conditions, 'attempt DESC', '*', 0, 1);
 107          if (empty($records)) {
 108              return self::new_attempt($user, $cm);
 109          }
 110          return new attempt(array_shift($records));
 111      }
 112  
 113      /**
 114       * Wipe all attempt data for specific course_module and an optional user.
 115       *
 116       * @param stdClass $cm a course_module record
 117       * @param stdClass $user a user record
 118       */
 119      public static function delete_all_attempts(stdClass $cm, stdClass $user = null): void {
 120          global $DB;
 121  
 122          $where = 'a.h5pactivityid = :h5pactivityid';
 123          $conditions = ['h5pactivityid' => $cm->instance];
 124          if (!empty($user)) {
 125              $where .= ' AND a.userid = :userid';
 126              $conditions['userid'] = $user->id;
 127          }
 128  
 129          $DB->delete_records_select('h5pactivity_attempts_results', "attemptid IN (
 130                  SELECT a.id
 131                  FROM {h5pactivity_attempts} a
 132                  WHERE $where)", $conditions);
 133  
 134          $DB->delete_records('h5pactivity_attempts', $conditions);
 135      }
 136  
 137      /**
 138       * Delete a specific attempt.
 139       *
 140       * @param attempt $attempt the attempt object to delete
 141       */
 142      public static function delete_attempt(attempt $attempt): void {
 143          global $DB;
 144          $attempt->delete_results();
 145          $DB->delete_records('h5pactivity_attempts', ['id' => $attempt->get_id()]);
 146      }
 147  
 148      /**
 149       * Save a new result statement into the attempt.
 150       *
 151       * It also updates the rawscore and maxscore if necessary.
 152       *
 153       * @param statement $statement the xAPI statement object
 154       * @param string $subcontent = '' optional subcontent identifier
 155       * @return bool if it can save the statement into db
 156       */
 157      public function save_statement(statement $statement, string $subcontent = ''): bool {
 158          global $DB;
 159  
 160          // Check statement data.
 161          $xapiobject = $statement->get_object();
 162          if (empty($xapiobject)) {
 163              return false;
 164          }
 165          $xapiresult = $statement->get_result();
 166          $xapidefinition = $xapiobject->get_definition();
 167          if (empty($xapidefinition) || empty($xapiresult)) {
 168              return false;
 169          }
 170  
 171          $xapicontext = $statement->get_context();
 172          if ($xapicontext) {
 173              $context = $xapicontext->get_data();
 174          } else {
 175              $context = new stdClass();
 176          }
 177          $definition = $xapidefinition->get_data();
 178          $result = $xapiresult->get_data();
 179          $duration = $xapiresult->get_duration();
 180  
 181          // Insert attempt_results record.
 182          $record = new stdClass();
 183          $record->attemptid = $this->record->id;
 184          $record->subcontent = $subcontent;
 185          $record->timecreated = time();
 186          $record->interactiontype = $definition->interactionType ?? 'other';
 187          $record->description = $this->get_description_from_definition($definition);
 188          $record->correctpattern = $this->get_correctpattern_from_definition($definition);
 189          $record->response = $result->response ?? '';
 190          $record->additionals = $this->get_additionals($definition, $context);
 191          $record->rawscore = 0;
 192          $record->maxscore = 0;
 193          if (isset($result->score)) {
 194              $record->rawscore = $result->score->raw ?? 0;
 195              $record->maxscore = $result->score->max ?? 0;
 196          }
 197          $record->duration = $duration;
 198          if (isset($result->completion)) {
 199              $record->completion = ($result->completion) ? 1 : 0;
 200          }
 201          if (isset($result->success)) {
 202              $record->success = ($result->success) ? 1 : 0;
 203          }
 204          if (!$DB->insert_record('h5pactivity_attempts_results', $record)) {
 205              return false;
 206          }
 207  
 208          // If no subcontent provided, results are propagated to the attempt itself.
 209          if (empty($subcontent)) {
 210              $this->set_duration($record->duration);
 211              $this->set_completion($record->completion ?? null);
 212              $this->set_success($record->success ?? null);
 213              // If Maxscore is not empty means that the rawscore is valid (even if it's 0)
 214              // and scaled score can be calculated.
 215              if ($record->maxscore) {
 216                  $this->set_score($record->rawscore, $record->maxscore);
 217              }
 218          }
 219          // Refresh current attempt.
 220          return $this->save();
 221      }
 222  
 223      /**
 224       * Update the current attempt record into DB.
 225       *
 226       * @return bool true if update is succesful
 227       */
 228      public function save(): bool {
 229          global $DB;
 230          $this->record->timemodified = time();
 231          // Calculate scaled score.
 232          if ($this->scoreupdated) {
 233              if (empty($this->record->maxscore)) {
 234                  $this->record->scaled = 0;
 235              } else {
 236                  $this->record->scaled = $this->record->rawscore / $this->record->maxscore;
 237              }
 238          }
 239          return $DB->update_record('h5pactivity_attempts', $this->record);
 240      }
 241  
 242      /**
 243       * Set the attempt score.
 244       *
 245       * @param int|null $rawscore the attempt rawscore
 246       * @param int|null $maxscore the attempt maxscore
 247       */
 248      public function set_score(?int $rawscore, ?int $maxscore): void {
 249          $this->record->rawscore = $rawscore;
 250          $this->record->maxscore = $maxscore;
 251          $this->scoreupdated = true;
 252      }
 253  
 254      /**
 255       * Set the attempt duration.
 256       *
 257       * @param int|null $duration the attempt duration
 258       */
 259      public function set_duration(?int $duration): void {
 260          $this->record->duration = $duration;
 261      }
 262  
 263      /**
 264       * Set the attempt completion.
 265       *
 266       * @param int|null $completion the attempt completion
 267       */
 268      public function set_completion(?int $completion): void {
 269          $this->record->completion = $completion;
 270      }
 271  
 272      /**
 273       * Set the attempt success.
 274       *
 275       * @param int|null $success the attempt success
 276       */
 277      public function set_success(?int $success): void {
 278          $this->record->success = $success;
 279      }
 280  
 281      /**
 282       * Delete the current attempt results from the DB.
 283       */
 284      public function delete_results(): void {
 285          global $DB;
 286          $conditions = ['attemptid' => $this->record->id];
 287          $DB->delete_records('h5pactivity_attempts_results', $conditions);
 288      }
 289  
 290      /**
 291       * Return de number of results stored in this attempt.
 292       *
 293       * @return int the number of results stored in this attempt.
 294       */
 295      public function count_results(): int {
 296          global $DB;
 297          $conditions = ['attemptid' => $this->record->id];
 298          return $DB->count_records('h5pactivity_attempts_results', $conditions);
 299      }
 300  
 301      /**
 302       * Return all results stored in this attempt.
 303       *
 304       * @return stdClass[] results records.
 305       */
 306      public function get_results(): array {
 307          global $DB;
 308          $conditions = ['attemptid' => $this->record->id];
 309          return $DB->get_records('h5pactivity_attempts_results', $conditions, 'id ASC');
 310      }
 311  
 312      /**
 313       * Get additional data for some interaction types.
 314       *
 315       * @param stdClass $definition the statement object definition data
 316       * @param stdClass $context the statement optional context
 317       * @return string JSON encoded additional information
 318       */
 319      private function get_additionals(stdClass $definition, stdClass $context): string {
 320          $additionals = [];
 321          $interactiontype = $definition->interactionType ?? 'other';
 322          switch ($interactiontype) {
 323              case 'choice':
 324              case 'sequencing':
 325                  $additionals['choices'] = $definition->choices ?? [];
 326              break;
 327  
 328              case 'matching':
 329                  $additionals['source'] = $definition->source ?? [];
 330                  $additionals['target'] = $definition->target ?? [];
 331              break;
 332  
 333              case 'likert':
 334                  $additionals['scale'] = $definition->scale ?? [];
 335              break;
 336  
 337              case 'performance':
 338                  $additionals['steps'] = $definition->steps ?? [];
 339              break;
 340          }
 341  
 342          $additionals['extensions'] = $definition->extensions ?? new stdClass();
 343  
 344          // Add context extensions.
 345          $additionals['contextExtensions'] = $context->extensions ?? new stdClass();
 346  
 347          if (empty($additionals)) {
 348              return '';
 349          }
 350          return json_encode($additionals);
 351      }
 352  
 353      /**
 354       * Extract the result description from statement object definition.
 355       *
 356       * In principle, H5P package can send a multilang description but the reality
 357       * is that most activities only send the "en_US" description if any and the
 358       * activity does not have any control over it.
 359       *
 360       * @param stdClass $definition the statement object definition
 361       * @return string The available description if any
 362       */
 363      private function get_description_from_definition(stdClass $definition): string {
 364          if (!isset($definition->description)) {
 365              return '';
 366          }
 367          $translations = (array) $definition->description;
 368          if (empty($translations)) {
 369              return '';
 370          }
 371          // By default, H5P packages only send "en-US" descriptions.
 372          return $translations['en-US'] ?? array_shift($translations);
 373      }
 374  
 375      /**
 376       * Extract the correct pattern from statement object definition.
 377       *
 378       * The correct pattern depends on the type of content and the plugin
 379       * has no control over it so we just store it in case that the statement
 380       * data have it.
 381       *
 382       * @param stdClass $definition the statement object definition
 383       * @return string The correct pattern if any
 384       */
 385      private function get_correctpattern_from_definition(stdClass $definition): string {
 386          if (!isset($definition->correctResponsesPattern)) {
 387              return '';
 388          }
 389          // Only arrays are allowed.
 390          if (is_array($definition->correctResponsesPattern)) {
 391              return json_encode($definition->correctResponsesPattern);
 392          }
 393          return '';
 394      }
 395  
 396      /**
 397       * Return the attempt number.
 398       *
 399       * @return int the attempt number
 400       */
 401      public function get_attempt(): int {
 402          return $this->record->attempt;
 403      }
 404  
 405      /**
 406       * Return the attempt ID.
 407       *
 408       * @return int the attempt id
 409       */
 410      public function get_id(): int {
 411          return $this->record->id;
 412      }
 413  
 414      /**
 415       * Return the attempt user ID.
 416       *
 417       * @return int the attempt userid
 418       */
 419      public function get_userid(): int {
 420          return $this->record->userid;
 421      }
 422  
 423      /**
 424       * Return the attempt H5P timecreated.
 425       *
 426       * @return int the attempt timecreated
 427       */
 428      public function get_timecreated(): int {
 429          return $this->record->timecreated;
 430      }
 431  
 432      /**
 433       * Return the attempt H5P timemodified.
 434       *
 435       * @return int the attempt timemodified
 436       */
 437      public function get_timemodified(): int {
 438          return $this->record->timemodified;
 439      }
 440  
 441      /**
 442       * Return the attempt H5P activity ID.
 443       *
 444       * @return int the attempt userid
 445       */
 446      public function get_h5pactivityid(): int {
 447          return $this->record->h5pactivityid;
 448      }
 449  
 450      /**
 451       * Return the attempt maxscore.
 452       *
 453       * @return int the maxscore value
 454       */
 455      public function get_maxscore(): int {
 456          return $this->record->maxscore;
 457      }
 458  
 459      /**
 460       * Return the attempt rawscore.
 461       *
 462       * @return int the rawscore value
 463       */
 464      public function get_rawscore(): int {
 465          return $this->record->rawscore;
 466      }
 467  
 468      /**
 469       * Return the attempt duration.
 470       *
 471       * @return int|null the duration value
 472       */
 473      public function get_duration(): ?int {
 474          return $this->record->duration;
 475      }
 476  
 477      /**
 478       * Return the attempt completion.
 479       *
 480       * @return int|null the completion value
 481       */
 482      public function get_completion(): ?int {
 483          return $this->record->completion;
 484      }
 485  
 486      /**
 487       * Return the attempt success.
 488       *
 489       * @return int|null the success value
 490       */
 491      public function get_success(): ?int {
 492          return $this->record->success;
 493      }
 494  
 495      /**
 496       * Return the attempt scaled.
 497       *
 498       * @return int|null the scaled value
 499       */
 500      public function get_scaled(): ?int {
 501          return is_null($this->record->scaled) ? $this->record->scaled : (int)$this->record->scaled;
 502      }
 503  
 504      /**
 505       * Return if the attempt has been modified.
 506       *
 507       * Note: adding a result only add track information unless the statement does
 508       * not specify subcontent. In this case this will update also the statement.
 509       *
 510       * @return bool if the attempt score have been modified
 511       */
 512      public function get_scoreupdated(): bool {
 513          return $this->scoreupdated;
 514      }
 515  }