Skip to content
Snippets Groups Projects
Commit 9a720a69 authored by Dennis Benz's avatar Dennis Benz Committed by Elmar Ludwig
Browse files

New Courseware Block: LTI, refs #2326

Merge request studip/studip!1545
parent 096ccf81
No related branches found
No related tags found
No related merge requests found
Showing
with 652 additions and 0 deletions
<?php
class Courseware_LtiController extends AuthenticatedController
{
/**
* Display the launch form for a tool as an iframe in a courseware LTI block.
*
* @param int $block_id courseware block id
*/
public function iframe_action($block_id)
{
$cw_block = \Courseware\Block::find($block_id);
if (!$cw_block->container->structural_element->canRead($GLOBALS['user']->id)) {
throw new AccessDeniedException();
}
$cw_block = \Courseware\Block::find($block_id);
$lti_link = $this->getLtiLink($cw_block);
$this->launch_url = $lti_link->getLaunchURL();
$this->launch_data = $lti_link->getBasicLaunchData();
$this->signature = $lti_link->getLaunchSignature($this->launch_data);
$this->set_layout(null);
$this->render_template('course/lti/iframe');
}
/**
* Return an LtiLink object for the passed courseware LTI block.
*
* @param \Courseware\Block $cw_block courseware LTI block
*
* @return LtiLink LTI link representation
*/
public function getLtiLink($cw_block)
{
$block_payload = json_decode($cw_block->payload, true);
// Collect LTI Data from courseware block payload
$id = $cw_block->id;
$context_id = Context::getId();
$range_id = $cw_block->getStructuralElement()->range_id;
$title = trim($block_payload['title']);
$tool_id = $block_payload['tool_id'];
$launch_url = trim($block_payload['launch_url']);
$custom_parameters = trim($block_payload['custom_parameters']);
$document_target = 'iframe';
if ($tool_id) {
$tool = LtiTool::find($tool_id);
// Prefer custom url
if (!$tool->allow_custom_url && !$tool->deep_linking || !$launch_url) {
$launch_url = $tool->launch_url;
}
$consumer_key = $tool->consumer_key;
$consumer_secret = $tool->consumer_secret;
$send_lis_person = $tool->send_lis_person;
$oauth_signature_method = $tool->oauth_signature_method;
$custom_parameters = $tool->custom_parameters . "\n" . $custom_parameters;
} else {
$consumer_key = trim($block_payload['consumer_key']);
$consumer_secret = trim($block_payload['consumer_secret']);
$send_lis_person = $block_payload['send_lis_person'];
$oauth_signature_method = $block_payload['oauth_signature_method'] ?? 'sha1';
}
if ($context_id) {
// Role in course
$roles = $GLOBALS['perm']->have_studip_perm('tutor', $context_id) ? 'Instructor' : 'Learner';
} else {
// Role in workspace
$roles = $range_id === $GLOBALS['user']->id ? 'Instructor' : 'Learner';
}
// Create LTI Link for setting up launch request
$lti_link = new LtiLink($launch_url, $consumer_key, $consumer_secret, $oauth_signature_method);
$lti_link->setResource($id, $title);
$lti_link->setUser($GLOBALS['user']->id, $roles, $send_lis_person);
$lti_link->setCourse($range_id);
$lti_link->addLaunchParameters([
'launch_presentation_locale' => str_replace('_', '-', $_SESSION['_language']),
'launch_presentation_document_target' => $document_target,
]);
$custom_parameters = explode("\n", $custom_parameters);
foreach ($custom_parameters as $param) {
if (strpos($param, '=') !== false) {
list($key, $value) = explode('=', $param, 2);
$lti_link->addCustomParameter(trim($key), trim($value));
}
}
return $lti_link;
}
}
......@@ -129,6 +129,7 @@ class RouteMap
$this->addAuthenticatedFilesRoutes($group);
$this->addAuthenticatedForumRoutes($group);
$this->addAuthenticatedInstitutesRoutes($group);
$this->addAuthenticatedLtiRoutes($group);
$this->addAuthenticatedMessagesRoutes($group);
$this->addAuthenticatedNewsRoutes($group);
$this->addAuthenticatedStudyAreasRoutes($group);
......@@ -251,6 +252,12 @@ class RouteMap
$group->get('/institutes/{id}/status-groups', Routes\Institutes\StatusGroupsOfInstitutes::class);
}
private function addAuthenticatedLtiRoutes(RouteCollectorProxy $group): void
{
$group->get('/lti-tools/{id}', Routes\Lti\LtiToolsShow::class);
$group->get('/lti-tools', Routes\Lti\LtiToolsIndex::class);
}
private function addAuthenticatedNewsRoutes(RouteCollectorProxy $group): void
{
$group->post('/courses/{id}/news', Routes\News\CourseNewsCreate::class);
......
<?php
namespace JsonApi\Routes\Lti;
use LtiTool;
use User;
class Authority
{
/**
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public static function canShowLtiTool(User $user, LtiTool $tool): bool
{
return true;
}
/**
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public static function canIndexLtiTools(User $user): bool
{
return true;
}
}
<?php
namespace JsonApi\Routes\Lti;
use JsonApi\Errors\AuthorizationFailedException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use JsonApi\JsonApiController;
class LtiToolsIndex extends JsonApiController
{
protected $allowedPagingParameters = ['offset', 'limit'];
public function __invoke(Request $request, Response $response, $args)
{
if (!Authority::canIndexLtiTools($this->getUser($request))) {
throw new AuthorizationFailedException();
}
list($offset, $limit) = $this->getOffsetAndLimit();
$total = \LtiTool::countBySql('1');
$tools = \LtiTool::findBySQL("1 ORDER BY `name` LIMIT ?, ?", [$offset, $limit]);
return $this->getPaginatedContentResponse($tools, $total);
}
}
<?php
namespace JsonApi\Routes\Lti;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
use JsonApi\Errors\AuthorizationFailedException;
use JsonApi\Errors\RecordNotFoundException;
use JsonApi\JsonApiController;
/**
* Displays a certain lti tool.
*/
class LtiToolsShow extends JsonApiController
{
/**
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function __invoke(Request $request, Response $response, $args)
{
if (!$resource = \LtiTool::find($args['id'])) {
throw new RecordNotFoundException();
}
if (!Authority::canShowLtiTool($this->getUser($request), $resource)) {
throw new AuthorizationFailedException();
}
return $this->getContentResponse($resource);
}
}
......@@ -33,6 +33,7 @@ class SchemaMap
\JsonApi\Models\ForumEntry::class => Schemas\ForumEntry::class,
\Institute::class => Schemas\Institute::class,
\InstituteMember::class => Schemas\InstituteMember::class,
\LtiTool::class => Schemas\LtiTool::class,
\Message::class => Schemas\Message::class,
\SemClass::class => Schemas\SemClass::class,
\Semester::class => Schemas\Semester::class,
......
<?php
namespace JsonApi\Schemas;
use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
class LtiTool extends SchemaProvider
{
const TYPE = 'lti-tools';
public function getId($resource): ?string
{
return $resource->id;
}
public function getAttributes($resource, ContextInterface $context): iterable
{
return [
'name' => $resource->name,
'launch-url' => $resource->launch_url,
'allow-custom-url' => (bool) $resource->allow_custom_url,
'deep-linking' => (bool) $resource->deep_linking,
];
}
public function getRelationships($resource, ContextInterface $context): iterable
{
return [];
}
}
......@@ -118,6 +118,7 @@ abstract class BlockType
ImageMap::class,
KeyPoint::class,
Link::class,
Lti::class,
TableOfContents::class,
Text::class,
Timeline::class,
......
{
"title": "Payload schema of Courseware\\BlockType\\LTI",
"type": "object",
"properties": {
"title": {
"type": "string"
},
"height": {
"type": "string"
},
"tool_id": {
"type": "string"
},
"launch_url": {
"type": "string"
},
"consumer_key": {
"type": "string"
},
"consumer_secret": {
"type": "string"
},
"oauth_signature_method": {
"type": "string"
},
"send_lis_person": {
"type": "boolean"
},
"custom_parameters": {
"type": "string"
}
},
"required": [
"tool_id"
],
"additionalProperties": true
}
<?php
namespace Courseware\BlockTypes;
use Opis\JsonSchema\Schema;
/**
* This class represents the content of a Courseware LTI block.
*
* @author Dennis Benz <debenz@uos.de>
* @license GPL2 or any later version
*
* @since Stud.IP 5.3
*/
class Lti extends BlockType
{
public static function getType(): string
{
return 'lti';
}
public static function getTitle(): string
{
return _('LTI');
}
public static function getDescription(): string
{
return _('Einbinden eines externen Tools.');
}
public function initialPayload(): array
{
return [
'title' => '',
'height' => '640',
'tool_id' => '',
'launch_url' => '',
'consumer_key' => '',
'consumer_secret' => '',
'oauth_signature_method' => 'sha1',
'send_lis_person' => false,
'custom_parameters' => '',
];
}
public static function getJsonSchema(): Schema
{
$schemaFile = __DIR__.'/Lti.json';
return Schema::fromJsonString(file_get_contents($schemaFile));
}
public function getPayload()
{
$payload = $this->decodePayloadString($this->block['payload']);
$user = \User::findCurrent();
// Remove sensitive lti parameters if user has no edit permission
if (!$this->block->getStructuralElement()->canEdit($user)) {
unset($payload['launch_url']);
unset($payload['consumer_key']);
unset($payload['consumer_secret']);
unset($payload['oauth_signature_method']);
unset($payload['send_lis_person']);
unset($payload['custom_parameters']);
}
return $payload;
}
public static function getCategories(): array
{
return ['external'];
}
public static function getContentTypes(): array
{
return ['rich'];
}
public static function getFileTypes(): array
{
return [];
}
}
......@@ -85,6 +85,7 @@ $blockadder-items: (
folder: folder-full,
headline: block-eyecatcher,
iframe: door-enter,
lti: plugin,
key-point: exclaim-circle,
link: link-extern,
table-of-contents: table-of-contents,
......@@ -3679,6 +3680,30 @@ i f r a m e b l o c k
i f r a m e b l o c k e n d
* * * * * * * * * * * * * */
/* * * * * * * * +
l t i b l o c k
* * * * * * * * */
.cw-block-lti {
.cw-block-content {
.cw-block-lti-content {
border: solid thin $content-color-40;
box-sizing: border-box;
}
.cw-block-lti-icon-tool {
@include background-icon(plugin, info, 24);
background-repeat: no-repeat;
display: block;
padding: 16px 16px 16px 40px;
background-position: 10px center;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
/* * * * * * * * * * * *
l t i b l o c k e n d
* * * * * * * * * * * */
/* * * * * * * * * * * *
f o l d e r b l o c k
......
<template>
<div class="cw-block cw-block-lti">
<courseware-default-block
:block="block"
:canEdit="canEdit"
:isTeacher="isTeacher"
:preview="false"
@showEdit="initCurrentData"
@storeEdit="storeBlock"
@closeEdit="initCurrentData"
>
<template #content>
<div v-if="currentTitle !== ''" class="cw-block-title">{{ currentTitle }}</div>
<iframe
v-if="toolId !== ''"
class="cw-block-lti-content"
:src="iframeUrl"
:height="currentHeight"
width="100%"
allowfullscreen
sandbox="allow-downloads allow-forms allow-popups allow-pointer-lock allow-same-origin allow-scripts"
/>
<div v-else class="cw-block-lti-content">
<span class="cw-block-lti-icon-tool">
{{ $gettext('Kein LTI-Tool konfiguriert') }}
</span>
</div>
</template>
<template v-if="canEdit" #edit>
<courseware-tabs>
<courseware-tab
:index="0"
:name="$gettext('Grunddaten')"
:selected="true"
>
<form class="default" @submit.prevent="">
<label>
{{ $gettext('Titel') }}
<input type="text" v-model="currentTitle" />
</label>
<label>
{{ $gettext('Auswahl des externen Tools') }}
<select v-model="currentToolId">
<option v-for="tool in tools" :key="tool.id" :value="tool.id">
{{ tool.name }}
</option>
<option value="0">{{ $gettext('Zugangsdaten selbst eingeben...') }}</option>
</select>
</label>
<label v-show="allowCustomUrl">
{{ $gettext('URL der Anwendung (optional)') }}
<studip-tooltip-icon :text="$gettext('Sie können direkt auf eine URL in der Anwendung verlinken.')"/>
<input type="text" v-model="currentLaunchUrl" :placeholder="currentTool?.launch_url" />
</label>
<div v-show="customToolSelected">
<label class="studiprequired">
{{ $gettext('URL der Anwendung') }}
<span class="asterisk" :title="$gettext('Dies ist ein Pflichtfeld')" aria-hidden="true">*</span>
<studip-tooltip-icon :text="$gettext('Die Betreiber dieses Tools müssen Ihnen eine URL und Zugangsdaten (Consumer-Key und Consumer-Secret) mitteilen.')"/>
<input type="text" v-model="currentLaunchUrl" required>
</label>
<label class="studiprequired">
{{ $gettext('Consumer-Key des LTI-Tools') }}
<span class="asterisk" :title="$gettext('Dies ist ein Pflichtfeld')" aria-hidden="true">*</span>
<input type="text" v-model="currentConsumerKey" required>
</label>
<label class="studiprequired">
{{ $gettext('Consumer-Secret des LTI-Tools') }}
<span class="asterisk" :title="$gettext('Dies ist ein Pflichtfeld')" aria-hidden="true">*</span>
<input type="text" v-model="currentConsumerSecret" required>
</label>
<label>
{{ $gettext('OAuth Signatur Methode des LTI-Tools') }}
<select v-model="currentOauthSignatureMethod">
<option value="sha1">HMAC-SHA1</option>
<option value="sha256">HMAC-SHA256</option>
</select>
</label>
<label>
<input type="checkbox" v-model="currentSendLisPerson" />
{{ $gettext('Nutzerdaten an LTI-Tool senden') }}
<studip-tooltip-icon :text="$gettext('Nutzerdaten 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>
</div>
</form>
</courseware-tab>
<courseware-tab
:index="1"
:name="$gettext('Zusätzliche Einstellungen')"
>
<form class="default" @submit.prevent="">
<label>
{{ $gettext('Höhe') }}
<input type="number" v-model="currentHeight" min="0" />
</label>
<label>
{{ $gettext('Zusätzliche LTI-Parameter') }}
<studip-tooltip-icon :text="$gettext('Ein Wert pro Zeile, Beispiel: Review:Chapter=1.2.56')"/>
<textarea v-model="currentCustomParameters" />
</label>
</form>
</courseware-tab>
</courseware-tabs>
</template>
<template #info>
<p>{{ $gettext('Informationen zum LTI-Block') }}</p>
</template>
</courseware-default-block>
</div>
</template>
<script>
import CoursewareDefaultBlock from "./CoursewareDefaultBlock.vue";
import {blockMixin} from "./block-mixin";
import {mapActions, mapGetters} from "vuex";
import CoursewareTabs from "./CoursewareTabs.vue";
import CoursewareTab from "./CoursewareTab.vue";
export default {
name: 'courseware-lti-block',
mixins: [blockMixin],
components: {
CoursewareTab,
CoursewareTabs,
CoursewareDefaultBlock
},
props: {
block: Object,
canEdit: Boolean,
isTeacher: Boolean,
},
data() {
return {
currentTitle: '',
currentHeight: '',
currentToolId: '',
currentLaunchUrl: '',
currentConsumerKey: '',
currentConsumerSecret: '',
currentOauthSignatureMethod: '',
currentSendLisPerson: false,
currentCustomParameters: '',
}
},
computed: {
...mapGetters({
urlHelper: 'urlHelper',
ltiTools: 'lti-tools/all',
}),
title() {
return this.block?.attributes?.payload?.title;
},
height() {
return this.block?.attributes?.payload?.height;
},
tools() {
return this.ltiTools.map(tool => ({
id: tool.id,
name: tool.attributes.name,
launch_url: tool.attributes['launch-url'],
allow_custom_url: tool.attributes['allow-custom-url'],
}));
},
toolId() {
return this.block?.attributes?.payload?.tool_id;
},
currentTool() {
return this.tools.find(tool => tool.id === this.currentToolId);
},
allowCustomUrl() {
return this.currentTool?.allow_custom_url;
},
customToolSelected() {
return this.currentToolId === '0';
},
launchUrl() {
return this.block?.attributes?.payload?.launch_url;
},
consumerKey() {
return this.block?.attributes?.payload?.consumer_key;
},
consumerSecret() {
return this.block?.attributes?.payload?.consumer_secret;
},
oauthSignatureMethod() {
return this.block?.attributes?.payload?.oauth_signature_method ?? 'sha1';
},
sendLisPerson() {
return this.block?.attributes?.payload?.send_lis_person;
},
customParameters() {
return this.block?.attributes?.payload?.custom_parameters;
},
iframeUrl() {
return this.urlHelper.getURL('dispatch.php/courseware/lti/iframe/' + this.block.id);
},
},
async mounted() {
await this.loadLtiTools();
this.initCurrentData();
},
methods: {
...mapActions({
updateBlock: 'updateBlockInContainer',
loadLtiTools: 'lti-tools/loadAll',
companionWarning: 'companionWarning',
}),
initCurrentData() {
this.currentTitle = this.title;
this.currentHeight = this.height;
this.currentToolId = this.toolId !== '' ? this.toolId : this.currentToolId; // keep preselected tool
this.currentLaunchUrl = this.launchUrl;
this.currentConsumerKey = this.consumerKey;
this.currentConsumerSecret = this.consumerSecret;
this.currentOauthSignatureMethod = this.oauthSignatureMethod;
this.currentSendLisPerson = Boolean(this.sendLisPerson); // prevent undefined value
this.currentCustomParameters = this.customParameters;
},
storeBlock() {
// require url, key and secret if custom tool is selected
if (this.currentToolId === '0') {
if (!this.currentLaunchUrl) {
this.companionWarning({
info: this.$gettext('Bitte geben Sie eine URL der Anwendung an.')
});
return false;
}
if (!this.currentConsumerKey) {
this.companionWarning({
info: this.$gettext('Bitte geben Sie den Consumer-Key des LTI-Tools an.')
});
return false;
}
if (!this.currentConsumerSecret) {
this.companionWarning({
info: this.$gettext('Bitte geben Sie den Consumer-Secret des LTI-Tools an.')
});
return false;
}
}
let attributes = {};
attributes.payload = {};
attributes.payload.title = this.currentTitle;
attributes.payload.height = this.currentHeight;
attributes.payload.tool_id = this.currentToolId;
attributes.payload.launch_url = this.currentLaunchUrl;
if (this.currentToolId === '0') {
attributes.payload.consumer_key = this.currentConsumerKey;
attributes.payload.consumer_secret = this.currentConsumerSecret;
attributes.payload.oauth_signature_method = this.currentOauthSignatureMethod;
attributes.payload.send_lis_person = this.currentSendLisPerson;
}
attributes.payload.custom_parameters = this.currentCustomParameters;
this.updateBlock({
attributes: attributes,
blockId: this.block.id,
containerId: this.block.relationships.container.data.id,
});
},
},
watch: {
tools(value) {
// Preselect tool
if (this.currentToolId === '') {
if (value.length > 0) {
// Preselect first tool
this.currentToolId = value[0].id;
} else {
// Preselect custom tool
this.currentToolId = '0';
}
}
}
},
};
</script>
......@@ -21,6 +21,7 @@ import CoursewareIframeBlock from './CoursewareIframeBlock.vue';
import CoursewareImageMapBlock from './CoursewareImageMapBlock.vue';
import CoursewareKeyPointBlock from './CoursewareKeyPointBlock.vue';
import CoursewareLinkBlock from './CoursewareLinkBlock.vue';
import CoursewareLtiBlock from "./CoursewareLtiBlock.vue";
import CoursewareTableOfContentsBlock from './CoursewareTableOfContentsBlock.vue';
import CoursewareTextBlock from './CoursewareTextBlock.vue';
import CoursewareTimelineBlock from './CoursewareTimelineBlock.vue';
......@@ -56,6 +57,7 @@ const ContainerComponents = {
CoursewareImageMapBlock,
CoursewareKeyPointBlock,
CoursewareLinkBlock,
CoursewareLtiBlock,
CoursewareTableOfContentsBlock,
CoursewareTextBlock,
CoursewareTimelineBlock,
......
......@@ -109,6 +109,7 @@ const mountApp = async (STUDIP, createApp, element) => {
'files',
'file-refs',
'folders',
'lti-tools',
'status-groups',
'users',
'institutes',
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment