diff --git a/lms_xapi.module b/lms_xapi.module index 32d48eadd54e3c950d24297991ef090d810acb03..177f274963aa1a8107ce55fcb90b719f8d5d5fcc 100644 --- a/lms_xapi.module +++ b/lms_xapi.module @@ -9,6 +9,7 @@ declare(strict_types=1); use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\FieldableEntityInterface; +use Drupal\lms_xapi\LRSReferenceStorage; use Drupal\user\UserInterface; /** @@ -20,7 +21,7 @@ function lms_xapi_entity_delete(EntityInterface $entity) { } // Cleanup. - $storage = \Drupal::service('lms_xapi.lrs_reference_storage'); + $storage = \Drupal::service(LRSReferenceStorage::class); if ($entity instanceof UserInterface) { // LMS IDs always start with a user ID. $storage->deleteLrsReferences($entity->id() . ':%'); diff --git a/lms_xapi.services.yml b/lms_xapi.services.yml index 9c53ebe511064a5cc5aab077007945f366f64b18..c405323a334ecb34268bd49c59daabeebc8bb253 100644 --- a/lms_xapi.services.yml +++ b/lms_xapi.services.yml @@ -2,10 +2,10 @@ services: _defaults: autowire: true - lms_xapi.lrs_reference_storage: - class: 'Drupal\lms_xapi\LRSReferenceStorage' - Drupal\lms_xapi\LRSReferenceStorage: '@lms_xapi.lrs_reference_storage' + Drupal\lms_xapi\LRSReferenceStorage: ~ - lms_xapi.tincan: - class: 'Drupal\lms_xapi\TincanService' - Drupal\lms_xapi\TincanService: '@lms_xapi.tincan' + Drupal\lms_xapi\XapiService: ~ + + logger.channel.lms_xapi: + parent: logger.channel_base + arguments: ['lms_xapi'] diff --git a/modules/lms_xapi_activity/src/ActivityXapiIdGenerator.php b/modules/lms_xapi_activity/src/ActivityXapiIdGenerator.php index 3082a127fddd210de8b59b7518e67b328e7b9612..662077e9e97810b8d5c3b83790b0f714a5c0be35 100644 --- a/modules/lms_xapi_activity/src/ActivityXapiIdGenerator.php +++ b/modules/lms_xapi_activity/src/ActivityXapiIdGenerator.php @@ -11,8 +11,10 @@ use Drupal\Core\Session\AccountInterface; use Drupal\lms\Entity\Bundle\Course; use Drupal\lms\TrainingManager; use Drupal\lms_xapi\XapiIdGeneratorInterface; -use Drupal\lms\Entity\Activity; +use Drupal\lms\Entity\ActivityInterface; use Drupal\lms\Entity\ActivityTypeInterface; +use Drupal\lms\Entity\AnswerInterface; +use Drupal\lms\Entity\CourseStatusInterface; /** * Xapi LRS event subscriber. @@ -35,20 +37,15 @@ class ActivityXapiIdGenerator implements XapiIdGeneratorInterface { AccountInterface $student, CacheableMetadata $cacheable_metadata, ): bool { - if (!$entity instanceof Activity) { + if (!$entity instanceof ActivityInterface) { return FALSE; } - $course = $this->routeMatch->getParameter('group'); $cacheable_metadata->setCacheContexts($cacheable_metadata->getCacheContexts() + ['route']); - if ($course instanceof Course) { - $course_status = $this->trainingManager->loadCourseStatus($course, $student, [ - 'current' => TRUE, - ]); - $cacheable_metadata->addCacheableDependency($course_status); - if ($course_status === NULL) { - return FALSE; - } + $course_status = $this->getCourseStatus($student); + if ($course_status === NULL) { + return FALSE; } + $cacheable_metadata->addCacheableDependency($course_status); $bundle = $entity->get('type')->entity; assert($bundle instanceof ActivityTypeInterface); @@ -68,11 +65,10 @@ class ActivityXapiIdGenerator implements XapiIdGeneratorInterface { AccountInterface $student, CacheableMetadata $cacheable_metadata, ): array { - - $course = $this->routeMatch->getParameter('group'); - $course_status = $this->trainingManager->loadCourseStatus($course, $student, [ - 'current' => TRUE, - ]); + $course_status = $this->getCourseStatus($student); + if ($course_status === NULL) { + throw new \Exception('Unable to determine the current course status.'); + } return [ $entity->getEntityTypeId(), @@ -81,4 +77,24 @@ class ActivityXapiIdGenerator implements XapiIdGeneratorInterface { ]; } + /** + * Get the current course status from route. + */ + private function getCourseStatus(AccountInterface $student): ?CourseStatusInterface { + $course = $this->routeMatch->getParameter('group'); + if (!$course instanceof Course) { + // This may be an answer details route. + $answer = $this->routeMatch->getParameter('lms_answer'); + if (!$answer instanceof AnswerInterface) { + return NULL; + } + return $answer->getLessonStatus()->getCourseStatus(); + } + else { + return $this->trainingManager->loadCourseStatus($course, $student, [ + 'current' => TRUE, + ]); + } + } + } diff --git a/modules/lms_xapi_activity/src/Plugin/ActivityAnswer/Xapi.php b/modules/lms_xapi_activity/src/Plugin/ActivityAnswer/Xapi.php index 71f9a86fa9a3df8eb332ef88b5cfae1d6bc9e151..ea8fd3f431df35ad6d209950d56c5d45d4bb3b34 100644 --- a/modules/lms_xapi_activity/src/Plugin/ActivityAnswer/Xapi.php +++ b/modules/lms_xapi_activity/src/Plugin/ActivityAnswer/Xapi.php @@ -9,7 +9,7 @@ use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\lms\Attribute\ActivityAnswer; use Drupal\lms\Entity\Answer; use Drupal\lms\Plugin\ActivityAnswerBase; -use Drupal\lms_xapi\TincanService; +use Drupal\lms_xapi\XapiService; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -22,35 +22,90 @@ use Symfony\Component\DependencyInjection\ContainerInterface; class Xapi extends ActivityAnswerBase { /** - * The TinCan service. + * The Xapi service. */ - protected TinCanService $lrsService; + protected XapiService $xapi; + + /** + * Static score storage. + */ + private ?float $score = NULL; /** * {@inheritdoc} */ public function injectServices(ContainerInterface $container): void { parent::injectServices($container); - $this->lrsService = $container->get('lms_xapi.tincan'); + $this->xapi = $container->get(XapiService::class); } /** * {@inheritdoc} */ public function getScore(Answer $answer): float { + if ($this->score !== NULL) { + return $this->score; + } + $account = $answer->getOwner(); $activity = $answer->getActivity(); $cacheable_metadata = new CacheableMetadata(); - $lms_id = $this->lrsService->getLmsId($activity, $account, $cacheable_metadata); - $score = $this->lrsService->getScoreFromLrs($lms_id); - return $score ?? 0; + $lms_id = $this->xapi->getLmsId($activity, $account, $cacheable_metadata); + $score = $this->xapi->getScoreFromLrs($lms_id); + $this->score = $score ?? 0; + + return $this->score; } /** * {@inheritdoc} */ public function evaluatedOnSave(Answer $answer): bool { + if ($this->getScore($answer) === 0.0) { + return FALSE; + } return TRUE; } + /** + * {@inheritdoc} + */ + public function evaluationDisplay(Answer $answer): array { + $activity = $answer->getActivity(); + + if (!$activity->hasField('field_xapi_package')) { + throw new \Exception('Xapi field missing on activity.'); + } + $items = $activity->get('field_xapi_package'); + if ($items->isEmpty()) { + return []; + } + /** @var \Drupal\lms_xapi\Plugin\Field\FieldType\XapiItem */ + $xapi_item = $items->first(); + $cacheable_metadata = new CacheableMetadata(); + $launch_url = $this->xapi->getLaunchUrl( + $xapi_item->getPackagePath(), + $activity, + $answer->get('user_id')->entity, + $cacheable_metadata + ); + if ($launch_url === NULL) { + return []; + } + + // Display the entire package with student data loaded from LRS. + $build = [ + '#type' => 'html_tag', + '#tag' => 'iframe', + '#attributes' => [ + 'src' => $launch_url, + 'style' => 'width: 100%; min-height: 800px; border: 0;', + ], + '#value' => '', + ]; + $cacheable_metadata->applyTo($build); + + return $build; + } + } diff --git a/modules/lms_xapi_lesson/src/Form/XapiLessonForm.php b/modules/lms_xapi_lesson/src/Form/XapiLessonForm.php index caeeb9c668905b45bc0f5e34be260dd307afad44..5f8c3e8053f7b1263c1f9936363c838c458608fe 100644 --- a/modules/lms_xapi_lesson/src/Form/XapiLessonForm.php +++ b/modules/lms_xapi_lesson/src/Form/XapiLessonForm.php @@ -9,7 +9,7 @@ use Drupal\Core\Form\FormStateInterface; use Drupal\lms\Entity\LessonStatusInterface; use Drupal\lms\Form\AnswerFormTrait; use Drupal\lms\TrainingManager; -use Drupal\lms_xapi\TincanService; +use Drupal\lms_xapi\XapiService; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -24,7 +24,7 @@ final class XapiLessonForm extends FormBase { */ public function __construct( private readonly TrainingManager $trainingManager, - private readonly TincanService $tincan, + private readonly XapiService $xapi, ) {} /** @@ -33,7 +33,7 @@ final class XapiLessonForm extends FormBase { public static function create(ContainerInterface $container) { return new static( $container->get('lms.training_manager'), - $container->get('lms_xapi.tincan') + $container->get(XapiService::class) ); } @@ -74,7 +74,7 @@ final class XapiLessonForm extends FormBase { // Update lesson score from LRS. $lesson = $lesson_status->getLesson(); - $lrs_score = $this->tincan->getScoreFromLrs( + $lrs_score = $this->xapi->getScoreFromLrs( $lesson_status->getCourseStatus()->getUserId(), 'lms_lesson', $lesson->id() diff --git a/modules/lrs_xapi/src/Controller/LrsXapiEndpoint.php b/modules/lrs_xapi/src/Controller/LrsXapiEndpoint.php index 334d36df517172fcb4c62547b0b31de20e4f4e29..85d6c955eae9265512746c27e429e1bdfa764990 100644 --- a/modules/lrs_xapi/src/Controller/LrsXapiEndpoint.php +++ b/modules/lrs_xapi/src/Controller/LrsXapiEndpoint.php @@ -84,7 +84,6 @@ final class LrsXapiEndpoint implements ContainerInjectionInterface { */ public function statements(Request $request): Response { $this->authorize($request); - if ($request->getMethod() === 'PUT') { $statement_data = $request->getContent(); $this->statementStorage->setStatement($statement_data); @@ -112,7 +111,7 @@ final class LrsXapiEndpoint implements ContainerInjectionInterface { else { $this->logRequest($request); } - return new Response('OK'); + return new Response('OK', 200); } /** diff --git a/src/Form/LmsXapiSettingsForm.php b/src/Form/LmsXapiSettingsForm.php index c23e590d14aebe864929e1534126659969984290..08b9c1fa4104194a31e17252ac77d756a611d364 100644 --- a/src/Form/LmsXapiSettingsForm.php +++ b/src/Form/LmsXapiSettingsForm.php @@ -7,7 +7,7 @@ namespace Drupal\lms_xapi\Form; use Drupal\Core\Form\ConfigFormBase; use Drupal\Core\Form\ConfigTarget; use Drupal\Core\Form\FormStateInterface; -use Drupal\lms_xapi\TincanService; +use Drupal\lms_xapi\XapiService; /** * LMS Xapi settings form class. @@ -25,7 +25,7 @@ final class LmsXapiSettingsForm extends ConfigFormBase { * {@inheritdoc} */ protected function getEditableConfigNames() { - return [TincanService::CONFIG_NAME]; + return [XapiService::CONFIG_NAME]; } /** @@ -35,19 +35,19 @@ final class LmsXapiSettingsForm extends ConfigFormBase { $form['endpoint'] = [ '#type' => 'textfield', '#title' => $this->t('LRS API endpoint'), - '#config_target' => new ConfigTarget(TincanService::CONFIG_NAME, 'endpoint'), + '#config_target' => new ConfigTarget(XapiService::CONFIG_NAME, 'endpoint'), '#required' => TRUE, ]; $form['username'] = [ '#type' => 'textfield', '#title' => $this->t('LRS API user name'), - '#config_target' => new ConfigTarget(TincanService::CONFIG_NAME, 'username'), + '#config_target' => new ConfigTarget(XapiService::CONFIG_NAME, 'username'), '#required' => TRUE, ]; $form['password'] = [ '#type' => 'password', '#title' => $this->t('LRS API password'), - '#config_target' => new ConfigTarget(TincanService::CONFIG_NAME, 'password'), + '#config_target' => new ConfigTarget(XapiService::CONFIG_NAME, 'password'), '#required' => TRUE, ]; diff --git a/src/Plugin/Field/FieldFormatter/XapiFieldFormatter.php b/src/Plugin/Field/FieldFormatter/XapiFieldFormatter.php index 31f9120c179a16dd219a4b78b667f5c286867f38..84848e9d42b381504bcf03e74f06239a42b7e82d 100644 --- a/src/Plugin/Field/FieldFormatter/XapiFieldFormatter.php +++ b/src/Plugin/Field/FieldFormatter/XapiFieldFormatter.php @@ -4,20 +4,15 @@ declare(strict_types=1); namespace Drupal\lms_xapi\Plugin\Field\FieldFormatter; -use Drupal\Component\Serialization\Json; use Drupal\Core\Cache\CacheableMetadata; -use Drupal\Core\Config\ImmutableConfig; use Drupal\Core\Field\Attribute\FieldFormatter; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\FormatterBase; -use Drupal\Core\File\FileUrlGeneratorInterface; use Drupal\Core\Session\AccountInterface; -use Drupal\Core\Site\Settings; use Drupal\Core\StringTranslation\TranslatableMarkup; -use Drupal\Core\Url; use Drupal\lms_xapi\Plugin\Field\FieldType\XapiItem; -use Drupal\lms_xapi\TincanService; +use Drupal\lms_xapi\XapiService; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -42,10 +37,8 @@ final class XapiFieldFormatter extends FormatterBase { $label, $view_mode, array $third_party_settings, - protected readonly FileUrlGeneratorInterface $fileUrlGenerator, - protected readonly ImmutableConfig $config, protected readonly AccountInterface $currentUser, - protected readonly TincanService $tincan, + protected readonly XapiService $xapi, ) { parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $label, $view_mode, $third_party_settings); } @@ -55,10 +48,8 @@ final class XapiFieldFormatter extends FormatterBase { */ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { return new static($plugin_id, $plugin_definition, $configuration['field_definition'], $configuration['settings'], $configuration['label'], $configuration['view_mode'], $configuration['third_party_settings'], - $container->get('file_url_generator'), - $container->get('config.factory')->get('lms_xapi.settings'), - $container->get('current_user'), - $container->get('lms_xapi.tincan'), + $container->get(AccountInterface::class), + $container->get(XapiService::class), ); } @@ -79,54 +70,17 @@ final class XapiFieldFormatter extends FormatterBase { return $no_cache_return; } - $path = $item->getPackagePath(); - $tincan_file = $path . '/tincan.xml'; - if (!\file_exists($tincan_file)) { - return $no_cache_return; - } - - $config = [ - 'endpoint' => $this->config->get('endpoint'), - ]; - if (Settings::get('lms_xapi_disable_auth') !== TRUE) { - $config += [ - 'username' => $this->config->get('username'), - 'password' => $this->config->get('password'), - ]; - } - foreach ($config as $value) { - if ($value === NULL || $value === '') { - return $no_cache_return; - } - } - - $entity = $item->getEntity(); - - $xml = new \SimpleXMLElement(\file_get_contents($tincan_file)); - $launch_file = $path . '/' . $xml->activities->activity->launch; - $launch_url = $this->fileUrlGenerator->generateAbsoluteString($launch_file); - - // Support relative URLs. - if (!\str_starts_with($config['endpoint'], 'http')) { - $config['endpoint'] = Url::fromRoute('<front>', [], [ - 'absolute' => TRUE, - ])->toString() . $config['endpoint']; - } $cacheable_metadata = new CacheableMetadata(); - $lms_id = $this->tincan->getLmsId($entity, $this->currentUser, $cacheable_metadata); - $query_args = [ - 'endpoint' => $config['endpoint'], - 'actor' => Json::encode([ - 'mbox_sha1sum' => sha1('mailto:' . $this->currentUser->getEmail()), - 'name' => $this->currentUser->getAccountName(), - ]), - 'registration' => $this->tincan->getLrsUuid($lms_id), - ]; - if (Settings::get('lms_xapi_disable_auth') !== TRUE) { - $query_args['auth'] = 'Basic ' . \base64_encode($config['username'] . ':' . $config['password']); + $launch_url = $this->xapi->getLaunchUrl( + $item->getPackagePath(), + $item->getEntity(), + $this->currentUser, + $cacheable_metadata + ); + if ($launch_url === NULL) { + return $no_cache_return; } - $launch_url .= '?' . \http_build_query($query_args); $build = [ '#type' => 'html_tag', diff --git a/src/TincanService.php b/src/XapiService.php similarity index 58% rename from src/TincanService.php rename to src/XapiService.php index 8c540fd37f6170e33bfac76eb834b045abbc7820..3f43e9d0f2e904895e99eaa810f8e00daf5f5a1d 100644 --- a/src/TincanService.php +++ b/src/XapiService.php @@ -4,11 +4,16 @@ declare(strict_types=1); namespace Drupal\lms_xapi; +use Drupal\Component\Serialization\Json; use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Database\DatabaseException; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\File\FileUrlGeneratorInterface; +use Drupal\Core\Logger\LoggerChannelInterface; use Drupal\Core\Session\AccountInterface; +use Drupal\Core\Site\Settings; use Drupal\Core\Url; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\AutowireCallable; use Symfony\Component\DependencyInjection\Attribute\AutowireIterator; use TinCan\RemoteLRS; @@ -18,7 +23,7 @@ use TinCan\Verb; /** * Tincan API integration utilities. */ -final class TincanService { +final class XapiService { public const CONFIG_NAME = 'lms_xapi.settings'; @@ -28,6 +33,9 @@ final class TincanService { private \Closure $getConfig, #[AutowireIterator('xapi_id_generator')] private iterable $xapiIdGenerators, + private readonly FileUrlGeneratorInterface $fileUrlGenerator, + #[Autowire(service: 'logger.channel.lms_xapi', lazy: TRUE)] + private readonly LoggerChannelInterface $logger, ) {} /** @@ -124,7 +132,7 @@ final class TincanService { if (!\str_starts_with($settings['endpoint'], 'http')) { $settings['endpoint'] = Url::fromRoute('<front>', [], [ 'absolute' => TRUE, - ])->toString() . $settings['endpoint']; + ])->toString() . \ltrim($settings['endpoint'], '/'); } // @todo This logic is taken from opigno_tincan_activity module, should be @@ -143,32 +151,38 @@ final class TincanService { $verb_passed = new Verb(); $verb_passed->setId('http://adlnet.gov/expapi/verbs/passed'); - $result = $lrs->queryStatements([ - 'registration' => $uuid, - 'verb' => $verb_passed, - 'limit' => 1, - ]); - - $statements = []; - if (!empty($result->content) && is_object($result->content)) { - $statements = $result->content->getStatements(); - - // If nothing with "passed", test with "failed" verb. - if (count($statements) === 0) { - $verb_failed = new Verb(); - $verb_failed->setId('http://adlnet.gov/expapi/verbs/failed'); - - $result = $lrs->queryStatements([ - 'registration' => $uuid, - 'verb' => $verb_failed, - 'limit' => 1, - ]); - - if (!empty($result->content) && is_object($result->content)) { - $statements = $result->content->getStatements(); + try { + $result = $lrs->queryStatements([ + 'registration' => $uuid, + 'verb' => $verb_passed, + 'limit' => 1, + ]); + + $statements = []; + if (!empty($result->content) && is_object($result->content)) { + $statements = $result->content->getStatements(); + + // If nothing with "passed", test with "failed" verb. + if (count($statements) === 0) { + $verb_failed = new Verb(); + $verb_failed->setId('http://adlnet.gov/expapi/verbs/failed'); + + $result = $lrs->queryStatements([ + 'registration' => $uuid, + 'verb' => $verb_failed, + 'limit' => 1, + ]); + + if (!empty($result->content) && is_object($result->content)) { + $statements = $result->content->getStatements(); + } } } } + catch (\Exception $e) { + $this->logger->error($e->getMessage()); + return NULL; + } if (\count($statements) === 0) { return NULL; @@ -207,4 +221,68 @@ final class TincanService { return NULL; } + /** + * Get package launch URL. + */ + public function getLaunchUrl( + string $package_path, + EntityInterface $entity, + AccountInterface $account, + CacheableMetadata $cacheable_metadata, + ): ?string { + $tincan_file = $package_path . '/tincan.xml'; + if (!\file_exists($tincan_file)) { + $this->logger->error(\sprintf('Xapi package file missing: %s.', $tincan_file)); + return NULL; + } + $tincan_file_contents = \file_get_contents($tincan_file); + if ($tincan_file_contents === FALSE) { + $this->logger->error(\sprintf('Unable to get contents of %s file.', $tincan_file)); + return NULL; + } + + $config = ($this->getConfig)(self::CONFIG_NAME); + $launch_config = [ + 'endpoint' => $config->get('endpoint'), + ]; + if (Settings::get('lms_xapi_disable_auth') !== TRUE) { + $launch_config += [ + 'username' => $config->get('username'), + 'password' => $config->get('password'), + ]; + } + foreach ($launch_config as $key => $value) { + if ($value === NULL || $value === '') { + $this->logger->error(\sprintf('Missing Xapi config: %s.', $key)); + return NULL; + } + } + + $xml = new \SimpleXMLElement($tincan_file_contents); + $launch_file = $package_path . '/' . $xml->activities->activity->launch; + $launch_url = $this->fileUrlGenerator->generateAbsoluteString($launch_file); + + // Support relative URLs. + if (!\str_starts_with($launch_config['endpoint'], 'http')) { + $launch_config['endpoint'] = Url::fromRoute('<front>', [], [ + 'absolute' => TRUE, + ])->toString() . \ltrim($launch_config['endpoint'], '/'); + } + $lms_id = $this->getLmsId($entity, $account, $cacheable_metadata); + + $query_args = [ + 'endpoint' => $launch_config['endpoint'], + 'actor' => Json::encode([ + 'mbox_sha1sum' => sha1('mailto:' . $account->getEmail()), + 'name' => $account->getAccountName(), + ]), + 'registration' => $this->getLrsUuid($lms_id), + ]; + if (Settings::get('lms_xapi_disable_auth') !== TRUE) { + $query_args['auth'] = 'Basic ' . \base64_encode($launch_config['username'] . ':' . $launch_config['password']); + } + $launch_url .= '?' . \http_build_query($query_args); + return $launch_url; + } + }