diff options
| author | Moritz Strohm <strohm@data-quest.de> | 2025-04-24 10:48:24 +0000 |
|---|---|---|
| committer | Moritz Strohm <strohm@data-quest.de> | 2025-04-24 10:48:24 +0000 |
| commit | cd8222ba049eca136bb443410022d99dfbc5d0f2 (patch) | |
| tree | 99b46288e335427b3f3bde5555b56497a80c1b88 | |
| parent | 3c783c028c229a3e6561500521800e1fac4383ba (diff) | |
distinguish between LTI deployment IDs and LTI resource links in the database, fixes #5330
Closes #5330
Merge request studip/studip!4045
| -rw-r--r-- | app/controllers/course/lti.php | 183 | ||||
| -rw-r--r-- | app/controllers/lti/tool.php | 199 | ||||
| -rw-r--r-- | app/views/admin/lti/index.php | 18 | ||||
| -rw-r--r-- | app/views/course/lti/consent.php | 14 | ||||
| -rw-r--r-- | app/views/course/lti/iframe.php | 6 | ||||
| -rw-r--r-- | app/views/course/lti/index.php | 52 | ||||
| -rw-r--r-- | app/views/course/lti/select_tool.php | 14 | ||||
| -rw-r--r-- | app/views/lti/_tool_form_fields.php | 204 | ||||
| -rw-r--r-- | app/views/lti/_tool_info.php | 19 | ||||
| -rw-r--r-- | db/migrations/6.0.48_add_lti_resource_links_table.php | 61 | ||||
| -rw-r--r-- | lib/classes/LTI13a/LineItemRepository.php | 32 | ||||
| -rw-r--r-- | lib/models/Grading/Definition.php | 27 | ||||
| -rw-r--r-- | lib/models/LtiDeployment.php | 52 | ||||
| -rw-r--r-- | lib/models/LtiResourceLink.php | 187 | ||||
| -rw-r--r-- | lib/models/LtiTool.php | 2 | ||||
| -rw-r--r-- | lib/modules/LtiToolModule.php | 6 |
16 files changed, 669 insertions, 407 deletions
diff --git a/app/controllers/course/lti.php b/app/controllers/course/lti.php index 1560f6d..d167a9e 100644 --- a/app/controllers/course/lti.php +++ b/app/controllers/course/lti.php @@ -76,14 +76,18 @@ class Course_LtiController extends StudipController */ public function index_action() { - $this->lti_data_array = []; + $this->links = []; if ($this->edit_perm) { - $this->lti_data_array = LtiDeployment::findByCourse_id($this->course_id, 'ORDER BY position'); + $this->links = \LtiResourceLink::findByCourse_id($this->course_id, 'ORDER BY `position`'); } else { - //Only load those deployments that are fully configured: - $this->lti_data_array = LtiDeployment::findBySQL( - "`course_id` = :course_id AND (`options` IS NULL OR `options` NOT LIKE '%unfinished_deep_linking%') - ORDER BY `position`", + //Only load those LTI resource links that are fully configured: + $this->links = \LtiResourceLink::findBySQL( + "JOIN `lti_deployments` + ON `lti_deployments`.`id` = `lti_resource_links`.`deployment_id` + WHERE + `lti_resource_links`.`course_id` = :course_id + AND (`lti_deployments`.`options` IS NULL OR `lti_deployments`.`options` NOT LIKE '%unfinished_deep_linking%') + ORDER BY `lti_resource_links`.`position`", ['course_id' => $this->course_id] ); } @@ -131,10 +135,13 @@ class Course_LtiController extends StudipController public function select_tool_action() { //The permission check is done in the before filter. + $this->global_tool_deployments = LtiDeployment::findBySQL( + "JOIN `lti_tools` + ON `lti_deployments`.`tool_id` = `lti_tools`.`id` + WHERE `lti_tools`.`lti_version` = '1.3a' AND `lti_tools`.`range_id` = 'global' ORDER BY `lti_tools`.`name` ASC" + ); - $this->global_tools = LtiTool::findBySQL("`lti_version` = '1.3a' AND `range_id` = 'global' ORDER BY `name` ASC"); - - if (!$this->global_tools) { + if (!$this->global_tool_deployments) { if (!Config::get()->LTI_ALLOW_TOOL_CONFIG_IN_COURSE) { PageLayout::postError(_('Es sind keine globalen LTI-Tools konfiguriert, die in dieser Veranstaltung eingebunden werden können.')); return; @@ -143,10 +150,10 @@ class Course_LtiController extends StudipController $this->redirect('lti/tool/add/' . $this->course->id); } - $this->selected_tool_id = ''; - if (count($this->global_tools) >= 1) { + $this->selected_deployment_id = ''; + if (count($this->global_tool_deployments) >= 1) { //Preselect the first tool: - $this->selected_tool_id = $this->global_tools[0]->id; + $this->selected_deployment_id = $this->global_tool_deployments[0]->id; } } @@ -154,40 +161,50 @@ class Course_LtiController extends StudipController { if (Request::isPost()) { CSRFProtection::verifyUnsafeRequest(); - $selected_tool_id = Request::get('selected_tool_id'); - if ($selected_tool_id === 'new') { + $selected_deployment_id = Request::get('selected_deployment_id'); + if ($selected_deployment_id === 'new') { //Redirect to the page to configure an LTI tool for the course: $this->redirect('lti/tool/add/' . $this->course->id); } else { - //Load the selected tool and check if it can be used in the course. - $selected_tool = LtiTool::find($selected_tool_id); - if (!$selected_tool || $selected_tool->range_id !== 'global') { + //Load the selected deployment and check if it can be used in the course. + $selected_deployment = LtiDeployment::find($selected_deployment_id); + if (!$selected_deployment || $selected_deployment->tool->range_id !== 'global') { PageLayout::postError(_('Das ausgewählte LTI-Tool kann nicht genutzt werden.')); $this->redirect('course/lti/select_tool'); return; } - $this->redirect('lti/tool/add/' . $this->course->id . '/' . $selected_tool->id); + //Link the tool in the course: + $link = new \LtiResourceLink(); + $link->deployment_id = $selected_deployment->id; + $link->course_id = $this->course->id; + if ($link->store()) { + PageLayout::postSuccess(_('Das LTI-Tool wurde eingebunden.')); + } else { + PageLayout::postError(_('Das LTI-Tool konnte nicht eingebunden werden.')); + } + $this->relocate('course/lti', ['cid' => $this->course->id]); } } else { $this->redirect('course/lti/select_tool'); } } - public function consent_action(string $deployment_id) + public function consent_action(string $link_id) { - $this->deployment = LtiDeployment::find($deployment_id); - if (!$this->deployment) { + $this->resource_link = \LtiResourceLink::find($link_id); + if (!$this->resource_link) { PageLayout::postError(_('Die Einbindung eines LTI-Tools ist ungültig.')); return; } + $tool_id = $this->resource_link->deployment->tool_id; $this->privacy_settings = LtiToolPrivacySettings::findOneBySQL( 'tool_id = :tool_id AND user_id = :user_id', - ['tool_id' => $this->deployment->tool_id, 'user_id' => $GLOBALS['user']->id] + ['tool_id' => $tool_id, 'user_id' => $GLOBALS['user']->id] ); if (!$this->privacy_settings) { $this->privacy_settings = new LtiToolPrivacySettings(); - $this->privacy_settings->tool_id = $this->deployment->tool_id; + $this->privacy_settings->tool_id = $tool_id; $this->privacy_settings->user_id = $GLOBALS['user']->id; } @@ -219,7 +236,7 @@ class Course_LtiController extends StudipController $this->response->add_header('X-Dialog-Close', '1'); } elseif (Request::submitted('redirect_to_tool') && Request::submitted('save')) { //Redirect to the tool launch action, but only after the privacy settings have been saved: - $this->redirect('course/lti/iframe/' . $this->deployment->id); + $this->redirect('course/lti/iframe/' . $this->resource_link->id); } else { //Redirect to the LTI tool page of the course: $this->redirect('course/lti/index'); @@ -230,55 +247,47 @@ class Course_LtiController extends StudipController /** * Display the launch form for a tool as an iframe. */ - public function iframe_action(string $deployment_id) + public function iframe_action(string $link_id) { - $this->deployment = LtiDeployment::find($deployment_id); + $this->resource_link = \LtiResourceLink::find($link_id); $this->show_data_protection_info = !LtiToolPrivacySettings::countBySQL( "`tool_id` = :tool_id AND `user_id` = :user_id AND `accepted` = 1", - ['tool_id' => $this->deployment->tool_id, 'user_id' => $GLOBALS['user']->id] + ['tool_id' => $this->resource_link->deployment->tool_id, 'user_id' => $GLOBALS['user']->id] ); if ($this->show_data_protection_info) { - $this->redirect('course/lti/consent/' . $deployment_id, ['redirect_to_tool' => '1']); + $this->redirect('course/lti/consent/' . $this->resource_link->deployment_id, ['redirect_to_tool' => '1']); return; } if (!$this->show_data_protection_info) { //Redirect to the tool. $this->lti13a_mode = false; - $lti_version = $this->deployment->getToolLtiVersion(); + $lti_version = $this->resource_link->deployment->getToolLtiVersion(); if ($lti_version === '1.3a') { //LTI 1.3a $this->lti13a_mode = true; - $lti_resource_link = new LtiResourceLink( - $this->deployment->tool_id . '_' . $this->deployment->id . '_' . $this->course_id, - [ - 'url' => $this->deployment->getLaunchURL(), - 'title' => $this->deployment->title - ] - ); - - $return_url = !isset($this->deployment->options['document_target']) ? - URLHelper::getURL($GLOBALS['ABSOLUTE_URI_STUDIP'] . 'dispatch.php/course/lti', ['deployment_id' => $deployment_id]) : ''; - $document_target = isset($this->deployment->options['document_target']) ? 'iframe' : 'window'; + $return_url = !isset($this->resource_link->deployment->options['document_target']) ? + URLHelper::getURL($GLOBALS['ABSOLUTE_URI_STUDIP'] . 'dispatch.php/course/lti', ['deployment_id' => $this->resource_link->deployment_id]) : ''; + $document_target = isset($this->resource_link->deployment->options['document_target']) ? 'iframe' : 'window'; $locale = str_replace('_', '-', $_SESSION['_language']); - $registration = new Registration($this->deployment->tool); + $registration = new Registration($this->resource_link->deployment->tool); $builder = new LtiResourceLinkLaunchRequestBuilder(); //The AGS URLs need several parameters: $ags_url_parameters = [ 'cid' => $this->course_id, - 'tool_id' => $this->deployment->tool_id, - 'deployment_id' => $this->deployment->id, + 'tool_id' => $this->resource_link->deployment->tool_id, + 'deployment_id' => $this->resource_link->deployment_id, 'cancel_login' => '1' ]; //Build the message: $this->message = $builder->buildLtiResourceLinkLaunchRequest( - $lti_resource_link, + $this->resource_link, $registration, $GLOBALS['user']->id, - $this->deployment->id, + $this->resource_link->deployment_id, [ PlatformManager::getLtiRoleClaimForStudipRole($GLOBALS['perm']->get_studip_perm($this->course_id)) ], @@ -307,11 +316,12 @@ class Course_LtiController extends StudipController $this->url_for('lti/ags/line_item', $ags_url_parameters) ) ], - $this->deployment->getCustomLtiParameterArray(), + $this->resource_link->deployment->getCustomLtiParameterArray(), ) ); } else { //LTI 1.0/1.1 + $this->deployment = $this->resource_link->deployment; $lti_link = $this->getLtiLink($this->deployment); $this->launch_url = $this->deployment->getLaunchURL(); $this->launch_data = $lti_link->getBasicLaunchData(); @@ -356,18 +366,18 @@ class Course_LtiController extends StudipController } /** - * Moves an LTI deployment in a course either up or down. + * Moves an LTI resource link up or down in a course. * - * @param string $deployment_id The ID of the deployment to be moved. + * @param string $link_id The ID of the resource link to be moved. * * @param string $direction 'up' for moving the deployment upwards or 'down' for downwards. */ - public function move_action(string $deployment_id, string $direction) + public function move_action(string $link_id, string $direction) { CSRFProtection::verifyUnsafeRequest(); - $deployment = LtiDeployment::find($deployment_id); - if (!$deployment) { + $link = \LtiResourceLink::find($link_id); + if (!$link) { //Redirect and do nothing: $this->redirect('course/lti'); return; @@ -376,19 +386,19 @@ class Course_LtiController extends StudipController $new_position = 0; if ($direction === 'up') { - $new_position = $deployment->position - 1; + $new_position = $link->position - 1; } else { - $new_position = $deployment->position + 1; + $new_position = $link->position + 1; } //Find the deployment with the new position: - $other_deployment = LtiDeployment::findByCourseAndPosition($this->course_id, $new_position); - if ($other_deployment) { - $other_deployment->position = $deployment->position; - $other_deployment->store(); + $other_link = \LtiResourceLink::findByCourseAndPosition($this->course_id, $new_position); + if ($other_link) { + $other_link->position = $link->position; + $other_link->store(); } - $deployment->position = $new_position; - $deployment->store(); + $link->position = $new_position; + $link->store(); $this->redirect('course/lti'); } @@ -402,8 +412,8 @@ class Course_LtiController extends StudipController { CSRFProtection::verifyUnsafeRequest(); - $deployment = LtiDeployment::findByCourseAndPosition($this->course_id, $position); - $deployment->delete(); + $link = \LtiResourceLink::findByCourseAndPosition($this->course_id, $position); + $link->delete(); PageLayout::postSuccess(_('Der Abschnitt wurde gelöscht.')); $this->redirect('course/lti'); @@ -600,16 +610,15 @@ class Course_LtiController extends StudipController if (count($lti_resource_links) > 0) { $use_first_link = true; foreach ($lti_resource_links as $lti_resource_link) { - $deployment = null; - if ($use_first_link) { - //Recycle the deployment that has been created before - //for the course. - $deployment = LtiDeployment::findOneBySQL( - "`tool_id` = :tool_id AND `course_id` = :course_id - AND `options` LIKE '%unfinished_deep_linking%=%true'" + $deployment = LtiDeployment::findOneBySQL( + "JOIN `lti_resource_links` + ON `lti_deployments`.`id` = `lti_resource_links`.`deployment_id` + WHERE + `lti_deployments`.`tool_id` = :tool_id AND `lti_resource_links`.`course_id` = :course_id + AND `lti_deployments`.`options` LIKE '%unfinished_deep_linking%=%true'", + ['tool_id' => $this->tool->id, 'course_id' => $this->range_id] ); $use_first_link = false; - } if (!$deployment) { //If this is the first link, the deployment has been removed. //In that case and if it is not the first link, a new deployment @@ -617,13 +626,17 @@ class Course_LtiController extends StudipController $deployment = new LtiDeployment(); $deployment->tool_id = $tool->id; $deployment->title = $tool->name; - $deployment->course_id = $this->course_id; } $deployment->launch_url = $lti_resource_link->getUrl(); if (!empty($deployment->options['unfinished_deep_linking'])) { unset($deployment->options['unfinished_deep_linking']); } - $deployment->store(); + if ($deployment->store()) { + $link = new \LtiResourceLink(); + $link->deployment_id = $deployment->id; + $link->course_id = $this->range_id; + $link->store(); + } } } } else { @@ -632,17 +645,15 @@ class Course_LtiController extends StudipController $content_items = Request::get('content_items'); $content_items = json_decode($content_items, true); - if (!Studip\OAuth1::verifyRequest($this->getPsrRequest(), $tool->consumer_secret, '')) { - throw new Exception('Could not verify request.'); - } + if (!Studip\OAuth1::verifyRequest($this->getPsrRequest(), $tool->consumer_secret, '')) { + throw new Exception('Could not verify request.'); + } if (is_array($content_items) && count($content_items['@graph'])) { // we only support selecting a single content item $item = $content_items['@graph'][0]; $lti_data = new LtiDeployment(); - $lti_data->course_id = $this->course_id; - $lti_data->position = LtiDeployment::countBySQL('course_id = ?', [$this->course_id]); $lti_data->title = (string) $item['title']; $lti_data->description = Studip\Markup::purifyHtml(Studip\Markup::markAsHtml($item['text'])); $lti_data->tool_id = $tool_id; @@ -662,6 +673,11 @@ class Course_LtiController extends StudipController $lti_data->options = $options; $lti_data->store(); + $link = new \LtiResourceLink(); + $link->deployment_id = $lti_data->id; + $link->course_id = $this->course_id; + $link->position = \LtiResourceLink::countBySQL('course_id = ?', [$this->course_id]); + $link->store(); PageLayout::postSuccess($lti_msg ?: _('Der Link wurde als neuer Abschnitt hinzugefügt.')); } } @@ -864,12 +880,21 @@ class Course_LtiController extends StudipController Navigation::activateItem('/course/lti/grades'); if ($this->edit_perm) { - $this->lti_data_array = LtiDeployment::findByCourse_id($this->course_id, 'ORDER BY position'); + $this->lti_data_array = LtiDeployment::findBySQL( + "JOIN `lti_resource_links` + ON `lti_deployments`.`id` = `lti_resource_links`.`deployment_id` + WHERE `lti_resource_links`.`course_id` = :course_id + ORDER BY `lti_resource_links`.`position`", + ['course_id' => $this->course_id] + ); } else { //Only load those deployments that are fully configured: $this->lti_data_array = LtiDeployment::findBySQL( - "`course_id` = :course_id AND (`options` IS NULL OR `options` NOT LIKE '%unfinished_deep_linking%') - ORDER BY `position`", + "JOIN `lti_resource_links` + ON `lti_deployments`.`id` = `lti_resource_links`.`deployment_id` + WHERE `lti_resource_links`.`course_id` = :course_id + AND (`lti_deployments`.`options` IS NULL OR `lti_deployments`.`options` NOT LIKE '%unfinished_deep_linking%') + ORDER BY `lti_resource_links`.`position`", ['course_id' => $this->course_id] ); } diff --git a/app/controllers/lti/tool.php b/app/controllers/lti/tool.php index 0e7077e..fae276e 100644 --- a/app/controllers/lti/tool.php +++ b/app/controllers/lti/tool.php @@ -33,10 +33,13 @@ class Lti_ToolController extends AuthenticatedController public function index_action($range_id, $tool_id): void { - //$this->tool is created in the before-filter. + //$this->range_id and $this->tool are created in the before-filter. if ($this->range_id !== 'global') { $this->deployment = LtiDeployment::findOneBySQL( - '`tool_id` = :tool_id AND `course_id` = :range_id', + 'JOIN `lti_resource_links` + ON `lti_deployments`.`id` = `lti_resource_links`.`deployment_id` + WHERE + `lti_deployments`.`tool_id` = :tool_id AND `lti_resource_links`.`course_id` = :range_id', ['tool_id' => $this->tool->id, 'range_id' => $this->range_id] ); } @@ -59,7 +62,6 @@ class Lti_ToolController extends AuthenticatedController if (!$this->tool) { return; } - $this->deployment = null; if ($this->tool->isNew()) { if (!Config::get()->LTI_ALLOW_TOOL_CONFIG_IN_COURSE && $this->range_id !== 'global') { throw new AccessDeniedException( @@ -70,21 +72,14 @@ class Lti_ToolController extends AuthenticatedController throw new AccessDeniedException(); } PageLayout::postWarning(_('Bitte beachten Sie das geltende europäische Datenschutzrecht (DSGVO)!')); - if ($this->tool->range_id !== 'global') { - $this->deployment = new LtiDeployment(); - $this->deployment->course_id = $this->tool->range_id; - } - } elseif ($this->range_id !== 'global') { + } elseif (!$this->tool->isEditableByUser()) { + throw new AccessDeniedException(); + } else { + //The tool is old and editable by the user. Check if a deployment exists and load it. $this->deployment = LtiDeployment::findOneBySQL( - '`tool_id` = :tool_id AND `course_id` = :range_id', - ['tool_id' => $this->tool->id, 'range_id' => $this->range_id] + "`tool_id` = :tool_id ORDER BY `mkdate` ASC", + ['tool_id' => $this->tool->id] ); - if (!$this->deployment) { - //Create a new deployment: - $this->deployment = new LtiDeployment(); - $this->deployment->tool_id = $this->tool->id; - $this->deployment->course_id = $this->range_id; - } } if (Request::isPost()) { @@ -98,81 +93,95 @@ class Lti_ToolController extends AuthenticatedController protected function saveTool(): void { CSRFProtection::verifyUnsafeRequest(); - if ($this->range_id === 'global') { - //The admin page for editing global tools. - $this->tool->name = trim(Request::get('name')); - $this->tool->launch_url = trim(Request::get('launch_url')); + //Note: $this->tool is created in the before_filter. + $new_tool = $this->tool->isNew(); + $this->tool->name = trim(Request::get('name')); + $this->tool->launch_url = trim(Request::get('launch_url')); + $this->tool->terms_of_use_url = trim(Request::get('terms_of_use_url')); + $this->tool->privacy_policy_url = trim(Request::get('privacy_policy_url')); + $this->tool->data_protection_notes = trim(Request::get('data_protection_notes')); + $this->tool->lti_version = Request::get('lti_version', '1.3a'); + if ($this->tool->lti_version === '1.3a') { + $this->tool->oauth_signature_method = 'sha256'; + $this->tool->oidc_init_url = trim(Request::get('oidc_init_url')); + $this->tool->jwks_url = trim(Request::get('jwks_url')); + $this->tool->jwks_key_id = trim(Request::get('jwks_key_id')); + $this->tool->deep_linking_url = trim(Request::get('deep_linking_url')); + $this->tool->deep_linking = (bool) $this->tool->deep_linking_url; } else { - //The page for editing tools configured in courses. - $this->deployment->title = trim(Request::get('name')); - $this->deployment->description = trim(Request::get('description')); - $this->deployment->launch_url = trim(Request::get('launch_url')); - $document_target = trim(Request::get('document_target')); - if ($document_target === 'iframe') { - if (!is_array($this->deployment->options)) { - $this->deployment->options = []; - } - $this->deployment->options['document_target'] = $document_target; - } elseif (isset($this->deployment->options['document_target'])) { - unset($this->deployment->options['document_target']); - } + //LTI 1.0/1.1: + $this->tool->oauth_signature_method = 'sha1'; + $this->tool->consumer_key = trim(Request::get('consumer_key')); + $this->tool->consumer_secret = trim(Request::get('consumer_secret')); } + $this->tool->send_lis_person = Request::int('send_lis_person', 0); + $this->tool->custom_parameters = trim(Request::get('custom_parameters')); + $tool_public_key = trim(Request::get('tool_public_key')); - //If a deployment is present, the tool is not used in the global context. - //If a tool is not used in the global context and the range_id is not set to "global", - //it is a tool that is only used for one course. - if ( - !$this->deployment - || $this->tool->range_id !== 'global' - || $GLOBALS['perm']->have_perm('root') - ) { - $this->tool->name = trim(Request::get('name')); - $this->tool->launch_url = trim(Request::get('launch_url')); - $this->tool->terms_of_use_url = trim(Request::get('terms_of_use_url')); - $this->tool->privacy_policy_url = trim(Request::get('privacy_policy_url')); - $this->tool->data_protection_notes = trim(Request::get('data_protection_notes')); - $this->tool->lti_version = Request::get('lti_version', '1.3a'); - if ($this->tool->lti_version === '1.3a') { - $this->tool->oauth_signature_method = 'sha256'; - $this->tool->oidc_init_url = trim(Request::get('oidc_init_url')); - $this->tool->jwks_url = trim(Request::get('jwks_url')); - $this->tool->jwks_key_id = trim(Request::get('jwks_key_id')); - $this->tool->deep_linking_url = trim(Request::get('deep_linking_url')); - $this->tool->deep_linking = (bool) $this->tool->deep_linking_url; - } else { - //LTI 1.0/1.1: - $this->tool->oauth_signature_method = 'sha1'; - $this->tool->consumer_key = trim(Request::get('consumer_key')); - $this->tool->consumer_secret = trim(Request::get('consumer_secret')); - } - $this->tool->send_lis_person = Request::int('send_lis_person', 0); - $this->tool->custom_parameters = trim(Request::get('custom_parameters')); - $tool_public_key = trim(Request::get('tool_public_key')); - $errors = $this->tool->validate(); - if ($errors) { - PageLayout::postError( - _('Die folgenden Daten zum LTI-Tool sind fehlerhaft:'), - array_map('htmlReady', $errors) - ); - return; - } - if ($this->tool->lti_version === '1.3a' && !$tool_public_key && !$this->tool->jwks_url) { - PageLayout::postError( - _('Es wurde weder ein öffentlicher Schlüssel noch eine JWKS-URL zum Tool angegeben.') - ); - return; + //Check if the tool has a deployment. If so, use it. Otherwise, create a new deployment. + if (!$new_tool) { + $this->deployment = LtiDeployment::findOneBySQL( + "`tool_id` = :tool_id ORDER BY `mkdate` ASC", + ['tool_id' => $this->tool->id] + ); + } + if (!$this->deployment) { + $this->deployment = new LtiDeployment(); + if (!$new_tool) { + $this->deployment->tool_id = $this->tool->id; } - if ($this->tool->store() !== false) { - if ($this->deployment) { - $this->deployment->tool_id = $this->tool->id; - } - } else { - PageLayout::postError(_('Das LTI-Tool konnte nicht gespeichert werden.')); - return; + } + $this->deployment->description = trim(Request::get('description')); + $this->deployment->title = $this->tool->name; + $this->deployment->launch_url = $this->tool->launch_url; + $document_target = trim(Request::get('document_target')); + if ($document_target === 'iframe') { + if (!is_array($this->deployment->options)) { + $this->deployment->options = []; } + $this->deployment->options['document_target'] = $document_target; + } elseif (isset($this->deployment->options['document_target'])) { + unset($this->deployment->options['document_target']); + } + + $errors = $this->tool->validate(); + if ($errors) { + PageLayout::postError( + _('Die folgenden Daten zum LTI-Tool sind fehlerhaft:'), + array_map('htmlReady', $errors) + ); + return; + } + if ($this->tool->lti_version === '1.3a' && !$tool_public_key && !$this->tool->jwks_url) { + PageLayout::postError( + _('Es wurde weder ein öffentlicher Schlüssel noch eine JWKS-URL zum Tool angegeben.') + ); + return; } - if ($this->deployment) { + if ($this->tool->store() !== false) { + $this->deployment->tool_id = $this->tool->id; $this->deployment->store(); + } else { + PageLayout::postError(_('Das LTI-Tool konnte nicht gespeichert werden.')); + return; + } + if ($this->range_id !== 'global') { + $resource_link_exists = false; + if (!$new_tool) { + //Create an LTI resource link, if it doesn't exist yet: + $resource_link_exists = \LtiResourceLink::countBySQL( + "`deployment_id` = :deployment_id AND `course_id` = :course_id", + ['deployment_id' => $this->deployment->id, 'course_id' => $this->range_id] + ) > 0; + } + if (!$resource_link_exists) { + //Either the tool has just been created or the tool existed and it wasn't yet + //linked to the course. In those both cases, we have to create a new LTI resource link. + $resource_link = new \LtiResourceLink(); + $resource_link->deployment_id = $this->deployment->id; + $resource_link->course_id = $this->range_id; + $resource_link->store(); + } } if ($this->tool->lti_version === '1.3a' && $tool_public_key) { if (!$this->tool->updatePublicKey($tool_public_key)) { @@ -201,23 +210,31 @@ class Lti_ToolController extends AuthenticatedController $tool_name = $this->tool->name; if ($this->tool->range_id === 'global') { if ($range_id === 'global') { + //A global tool shall be deleted globally. + if (!$this->tool->isEditableByUser()) { + throw new AccessDeniedException(); + } $deleted = $this->tool->delete(); } else { - //A tool shall be deleted from a course: Delete the deployment instead. - $deployment = LtiDeployment::findOneBySQL( - "`tool_id` = :tool_id AND `course_id` = :course_id", + //A tool shall be deleted from a course: Delete the resource link instead. + $link = \LtiResourceLink::findOneBySQL( + "JOIN `lti_deployments` + ON `lti_deployments`.`id` = `lti_resource_links`.`deployment_id` + WHERE `lti_deployments`.`tool_id` = :tool_id AND `course_id` = :course_id", ['tool_id' => $this->tool->id, 'course_id' => $range_id] ); - if ($deployment) { - $tool_name = $deployment->title; - $deleted = $deployment->delete(); + if ($link) { + $deleted = $link->delete(); } else { PageLayout::postError(sprintf(_('Das LTI-Tool „%s“ ist in dieser Veranstaltung nicht vorhanden.'), htmlReady($this->tool->name))); return; } } } else { - //Delete the tool directly: + //A tool that is used inside a course shall be deleted. + if (!$this->tool->isEditableByUser()) { + throw new AccessDeniedException(); + } $deleted = $this->tool->delete(); } if ($deleted !== false) { diff --git a/app/views/admin/lti/index.php b/app/views/admin/lti/index.php index 54ebd94..f047add 100644 --- a/app/views/admin/lti/index.php +++ b/app/views/admin/lti/index.php @@ -16,6 +16,7 @@ <col style="width: 20%;"> <col style="width: 5%;"> <col style="width: 5%;"> + <col style="width: 5%;"> </colgroup> <thead> @@ -24,6 +25,7 @@ <th><?= _('URL der Anwendung') ?></th> <th><?= _('Consumer-Key') ?></th> <th><?= _('LTI-Version') ?></th> + <th><?= _('Deployment-ID') ?></th> <th><?= _('Links') ?></th> <th class="actions"><?= _('Aktionen') ?></th> </tr> @@ -44,7 +46,21 @@ </td> <td><?= htmlReady($tool->consumer_key) ?></td> <td><?= htmlReady($tool->getLtiVersionString()) ?></td> - <td><?= count($tool->links) ?></td> + <td> + <? + //Each tool should only have one deployment-ID: + $deployment = LtiDeployment::findOneByTool_id($tool->id); + ?> + <?= htmlReady($deployment->id ?? '') ?> + </td> + <td> + <?= \LtiResourceLink::countBySql( + "JOIN `lti_deployments` + ON `lti_deployments`.`id` = `lti_resource_links`.`deployment_id` + WHERE `lti_deployments`.`tool_id` = :tool_id", + ['tool_id' => $tool->id] + ) ?> + </td> <td class="actions"> <a href="<?= $controller->link_for('lti/tool/edit/global/' . $tool->id) ?>" title="<?= _('LTI-Tool konfigurieren') ?>" aria-label="<?= _('LTI-Tool konfigurieren') ?>" data-dialog> diff --git a/app/views/course/lti/consent.php b/app/views/course/lti/consent.php index 4c810a5..be78d5d 100644 --- a/app/views/course/lti/consent.php +++ b/app/views/course/lti/consent.php @@ -1,13 +1,13 @@ <?php /** * @var AuthenticatedController $controller - * @var LtiDeployment $deployment - * @var LtiDeploymentPrivacySettings $privacy_settings + * @var LtiResourceLink $resource_link + * @var LtiToolPrivacySettings $privacy_settings */ ?> -<? if ($deployment) : ?> +<? if ($resource_link) : ?> <form class="default" method="post" <?= $privacy_settings->isNew() ? 'data-dialog="reload-on-close"' : 'data-dialog' ?> - action="<?= $controller->link_for('course/lti/consent/' . $deployment->id) ?>"> + action="<?= $controller->link_for('course/lti/consent/' . $resource_link->id) ?>"> <?= CSRFProtection::tokenTag() ?> <? $data_protection_warning = CourseConfig::get(Context::getId())->LTI_DATA_PROTECTION_COURSE_WARNING; @@ -19,8 +19,8 @@ <legend><?= _('Datenschutzhinweise') ?></legend> <section> <p><?= htmlReady($data_protection_warning) ?></p> - <? if ($deployment->tool->data_protection_notes) : ?> - <p><?= formatReady($deployment->tool->data_protection_notes) ?></p> + <? if ($resource_link->deployment->tool->data_protection_notes) : ?> + <p><?= formatReady($resource_link->deployment->tool->data_protection_notes) ?></p> <? endif ?> </section> </fieldset> @@ -53,7 +53,7 @@ <?= _('Ihr Profilbild') ?> </label> </fieldset> - <?= $this->render_partial('lti/_deployment_user_info', ['deployment' => $deployment]) ?> + <?= $this->render_partial('lti/_deployment_user_info', ['deployment' => $resource_link->deployment]) ?> <fieldset> <legend><?= _('Bestätigung') ?></legend> <label> diff --git a/app/views/course/lti/iframe.php b/app/views/course/lti/iframe.php index c1a7de1..79cbd60 100644 --- a/app/views/course/lti/iframe.php +++ b/app/views/course/lti/iframe.php @@ -1,14 +1,14 @@ <?php /** * @var StudipController $controller - * @var LtiDeployment $deployment + * @var ?LtiResourceLink $resource_link * @var array $launch_data * @var string $signature * @var bool $lti13a_mode * @var \OAT\Library\Lti1p3Core\Message\LtiMessage $message */ ?> -<? if ($deployment) : ?> +<? if ($resource_link) : ?> <!DOCTYPE html> <html> <head> @@ -27,7 +27,7 @@ <?= _('Das LTI-Tool kann nicht aufgerufen werden.') ?> <? endif ?> <? else : ?> - <form name="ltiLaunchForm" method="post" action="<?= htmlReady($deployment->getLaunchUrl()) ?>"> + <form name="ltiLaunchForm" method="post" action="<?= htmlReady($resource_link->deployment->getLaunchUrl()) ?>"> <? foreach ($launch_data as $key => $value): ?> <input type="hidden" name="<?= htmlReady($key) ?>" value="<?= htmlReady($value, false) ?>"> <? endforeach ?> diff --git a/app/views/course/lti/index.php b/app/views/course/lti/index.php index 9c399d6..76eb307 100644 --- a/app/views/course/lti/index.php +++ b/app/views/course/lti/index.php @@ -1,28 +1,28 @@ <?php /** * @var Course_LtiController $controller - * @var LtiDeployment[] $lti_data_array + * @var \LtiResourceLink[] $links * @var bool $edit_perm */ ?> -<? if (empty($lti_data_array)): ?> +<? if (empty($links)): ?> <?= MessageBox::info(_('Es sind keine LTI-Tools konfiguriert.')) ?> <? endif ?> -<? foreach ($lti_data_array as $lti_data): ?> +<? foreach ($links as $link): ?> <? - $launch_url = $lti_data->getLaunchURL(); - $unfinished_deep_linking = !empty($lti_data->options['unfinished_deep_linking']); + $launch_url = $link->deployment->getLaunchURL(); + $unfinished_deep_linking = !empty($link->deployment->options['unfinished_deep_linking']); $no_consent = !LtiToolPrivacySettings::countBySql( '`tool_id` = :tool_id AND `user_id` = :user_id', - ['tool_id' => $lti_data->tool_id, 'user_id' => $GLOBALS['user']->id] + ['tool_id' => $link->deployment->tool_id, 'user_id' => $GLOBALS['user']->id] ); ?> <article class="studip"> <header> <h1> - <?= htmlReady($lti_data->title) ?> + <?= htmlReady($link->deployment->title) ?> <?= $unfinished_deep_linking ? '(' . _('LTI Deep Linking noch nicht fertig eingerichtet') . ')' : '' ?> </h1> @@ -30,16 +30,16 @@ <nav> <form action="" method="post"> <?= CSRFProtection::tokenTag() ?> - <? if ($lti_data->position > 0): ?> + <? if ($link->position > 0): ?> <?= Icon::create('arr_2up', Icon::ROLE_SORT)->asInput([ - 'formaction' => $controller->url_for('course/lti/move/' . $lti_data->id . '/up'), + 'formaction' => $controller->url_for('course/lti/move/' . $link->id . '/up'), 'title' => _('Nach oben verschieben'), 'aria-label' => _('Nach oben verschieben') ]) ?> <? endif ?> - <? if ($lti_data->position < count($lti_data_array) - 1): ?> + <? if ($link->position < count($links) - 1): ?> <?= Icon::create('arr_2down', Icon::ROLE_SORT)->asInput([ - 'formaction' => $controller->url_for('course/lti/move/' . $lti_data->id . '/down'), + 'formaction' => $controller->url_for('course/lti/move/' . $link->id . '/down'), 'title' => _('Nach unten verschieben'), 'aria-label' => _('Nach unten verschieben') ]) ?> @@ -47,34 +47,36 @@ <? $menu = ActionMenu::get(); - $show_admin_actions = $GLOBALS['perm']->have_studip_perm('tutor', $lti_data->course_id); + $show_admin_actions = $GLOBALS['perm']->have_studip_perm('tutor', $link->course_id); if ($show_admin_actions) { $menu->addLink( - $controller->url_for('lti/tool/index/' . $lti_data->course_id . '/' . $lti_data->tool->id), + $controller->url_for('lti/tool/index/' . $link->course_id . '/' . $link->deployment->tool->id), _('Konfiguration des LTI-Tools anzeigen'), Icon::create('info-circle'), ['data-dialog' => 'size=default'] ); } $menu->addLink( - $controller->url_for('course/lti/consent/' . $lti_data->id), + $controller->url_for('course/lti/consent/' . $link->id), _('Datenschutzeinstellungen'), Icon::create('privacy'), ['data-dialog' => 'size=default'] ); - if ($show_admin_actions) { + if ($link->deployment->tool->isEditableByUser()) { $menu->addLink( - $controller->url_for('lti/tool/edit/' . $lti_data->course_id . '/' . $lti_data->tool->id), + $controller->url_for('lti/tool/edit/' . $link->course_id . '/' . $link->deployment->tool_id), _('LTI-Tool konfigurieren'), Icon::create('edit'), ['data-dialog' => 'size=default'] ); + } + if ($show_admin_actions) { $menu->addLink( sprintf( 'javascript:void(STUDIP.Dialog.confirmAsPost(\'%s\', \'%s\'))', - sprintf(_('Wollen Sie das LTI-Tool "%s" wirklich entfernen?'), $lti_data->title), - $controller->url_for('lti/tool/delete/' . $lti_data->course_id . '/' . $lti_data->tool->id) + sprintf(_('Wollen Sie das LTI-Tool "%s" wirklich entfernen?'), $link->deployment->title), + $controller->url_for('lti/tool/delete/' . $link->course_id . '/' . $link->deployment->tool_id) ), _('LTI-Tool entfernen'), Icon::create('trash') @@ -90,29 +92,29 @@ <? if ($unfinished_deep_linking) : ?> <?= Studip\LinkButton::create( _('Einrichtung abschließen'), - $controller->url_for('course/lti/select_link/' . $lti_data->id, ['tool_id' => $lti_data->tool_id]), + $controller->url_for('course/lti/select_link/' . $link->id, ['tool_id' => $link->tool_id]), ['target' => '_blank'] ) ?> <? elseif ($no_consent) : ?> - <?= formatReady($lti_data->description) ?> + <?= formatReady($link->deployment->description) ?> <p><?= _('Sie haben der Datenweitergabe an das LTI-Tool noch nicht zugestimmt und können es deswegen noch nicht nutzen.') ?></p> <?= Studip\LinkButton::create( _('Datenschutzeinstellungen öffnen'), - $controller->url_for('course/lti/consent/' . $lti_data->id), + $controller->url_for('course/lti/consent/' . $link->id), ['data-dialog' => 'reload-on-close'] ) ?> <? elseif ($launch_url) : ?> <? - $document_target = $lti_data->options['document_target'] ?? ''; + $document_target = $link->deployment->options['document_target'] ?? ''; ?> - <?= formatReady($lti_data->description) ?> + <?= formatReady($link->deployment->description) ?> <? if ($document_target === 'iframe') : ?> <iframe style="border: none; height: 640px; width: 100%;" - src="<?= $controller->link_for('course/lti/iframe/' . $lti_data->id) ?>"></iframe> + src="<?= $controller->link_for('course/lti/iframe/' . $link->id) ?>"></iframe> <? else : ?> <?= Studip\LinkButton::create( _('Anwendung starten'), - $controller->url_for('course/lti/iframe/' . $lti_data->id), + $controller->url_for('course/lti/iframe/' . $link->id), ['target' => '_blank'] ) ?> <? endif ?> diff --git a/app/views/course/lti/select_tool.php b/app/views/course/lti/select_tool.php index b6163e0..118c1ae 100644 --- a/app/views/course/lti/select_tool.php +++ b/app/views/course/lti/select_tool.php @@ -1,7 +1,7 @@ <?php /** * @var StudipController $controller - * @var LtiTool[] $global_tools + * @var LtiDeployment[] $global_tool_deployments */ ?> <form class="default" method="post" action="<?= $controller->link_for('course/lti/select_tool_redirect') ?>" @@ -11,10 +11,14 @@ <legend><?= _('Auswahl des LTI-Tools') ?></legend> <label> <?= _('Bitte wählen Sie ein LTI-Tool aus.') ?> - <select name="selected_tool_id"> - <? foreach ($global_tools as $tool) : ?> - <option value="<?= htmlReady($tool->id) ?>"> - <?= htmlReady($tool->name) ?> + <select name="selected_deployment_id"> + <? foreach ($global_tool_deployments as $deployment) : ?> + <option value="<?= htmlReady($deployment->id) ?>"> + <? if ($deployment->title !== $deployment->tool->name) : ?> + <?= htmlReady(sprintf('%1$s (%2$s)', $deployment->tool->name, $deployment->title)) ?> + <? else : ?> + <?= htmlReady($deployment->title) ?> + <? endif ?> </option> <? endforeach ?> <? if (Config::get()->LTI_ALLOW_TOOL_CONFIG_IN_COURSE) : ?> diff --git a/app/views/lti/_tool_form_fields.php b/app/views/lti/_tool_form_fields.php index 7a7dfbf..0dcbd25 100644 --- a/app/views/lti/_tool_form_fields.php +++ b/app/views/lti/_tool_form_fields.php @@ -1,7 +1,7 @@ <?php /** * @var LtiTool $tool - * @var LtiDeployment $deployment + * @var ?LtiDeployment $deployment */ ?> <fieldset> @@ -10,118 +10,106 @@ <span class="textlabel"><?= _('Titel') ?></span> <span class="asterisk">*</span> <input type="text" name="name" required - value="<?= htmlReady(!empty($deployment) ? $deployment->title : $tool->name ?? '') ?>"> + value="<?= htmlReady($tool->name ?? '') ?>"> </label> - <? if (!empty($deployment)) : ?> + <label> + <?= _('Beschreibung') ?> + <textarea name="description" class="wysiwyg"><?= wysiwygReady($deployment->description ?? '') ?></textarea> + </label> + <label> + <?= _('Datenschutzhinweise') ?> + <textarea name="data_protection_notes" class="wysiwyg" + placeholder="<?= _('Bitte machen Sie Angaben zu dem angebundenen Werkzeug, soweit sie ihnen bekannt sind. Wie ist der Name, wer bietet es an, wozu wird es eingesetzt und welche Daten werden übertragen? (Beispiel: „Tool XY wird zur Durchführung von Sprachtests genutzt und Testergebnisse und ggf. Noten gespeichert. Zur Anmeldung werden Name und Nutzerkennung übertragen.“)') ?>"><?= wysiwygReady($tool->data_protection_notes) ?></textarea> + </label> + <label> + <?= _('URL zu den Nutzungsbedingungen des LTI-Tools (falls verfügbar)') ?> + <input type="url" name="terms_of_use_url" value="<?= htmlReady($tool->terms_of_use_url) ?>"> + </label> + <label> + <?= _('URL zur Datenschutzerklärung des LTI-Tools (falls verfügbar)') ?> + <input type="url" name="privacy_policy_url" value="<?= htmlReady($tool->privacy_policy_url) ?>"> + </label> +</fieldset> +<fieldset> + <legend><?= _('Konfiguration des LTI-Tools') ?></legend> + <label class="studiprequired"> + <span class="textlabel"><?= _('LTI-Version') ?></span> + <span class="asterisk">*</span> + <select name="lti_version" + data-shows=".lti11-field" data-hides=".lti13a-field" + data-triggering-value="1.1"> + <option value="1.1" <?= !empty($tool->lti_version) && $tool->lti_version === '1.1' ? 'selected' : '' ?>> + 1.0/1.1 + </option> + <option value="1.3a" <?= empty($tool->lti_version) || $tool->lti_version === '1.3a' ? 'selected' : '' ?>> + 1.3a + </option> + </select> + </label> + + <label class="studiprequired"> + <span class="textlabel"><?= _('LTI Launch-URL') ?></span> + <span class="asterisk">*</span> + <input type="text" name="launch_url" required + value="<?= htmlReady($tool->launch_url ?? '') ?>"> + </label> + + <div class="lti13a-field"> <label> - <?= _('Beschreibung') ?> - <textarea name="description" class="wysiwyg"><?= wysiwygReady($deployment->description ?? '') ?></textarea> + <?= _('OIDC Login-URL') ?> + <?= tooltipIcon(_('Die URL, mit der der Login via OpenID Connect stattfindet.')) ?> + <input type="text" name="oidc_init_url" value="<?= htmlReady($tool->oidc_init_url ?? '') ?>"> </label> <label> - <?= _('Datenschutzhinweise') ?> - <textarea name="data_protection_notes" class="wysiwyg" - placeholder="<?= _('Bitte machen Sie Angaben zu dem angebundenen Werkzeug, soweit sie ihnen bekannt sind. Wie ist der Name, wer bietet es an, wozu wird es eingesetzt und welche Daten werden übertragen? (Beispiel: „Tool XY wird zur Durchführung von Sprachtests genutzt und Testergebnisse und ggf. Noten gespeichert. Zur Anmeldung werden Name und Nutzerkennung übertragen.“)') ?>"><?= wysiwygReady($tool->data_protection_notes) ?></textarea> + <?= _('Deep-linking URL') ?> + <input type="url" name="deep_linking_url" value="<?= htmlReady($tool->deep_linking_url ?? '') ?>"> </label> - <? endif ?> - <? if ($tool->isEditableByUser()) : ?> <label> - <?= _('URL zu den Nutzungsbedingungen des LTI-Tools (falls verfügbar)') ?> - <input type="url" name="terms_of_use_url" value="<?= htmlReady($tool->terms_of_use_url) ?>"> + <?= _('JWKS-URL') ?> + <?= tooltipIcon(_('Die URL, mit der der der Austausch von JSON web keys stattfinden kann.')) ?> + <input type="text" name="jwks_url" + value="<?= htmlReady($tool->jwks_url ?? '') ?>"> </label> <label> - <?= _('URL zur Datenschutzerklärung des LTI-Tools (falls verfügbar)') ?> - <input type="url" name="privacy_policy_url" value="<?= htmlReady($tool->privacy_policy_url) ?>"> + <?= _('Schlüssel-ID') ?> + <?= tooltipIcon(_('Die ID des Schlüssels, der über die JWKS-URL geladen werden soll.')) ?> + <input type="text" name="jwks_key_id" value="<?= htmlReady($tool->jwks_key_id ?? '') ?>"> </label> - <? endif ?> -</fieldset> -<fieldset> - <legend><?= _('Konfiguration des LTI-Tools') ?></legend> - <? if ($tool->isEditableByUser()) : ?> + <label> + <?= _('Öffentlicher Schlüssel des LTI-Tools') ?> + <? + $keyring = null; + if ($tool && !$tool->isNew()) { + $keyring = $tool->getKeyring(); + } + $public_key_string = ''; + if ($keyring) { + $keychain = $keyring->toKeyChain(); + $public_key_string = $keychain->getPublicKey()->getContent(); + } + ?> + <textarea name="tool_public_key"><?= htmlReady($public_key_string) ?></textarea> + </label> + </div> + <div class="lti11-field"> <label class="studiprequired"> - <span class="textlabel"><?= _('LTI-Version') ?></span> + <span class="textlabel"><?= _('Consumer-Key des LTI-Tools') ?></span> <span class="asterisk">*</span> - <select name="lti_version" - data-shows=".lti11-field" data-hides=".lti13a-field" - data-triggering-value="1.1"> - <option value="1.1" <?= !empty($tool->lti_version) && $tool->lti_version === '1.1' ? 'selected' : '' ?>> - 1.0/1.1 - </option> - <option value="1.3a" <?= empty($tool->lti_version) || $tool->lti_version === '1.3a' ? 'selected' : '' ?>> - 1.3a - </option> - </select> + <input type="text" name="consumer_key" required + value="<?= htmlReady($tool->consumer_key ?? '') ?>"> </label> - <? endif ?> - - <label class="studiprequired"> - <span class="textlabel"><?= _('LTI Launch-URL') ?></span> - <span class="asterisk">*</span> - <input type="text" name="launch_url" required - value="<?= htmlReady( - !empty($deployment->launch_url) - ? $deployment->launch_url - : $tool->launch_url ?? '' - ) ?>"> - </label> - - <? if ($tool->isEditableByUser()) : ?> - <div class="lti13a-field"> - <label> - <?= _('OIDC Login-URL') ?> - <?= tooltipIcon(_('Die URL, mit der der Login via OpenID Connect stattfindet.')) ?> - <input type="text" name="oidc_init_url" value="<?= htmlReady($tool->oidc_init_url ?? '') ?>"> - </label> - <label> - <?= _('Deep-linking URL') ?> - <input type="url" name="deep_linking_url" value="<?= htmlReady($tool->deep_linking_url ?? '') ?>"> - </label> - <label> - <?= _('JWKS-URL') ?> - <?= tooltipIcon(_('Die URL, mit der der der Austausch von JSON web keys stattfinden kann.')) ?> - <input type="text" name="jwks_url" - value="<?= htmlReady($tool->jwks_url ?? '') ?>"> - </label> - <label> - <?= _('Schlüssel-ID') ?> - <?= tooltipIcon(_('Die ID des Schlüssels, der über die JWKS-URL geladen werden soll.')) ?> - <input type="text" name="jwks_key_id" value="<?= htmlReady($tool->jwks_key_id ?? '') ?>"> - </label> - <label> - <?= _('Öffentlicher Schlüssel des LTI-Tools') ?> - <? - $keyring = null; - if ($tool && !$tool->isNew()) { - $keyring = $tool->getKeyring(); - } - $public_key_string = ''; - if ($keyring) { - $keychain = $keyring->toKeyChain(); - $public_key_string = $keychain->getPublicKey()->getContent(); - } - ?> - <textarea name="tool_public_key"><?= htmlReady($public_key_string) ?></textarea> - </label> - </div> - <div class="lti11-field"> - <label class="studiprequired"> - <span class="textlabel"><?= _('Consumer-Key des LTI-Tools') ?></span> - <span class="asterisk">*</span> - <input type="text" name="consumer_key" required - value="<?= htmlReady($tool->consumer_key ?? '') ?>"> - </label> - <label class="studiprequired"> + <label class="studiprequired"> <span class="textlabel"><?= _('Consumer-Secret des LTI-Tools') ?></span> - <span class="asterisk">*</span> - <input type="text" name="consumer_secret" required - value="<?= htmlReady($tool->consumer_secret ?? '') ?>"> - </label> - </div> - <label> - <input type="checkbox" name="send_lis_person" value="1" <?= !empty($tool->send_lis_person) ? ' checked' : '' ?>> - <?= _('Personendaten an das LTI-Tool senden') ?> - <?= tooltipIcon(_('Personendaten dürfen nur an das externe Tool gesendet werden, wenn es keine Datenschutzbedenken gibt. Mit Setzen des Hakens bestätigen Sie, dass die Übermittlung der Daten zulässig ist.')) ?> + <span class="asterisk">*</span> + <input type="text" name="consumer_secret" required + value="<?= htmlReady($tool->consumer_secret ?? '') ?>"> </label> - <? endif ?> + </div> + <label> + <input type="checkbox" name="send_lis_person" value="1" <?= !empty($tool->send_lis_person) ? ' checked' : '' ?>> + <?= _('Personendaten an das LTI-Tool senden') ?> + <?= tooltipIcon(_('Personendaten dürfen nur an das externe Tool gesendet werden, wenn es keine Datenschutzbedenken gibt. Mit Setzen des Hakens bestätigen Sie, dass die Übermittlung der Daten zulässig ist.')) ?> + </label> <label> <?= _('Zusätzliche LTI-Parameter') ?> <?= tooltipIcon(_('Ein Wert pro Zeile, Beispiel: Review:Chapter=1.2.56')) ?> @@ -132,16 +120,14 @@ ) ?></textarea> </label> </fieldset> -<? if (!empty($deployment)) : ?> - <fieldset> - <legend><?= _('Anzeigeeinstellungen') ?></legend> - <label> - <input type="checkbox" name="document_target" value="iframe" <?= isset($deployment->options['document_target']) && $deployment->options['document_target'] === 'iframe' ? ' checked' : '' ?>> - <?= _('Anzeige im IFRAME auf der Seite') ?> - <?= tooltipIcon(_('Normalerweise wird das externe Tool in einem neuen Fenster angezeigt. Aktivieren Sie diese Option, wenn die Anzeige stattdessen in einem IFRAME erfolgen soll.')) ?> - </label> - </fieldset> -<? endif ?> +<fieldset> + <legend><?= _('Anzeigeeinstellungen') ?></legend> + <label> + <input type="checkbox" name="document_target" value="iframe" <?= isset($deployment->options['document_target']) && $deployment->options['document_target'] === 'iframe' ? ' checked' : '' ?>> + <?= _('Anzeige im IFRAME auf der Seite') ?> + <?= tooltipIcon(_('Normalerweise wird das externe Tool in einem neuen Fenster angezeigt. Aktivieren Sie diese Option, wenn die Anzeige stattdessen in einem IFRAME erfolgen soll.')) ?> + </label> +</fieldset> <footer data-dialog-button> <?= Studip\Button::createAccept(_('Speichern'), 'save') ?> diff --git a/app/views/lti/_tool_info.php b/app/views/lti/_tool_info.php index 452e8f4..0fafc9d 100644 --- a/app/views/lti/_tool_info.php +++ b/app/views/lti/_tool_info.php @@ -65,10 +65,21 @@ <? endif ?> <dt><?= _('Direktlink zum LTI-Tool') ?></dt> <dd> - <a href="<?= $controller->link_for('course/lti/iframe', $deployment->id) ?>"> - <?= Icon::create('link-extern')->asImg(['class' => 'text-bottom']) ?> - <?= $controller->link_for('course/lti/iframe', $deployment->id) ?> - </a> + <ul> + <? foreach ($tool->deployments as $deployment) : ?> + <? + $link = LtiResourceLink::findOneByDeployment_id($deployment->id); + ?> + <? if ($link) : ?> + <li> + <a href="<?= $controller->link_for('course/lti/iframe', $link->id) ?>"> + <?= Icon::create('link-extern')->asImg(['class' => 'text-bottom']) ?> + <?= $controller->link_for('course/lti/iframe', $link->id) ?> + </a> + </li> + <? endif ?> + <? endforeach ?> + </ul> </dd> </dl> </article> diff --git a/db/migrations/6.0.48_add_lti_resource_links_table.php b/db/migrations/6.0.48_add_lti_resource_links_table.php new file mode 100644 index 0000000..40bb129 --- /dev/null +++ b/db/migrations/6.0.48_add_lti_resource_links_table.php @@ -0,0 +1,61 @@ +<?php + + +class AddLtiResourceLinksTable extends Migration +{ + public function description() + { + return 'Creates the lti_resource_links table and moves colums from the lti_deployments table into it.'; + } + + protected function up() + { + $db = DBManager::get(); + + //Create the lti_resource_links table: + $db->exec( + "CREATE TABLE IF NOT EXISTS lti_resource_links ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + deployment_id INT(11) NOT NULL, + course_id CHAR(32) NOT NULL, + position INT(11) NOT NULL DEFAULT 0, + mkdate INT(11) NOT NULL DEFAULT 0, + chdate INT(11) NOT NULL DEFAULT 0 + )" + ); + $db->exec("ALTER TABLE `lti_resource_links` ADD INDEX (`deployment_id`)"); + + //Migrate the contents of lti_deployments: + $db->exec( + "INSERT INTO `lti_resource_links` (`deployment_id`, `course_id`, `position`, `mkdate`, `chdate`) + SELECT `id`, `course_id`, `position`, `mkdate`, `chdate` FROM `lti_deployments`" + ); + + //Remove columns from lti_deployments: + $db->exec("ALTER TABLE `lti_deployments` DROP COLUMN `course_id`, DROP COLUMN `position`"); + } + + protected function down() + { + $db = DBManager::get(); + + //Add columns to lti_deployments: + $db->exec( + "ALTER TABLE `lti_deployments` + ADD COLUMN `course_id` CHAR(32) NOT NULL, + ADD COLUMN `position` INT(11) NOT NULL DEFAULT 0" + ); + + //Migrate the content of lti_resource_links: + $db->exec( + "UPDATE `lti_deployments` JOIN `lti_resource_links` + ON `lti_deployments`.`id` = `lti_resource_links`.`deployment_id` + SET + `lti_deployments`.`course_id` = `lti_resource_links`.`course_id`, + `lti_deployments`.`position` = `lti_resource_links`.`position`" + ); + + //Remove the lti_resource_links table: + $db->exec("DROP TABLE `lti_resource_links`"); + } +} diff --git a/lib/classes/LTI13a/LineItemRepository.php b/lib/classes/LTI13a/LineItemRepository.php index add863e..c6c6ef1 100644 --- a/lib/classes/LTI13a/LineItemRepository.php +++ b/lib/classes/LTI13a/LineItemRepository.php @@ -110,22 +110,19 @@ class LineItemRepository implements LineItemRepositoryInterface return $result; } - //$resourceLinkIdentifier contains the Stud.IP tool-ID, the deployment-ID and the course-ID, - //separated by underscores. - $id_parts = explode('_', $resourceLinkIdentifier); - if (count($id_parts) !== 3) { + //Find the LTI resource link by its ID: + $resource_link = \LtiResourceLink::find($resourceLinkIdentifier); + if (!$resource_link) { throw new LTIException('Invalid resource link identifier.'); } - $tool_id = $id_parts[0]; - $deployment_id = $id_parts[1]; - $course_id = $id_parts[2]; + $tool_id = $resource_link->deployment->tool_id ?? null; $sql = ''; $sql_params = []; - if ($tool_id && $course_id) { + if ($tool_id && $resource_link->course_id) { $sql .= "`tool` = :tool AND `course_id` = :course_id"; - $sql_params['tool'] = self::getGradingToolName($tool_id, $deployment_id); - $sql_params['course_id'] = $course_id; + $sql_params['tool'] = self::getGradingToolName($tool_id, $resource_link->deployment_id); + $sql_params['course_id'] = $resource_link->course_id; } else { //No tool-ID means no line item collection can be found. return $result; @@ -155,18 +152,17 @@ class LineItemRepository implements LineItemRepositoryInterface */ public function save(LineItemInterface $lineItem): LineItemInterface { - //The resource link identifier contains the Stud.IP tool-ID, deployment-ID and course-ID - //separated by underscores. - $studip_ids = explode('_', $lineItem->getResourceLinkIdentifier() ?? ''); - $tool_id = $studip_ids[0]; - $deployment_id = $studip_ids[1]; - $course_id = $studip_ids[2]; + $resource_link_id = $lineItem->getResourceLinkIdentifier() ?? ''; + $resource_link = \LtiResourceLink::find($resource_link_id); + if (!$resource_link) { + throw new LTIException('Invalid resource link identifier.'); + } $definition = new Definition(); $definition->id = $lineItem->getIdentifier(); $definition->name = $lineItem->getLabel(); - $definition->course_id = $course_id; - $definition->tool = sprintf('lti-%s-%s', $tool_id, $deployment_id); + $definition->course_id = $resource_link->course_id; + $definition->tool = $resource_link->id; $definition->weight = '1.0'; if ($definition->store()) { return $definition->toLineItem(); diff --git a/lib/models/Grading/Definition.php b/lib/models/Grading/Definition.php index 7fc2066..56417b2 100644 --- a/lib/models/Grading/Definition.php +++ b/lib/models/Grading/Definition.php @@ -72,21 +72,20 @@ class Definition extends \SimpleORMap public function toLineItem() : LineItemInterface { - //Build the resource link identifier first: - $studip_ids = explode('-', $this->tool ?? ''); - $tool_id = $studip_ids[1] ?? ''; - $deployment_id = $studip_ids[2] ?? ''; - $resource_link_identifier = sprintf('%s_%s_%s', $tool_id, $deployment_id, $this->course_id); + $resource_link_identifier = $this->tool ?? ''; + $deployment_id = ''; + if ($resource_link_identifier) { + $lti_resource_link = \LtiResourceLink::find($resource_link_identifier); + if ($lti_resource_link) { + $deployment_id = $lti_resource_link->deployment_id; + } + } - $identifier = \URLHelper::getURL( - 'dispatch.php/lti/ags/line_item', - [ - 'cid' => $this->course_id, - 'definition_id' => $this->id, - 'deployment_id' => $deployment_id, - 'tool_id' => $tool_id - ] - ); + $identifier = \URLHelper::getURL(sprintf( + 'dispatch.php/lti/ags/line_item/%1$s/%2$s', + $resource_link_identifier, + $this->id + )); return new LineItem( PHP_FLOAT_MAX, diff --git a/lib/models/LtiDeployment.php b/lib/models/LtiDeployment.php index 0b66427..e59e738 100644 --- a/lib/models/LtiDeployment.php +++ b/lib/models/LtiDeployment.php @@ -12,8 +12,6 @@ * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 * * @property int $id database column - * @property int $position database column - * @property string $course_id database column * @property string $title database column * @property string $description database column * @property int $tool_id database column @@ -37,64 +35,24 @@ class LtiDeployment extends SimpleORMap $config['serialized_fields']['options'] = JSONArrayObject::class; - $config['belongs_to']['course'] = [ - 'class_name' => Course::class, - 'foreign_key' => 'course_id' - ]; $config['belongs_to']['tool'] = [ 'class_name' => LtiTool::class, 'foreign_key' => 'tool_id' ]; - + $config['has_many']['resource_links'] = [ + 'class_name' => LtiResourceLink::class, + 'assoc_foreign_key' => 'deployment_id', + 'on_delete' => 'delete' + ]; $config['has_many']['grades'] = [ 'class_name' => LtiGrade::class, 'assoc_foreign_key' => 'link_id', 'on_delete' => 'delete' ]; - $config['registered_callbacks']['before_create'] = ['cbCalculatePosition']; - parent::configure($config); } - /** - * Calculates the position of the new deployment in the course. - */ - public function cbCalculatePosition() : void - { - $this->position = self::countBySql( - 'JOIN `lti_tools` ON `tool_id` = `lti_tools`.`id` - WHERE `lti_tools`.`range_id` = :range_id', - ['range_id' => $this->tool->range_id] - ) + 1; - } - - /** - * Find a single entry by course_id and position. - * - * @return static|null - */ - public static function findByCourseAndPosition($course_id, $position) - { - return self::findOneBySQL('course_id = ? AND position = ?', [$course_id, $position]); - } - - /** - * Delete this entity. - */ - public function delete() - { - $db = DBManager::get(); - $course_id = $this->course_id; - $position = $this->position; - - if ($result = parent::delete()) { - $db->execute('UPDATE `lti_deployments` SET `position` = position - 1 WHERE `course_id` = ? AND `position` > ?', [$course_id, $position]); - } - - return $result; - } - public function getToolLtiVersion() : string { return $this->tool->lti_version ?? ''; diff --git a/lib/models/LtiResourceLink.php b/lib/models/LtiResourceLink.php new file mode 100644 index 0000000..785c350 --- /dev/null +++ b/lib/models/LtiResourceLink.php @@ -0,0 +1,187 @@ +<?php +/* + * LtiResourceLink.php + * This file is part of Stud.IP. + * + * This program 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 2 of + * the License, or (at your option) any later version. + * + * @author Moritz Strohm + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + */ + + +use OAT\Library\Lti1p3Core\Resource\LtiResourceLink\LtiResourceLinkInterface; +use OAT\Library\Lti1p3Core\Util\Collection\Collection; +use OAT\Library\Lti1p3Core\Util\Collection\CollectionInterface; + +/** + * The LtiResourceLink class is a model for the lti_resource_links table. + * + * @property int $id database column + * @property int $deployment_id database column + * @property string $course_id database column + * @property int $position database column + * @property int $mkdate database column + * @property int $chdate database column + * @property ?LtiDeployment $deployment related object + * @property ?Course $course related object + */ +class LtiResourceLink extends \SimpleORMap implements LtiResourceLinkInterface +{ + protected static function configure($config = []) + { + $config['db_table'] = 'lti_resource_links'; + + $config['belongs_to']['course'] = [ + 'class_name' => Course::class, + 'foreign_key' => 'course_id' + ]; + $config['belongs_to']['deployment'] = [ + 'class_name' => LtiDeployment::class, + 'foreign_key' => 'deployment_id' + ]; + + $config['registered_callbacks']['before_create'] = ['cbCalculatePosition']; + + parent::configure($config); + } + + /** + * Calculates the position for a new LTI resource link in the course. + */ + public function cbCalculatePosition() : void + { + $this->position = self::countByCourse_id($this->course_id); + } + + /** + * Delete this entity. + */ + public function delete() + { + $course_id = $this->course_id; + $position = $this->position; + if ($result = parent::delete()) { + DBManager::get()->execute( + "UPDATE `lti_resource_links` + SET `position` = position - 1 + WHERE `course_id` = :course_id AND `position` > :position", + [ + 'course_id' => $course_id, + 'position' => $position + ] + ); + } + + return $result; + } + + /** + * Find a single entry by course_id and position. + * + * @return static|null + */ + public static function findByCourseAndPosition($course_id, $position) + { + return self::findOneBySQL('course_id = ? AND position = ?', [$course_id, $position]); + } + + //OAT library LtiResourceLinkInterface and ResourceInterface implementation: + + public function getUrl(): ?string + { + if ($this->deployment) { + return $this->deployment->getLaunchURL(); + } + return null; + } + + public function getIcon(): ?array + { + return null; + } + + public function getThumbnail(): ?array + { + if ($this->course) { + return [$this->course->getItemAvatarURL()]; + } + return null; + } + + public function getIframe(): ?array + { + //Not supported. + return null; + } + + public function getCustom(): ?array + { + //Not supported. + return null; + } + + public function getLineItem(): ?array + { + // TODO: Implement getLineItem() method. + return null; + } + + public function getAvailability(): ?array + { + // TODO: Implement getAvailability() method. + return null; + } + + public function getSubmission(): ?array + { + // TODO: Implement getSubmission() method. + return null; + } + + public function getIdentifier(): string + { + return strval($this->id); + } + + public function getType(): string + { + return 'ltiResourceLink'; + } + + public function getTitle(): ?string + { + if ($this->deployment) { + return $this->deployment->title; + } + return null; + } + + public function getText(): ?string + { + return null; + } + + public function getProperties(): CollectionInterface + { + $collection = new Collection(); + $collection->add([ + 'url' => $this->getUrl(), + 'title' => $this->getTitle() + ]); + return $collection; + } + + public function normalize(): array + { + return array_filter( + array_merge( + $this->getProperties()->all(), + ['type' => $this->getType()] + ) + ); + } +} diff --git a/lib/models/LtiTool.php b/lib/models/LtiTool.php index 9839ac0..bde6203 100644 --- a/lib/models/LtiTool.php +++ b/lib/models/LtiTool.php @@ -48,7 +48,7 @@ class LtiTool extends SimpleORMap { $config['db_table'] = 'lti_tools'; - $config['has_many']['links'] = [ + $config['has_many']['deployments'] = [ //formerly: links 'class_name' => LtiDeployment::class, 'assoc_foreign_key' => 'tool_id', 'on_delete' => 'delete' diff --git a/lib/modules/LtiToolModule.php b/lib/modules/LtiToolModule.php index 2a64e83..9d977be 100644 --- a/lib/modules/LtiToolModule.php +++ b/lib/modules/LtiToolModule.php @@ -27,7 +27,7 @@ class LtiToolModule extends CorePlugin implements StudipModule, SystemPlugin, Pr LtiGrade::deleteBySQL('user_id = ?', [$user->id]); }); NotificationCenter::on('CourseDidDelete', function ($event, $course) { - LtiDeployment::deleteBySQL('course_id = ?', [$course->id]); + \LtiResourceLink::deleteBySQL('course_id = ?', [$course->id]); }); } @@ -40,7 +40,7 @@ class LtiToolModule extends CorePlugin implements StudipModule, SystemPlugin, Pr return null; } - $changed = LtiDeployment::countBySQL('course_id = ? AND chdate > ?', [$course_id, $last_visit]); + $changed = \LtiResourceLink::countBySQL('course_id = ? AND chdate > ?', [$course_id, $last_visit]); $icon = Icon::create('plugin', $changed ? Icon::ROLE_NEW : Icon::ROLE_CLICKABLE); @@ -60,7 +60,7 @@ class LtiToolModule extends CorePlugin implements StudipModule, SystemPlugin, Pr return []; } - $grades = LtiDeployment::countBySQL('course_id = ?', [$course_id]); + $grades = \LtiResourceLink::countBySQL('course_id = ?', [$course_id]); $navigation = new Navigation(_('LTI-Tools')); $navigation->setImage(Icon::create('link-extern', Icon::ROLE_INFO_ALT)); |
