Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

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