diff --git a/.gitignore b/.gitignore index a08302f..b726d58 100644 --- a/.gitignore +++ b/.gitignore @@ -46,5 +46,8 @@ development/node_modules/ # Local History for Visual Studio Code .history/ +## vim +.phpactor.json + # macOS .DS_Store diff --git a/CHANGES.md b/CHANGES.md index 47bea79..8bf4511 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,8 @@ +### v1.3.0 (Build - 2024101601) + +* Added Grading +* Improved Moodle 4.5 compatibility + ### v1.2.1 (Build - 2024091201) * Namespaced styles diff --git a/classes/grading/grading_service.php b/classes/grading/grading_service.php new file mode 100644 index 0000000..5644310 --- /dev/null +++ b/classes/grading/grading_service.php @@ -0,0 +1,111 @@ +. + +// phpcs:disable moodle.NamingConventions.ValidFunctionName.LowercaseMethod +// phpcs:disable moodle.NamingConventions.ValidVariableName.MemberNameUnderscore +// phpcs:disable moodle.Files.RequireLogin.Missing -- doesn't require user to be logged in, as it's an LTI service + +namespace mod_kialo\grading; + +require_once(__DIR__ . '/../../../../config.php'); +require_once(__DIR__ . '/../../lib.php'); +require_once(__DIR__ . '/../../constants.php'); +require_once(__DIR__ . '/../../vendor/autoload.php'); +require_once($CFG->libdir . '/gradelib.php'); + +use grade_item; +use moodle_url; +use OAT\Library\Lti1p3Core\Exception\LtiException; + +/** + * Service offering grading functionalities for LTI requests. + * + * @package mod_kialo + * @copyright 2023 onwards, Kialo GmbH + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class grading_service { + /** + * Returns the line_item describing the grading settings for the given course module. + * + * @param int $courseid + * @param int $cmid + * @param string $resourcelinkid + * @return void + * @throws LtiException + * @throws \coding_exception + * @throws \moodle_exception + */ + public static function get_line_item(int $courseid, int $cmid, string $resourcelinkid): line_item { + $module = get_coursemodule_from_id('kialo', $cmid, $courseid, false, MUST_EXIST); + + $gradeitem = grade_item::fetch(['iteminstance' => $module->instance, 'itemtype' => 'mod']); + if (!$gradeitem) { + $maxscore = 100; + } else { + $maxscore = $gradeitem->grademax; + } + + $lineitem = new line_item(); + + // Assuming this is called from /mod/kialo/lti_lineitem.php. The ID is the URL of the request. + $lineitem->id = (new moodle_url($_SERVER['REQUEST_URI']))->out(false); + $lineitem->label = $module->name; + $lineitem->scoreMaximum = floatval($maxscore); + $lineitem->resourceLinkId = $resourcelinkid; + + return $lineitem; + } + + /** + * Writes grade information. The expected data format is the one defined in the spec, + * see https://www.imsglobal.org/spec/lti-ags/v2p0#example-posting-a-final-score-update. + * + * @param int $courseid + * @param int $cmid + * @param array $data array with required field userId + * @return bool Returns true if the grade information could be persisted. + * @throws LtiException + * @throws \coding_exception + * @throws \dml_exception + */ + public static function update_grade(int $courseid, int $cmid, array $data): bool { + global $DB; + + $module = get_coursemodule_from_id('kialo', $cmid, $courseid, false, MUST_EXIST); + $moduleinstance = $DB->get_record('kialo', ['id' => $module->instance], '*', MUST_EXIST); + + if (!isset($data['userId'])) { + throw new LtiException("Missing userId in the request body"); + } + + // Receive a score for the line item via JSON request body. + $userid = $data['userId']; + $scoregiven = isset($data['scoreGiven']) ? floatval($data['scoreGiven']) : null; + $comment = $data['comment'] ?? ''; + $timestamp = isset($data['timestamp']) ? strtotime($data['timestamp']) : time(); + + $grades = [ + 'userid' => $userid, + 'feedback' => $comment, + 'dategraded' => $timestamp, + ]; + $grades['rawgrade'] = $scoregiven; + + $result = kialo_grade_item_update($moduleinstance, (object) $grades); + return ($result === GRADE_UPDATE_OK || $result === GRADE_UPDATE_MULTIPLE); + } +} diff --git a/classes/grading/line_item.php b/classes/grading/line_item.php new file mode 100644 index 0000000..abfd131 --- /dev/null +++ b/classes/grading/line_item.php @@ -0,0 +1,94 @@ +. + +// phpcs:disable moodle.NamingConventions.ValidFunctionName.LowercaseMethod +// phpcs:disable moodle.NamingConventions.ValidVariableName.MemberNameUnderscore + +namespace mod_kialo\grading; + +/** + * Represents a line item in the LTI 1.3 Assignment and Grading Service. + * + * @package mod_kialo + * @copyright 2023 onwards, Kialo GmbH + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class line_item { + /** + * @var string|null $id + */ + public $id; + + /** + * @var float|null $scoreMaximum + */ + public $scoreMaximum; + + /** + * @var string|null $label + */ + public $label; + + /** + * @var string|null $resourceId + */ + public $resourceId; + + /** + * @var string|null $tag + */ + public $resourceLinkId; + + /** + * @var string|null $tag + */ + public $tag; + + /** + * ISO 8601 timestamp, see https://www.imsglobal.org/spec/lti-ags/v2p0#startdatetime. + * + * @var string|null $startDateTime + */ + public $startDateTime; + + /** + * ISO 8601 timestamp, see https://www.imsglobal.org/spec/lti-ags/v2p0#enddatetime. + * + * @var string|null $endDateTime + */ + public $endDateTime; + + /** + * @var bool|null $gradesReleased + */ + public $gradesReleased; + + /** + * LineItem constructor. + * @param array|null $lineitem + */ + public function __construct(?array $lineitem = null) { + $this->id = $lineitem['id'] ?? null; + $this->scoreMaximum = $lineitem['scoreMaximum'] ?? null; + $this->label = $lineitem['label'] ?? null; + $this->resourceId = $lineitem['resourceId'] ?? null; + $this->resourceLinkId = $lineitem['resourceLinkId'] ?? null; + $this->tag = $lineitem['tag'] ?? null; + $this->startDateTime = $lineitem['startDateTime'] ?? null; + $this->endDateTime = $lineitem['endDateTime'] ?? null; + $this->gradesReleased = $lineitem['gradesReleased'] ?? null; + } +} diff --git a/classes/kialo_config.php b/classes/kialo_config.php index 558f5c5..590d664 100644 --- a/classes/kialo_config.php +++ b/classes/kialo_config.php @@ -19,6 +19,7 @@ use moodle_url; use OAT\Library\Lti1p3Core\Platform\Platform; use OAT\Library\Lti1p3Core\Registration\Registration; +use OAT\Library\Lti1p3Core\Registration\RegistrationRepositoryInterface; use OAT\Library\Lti1p3Core\Security\Key\KeyChainFactory; use OAT\Library\Lti1p3Core\Security\Key\KeyChainInterface; use OAT\Library\Lti1p3Core\Security\Key\KeyInterface; @@ -133,10 +134,11 @@ public function get_client_id(): string { */ public function get_platform(): Platform { return new Platform( - 'kialo-moodle-plugin', // Identifier. - 'Kialo Moodle Plugin', // Name. - (new moodle_url('/mod/kialo'))->out(), // Audience. - (new moodle_url('/mod/kialo/lti_auth.php'))->out(), // OIDC authentication url. + 'kialo-moodle-plugin', // Identifier. + 'Kialo Moodle Plugin', // Name. + (new moodle_url('/mod/kialo'))->out(), // Audience. + (new moodle_url('/mod/kialo/lti_auth.php'))->out(), // OIDC authentication url. + (new moodle_url('/mod/kialo/lti_token.php'))->out(), // OAuth2 access token URL. ); } @@ -180,4 +182,16 @@ public function create_registration(?string $deploymentid = null): Registration $tooljwksurl, // JWKS URL used to download Kialo's keyset. ); } + + /** + * Returns a registration repository for the Kialo plugin. + * + * @param string|null $deploymentid The deployment id to use, or null, if it's not relevant. + * @return RegistrationRepositoryInterface + * @throws \dml_exception + */ + public function get_registration_repository(?string $deploymentid = null): RegistrationRepositoryInterface { + $registration = $this->create_registration($deploymentid); + return new static_registration_repository($registration); + } } diff --git a/classes/kialo_logger.php b/classes/kialo_logger.php new file mode 100644 index 0000000..7f17e65 --- /dev/null +++ b/classes/kialo_logger.php @@ -0,0 +1,189 @@ +. + +namespace mod_kialo; + +use Psr\Log\LoggerInterface; + +/** + * A logger that writes to a file. + * + * @package mod_kialo + * @copyright 2023 onwards, Kialo GmbH + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class kialo_logger implements LoggerInterface { + + /** + * Name of the logger. + * + * @var string + */ + private $name; + + /** + * Creates a new logger that writes to a file. + * + * @param string $name name of the logger + */ + public function __construct(string $name) { + $this->name = $name; + } + + /** + * Write any log message. + * + * @param string $level + * @param string $message + * @param array $context + * @return void + */ + protected function write(string $level, string $message, array $context): void { + global $CFG; + if (!$CFG->debug) { + return; + } + + $log = date('Y-m-d H:i:s') . ' [' . $this->name . '] [' . $level . '] ' . $message . ' ' . json_encode($context) . PHP_EOL; + $path = __DIR__ . '/../kialo.log'; + if (!file_exists($path)) { + touch($path); + } + file_put_contents($path, $log, FILE_APPEND); + } + + /** + * System is unusable. + * + * @param string $message + * @param mixed[] $context + * + * @return void + */ + public function emergency($message, array $context = []) { + $this->write('EMERGENCY', $message, $context); + } + + /** + * Action must be taken immediately. + * + * Example: Entire website down, database unavailable, etc. This should + * trigger the SMS alerts and wake you up. + * + * @param string $message + * @param mixed[] $context + * + * @return void + */ + public function alert($message, array $context = []) { + $this->write('ALERT', $message, $context); + } + + /** + * Critical conditions. + * + * Example: Application component unavailable, unexpected exception. + * + * @param string $message + * @param mixed[] $context + * + * @return void + */ + public function critical($message, array $context = []) { + $this->write('CRITICAL', $message, $context); + } + + /** + * Runtime errors that do not require immediate action but should typically + * be logged and monitored. + * + * @param string $message + * @param mixed[] $context + * + * @return void + */ + public function error($message, array $context = []) { + $this->write('ERROR', $message, $context); + } + + /** + * Exceptional occurrences that are not errors. + * + * Example: Use of deprecated APIs, poor use of an API, undesirable things + * that are not necessarily wrong. + * + * @param string $message + * @param mixed[] $context + * + * @return void + */ + public function warning($message, array $context = []) { + $this->write('WARNING', $message, $context); + } + + /** + * Normal but significant events. + * + * @param string $message + * @param mixed[] $context + * + * @return void + */ + public function notice($message, array $context = []) { + $this->write('NOTICE', $message, $context); + } + + /** + * Interesting events. + * + * Example: User logs in, SQL logs. + * + * @param string $message + * @param mixed[] $context + * + * @return void + */ + public function info($message, array $context = []) { + $this->write('INFO', $message, $context); + } + + /** + * Detailed debug information. + * + * @param string $message + * @param mixed[] $context + * + * @return void + */ + public function debug($message, array $context = []) { + $this->write('DEBUG', $message, $context); + } + + /** + * Logs with an arbitrary level. + * + * @param mixed $level + * @param string $message + * @param mixed[] $context + * + * @return void + * + * @throws \Psr\Log\InvalidArgumentException + */ + public function log($level, $message, array $context = []) { + $this->write($level, $message, $context); + } +} diff --git a/classes/kialo_view.php b/classes/kialo_view.php index 6e6ed2d..3b5894c 100644 --- a/classes/kialo_view.php +++ b/classes/kialo_view.php @@ -25,6 +25,7 @@ namespace mod_kialo; use context_module; +use Psr\Http\Message\ResponseInterface; /** * Helpers for Kialo views. @@ -63,4 +64,28 @@ public static function get_current_group_info(\stdClass $cm, \stdClass $course): $result->groupname = groups_get_group_name($groupid); return $result; } + + /** + * Writes a response to the client. + * + * @param ResponseInterface $response + * @return void + */ + public static function write_response(ResponseInterface $response): void { + $statusline = sprintf( + 'HTTP/%s %s %s', + $response->getProtocolVersion(), + $response->getStatusCode(), + $response->getReasonPhrase() + ); + header($statusline); + + foreach ($response->getHeaders() as $name => $values) { + foreach ($values as $value) { + header(sprintf('%s: %s', $name, $value), false); + } + } + + echo $response->getBody(); + } } diff --git a/classes/lti_flow.php b/classes/lti_flow.php index f261b0e..965c02c 100644 --- a/classes/lti_flow.php +++ b/classes/lti_flow.php @@ -24,27 +24,46 @@ namespace mod_kialo; +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/../constants.php'); + use context_module; +use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\ServerRequest; +use League\OAuth2\Server\Exception\OAuthServerException; +use moodle_url; use OAT\Library\Lti1p3Core\Exception\LtiException; use OAT\Library\Lti1p3Core\Exception\LtiExceptionInterface; use OAT\Library\Lti1p3Core\Message\Launch\Builder\PlatformOriginatingLaunchBuilder; use OAT\Library\Lti1p3Core\Message\Launch\Validator\Platform\PlatformLaunchValidator; +use OAT\Library\Lti1p3Core\Message\LtiMessage; use OAT\Library\Lti1p3Core\Message\LtiMessageInterface; use OAT\Library\Lti1p3Core\Message\Payload\Builder\MessagePayloadBuilder; use OAT\Library\Lti1p3Core\Message\Payload\Claim\DeepLinkingSettingsClaim; use OAT\Library\Lti1p3Core\Message\Payload\Claim\ResourceLinkClaim; +use OAT\Library\Lti1p3Core\Message\Payload\LtiMessagePayloadInterface; use OAT\Library\Lti1p3Core\Resource\LtiResourceLink\LtiResourceLinkInterface; use OAT\Library\Lti1p3Core\Security\Jwks\Fetcher\JwksFetcher; use OAT\Library\Lti1p3Core\Security\Jwt\Builder\Builder as JwtBuilder; +use OAT\Library\Lti1p3Core\Security\Jwt\Parser\Parser; use OAT\Library\Lti1p3Core\Security\Nonce\NonceRepository; +use OAT\Library\Lti1p3Core\Security\OAuth2\Entity\Scope; +use OAT\Library\Lti1p3Core\Security\OAuth2\Factory\AuthorizationServerFactory; +use OAT\Library\Lti1p3Core\Security\OAuth2\Generator\AccessTokenResponseGenerator; +use OAT\Library\Lti1p3Core\Security\OAuth2\Repository\AccessTokenRepository; +use OAT\Library\Lti1p3Core\Security\OAuth2\Repository\ClientRepository; +use OAT\Library\Lti1p3Core\Security\OAuth2\Repository\ScopeRepository; +use OAT\Library\Lti1p3Core\Security\OAuth2\Validator\RequestAccessTokenValidator; use OAT\Library\Lti1p3Core\Security\Oidc\OidcAuthenticator; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; /** * Functions implementing the LTI steps. */ class lti_flow { + /** * The LTI standard requires a stable GUID to be send with the platform information. * See https://www.imsglobal.org/spec/lti/v1p3#platform-instance-claim. @@ -133,6 +152,28 @@ public static function assign_lti_roles($context): array { return $roles; } + /** + * Generates a resource link ID based on the course module ID. + * This is an arbitrary string, but it must be unique and identify the + * Kialo module in Moodle so we can link back to it later. + * + * @param int $coursemoduleid + * @return string + */ + public static function resource_link_id(int $coursemoduleid): string { + return 'resource-link-' . $coursemoduleid; + } + + /** + * Returns the course module ID from the resource link ID. + * + * @param string $resourcelinkid + * @return int + */ + public static function parse_resource_link_id(string $resourcelinkid): int { + return (int) preg_replace('/resource-link-/', '', $resourcelinkid); + } + /** * Initializes an LTI flow that ends up just taking the user to the target_link_uri on the tool (i.e. Kialo). * @@ -176,15 +217,15 @@ public static function init_resource_link( $roles, [ // See https://www.imsglobal.org/spec/lti/v1p3#resource-link-claim. - new ResourceLinkClaim('resource-link-' . $coursemoduleid, '', ''), + new ResourceLinkClaim(self::resource_link_id($coursemoduleid), '', ''), // We provide the course ID as the context ID so that discussion links are scoped to the course. // See https://www.imsglobal.org/spec/lti/v1p3#context-claim. - "https://purl.imsglobal.org/spec/lti/claim/context" => [ + LtiMessagePayloadInterface::CLAIM_LTI_CONTEXT => [ "id" => $courseid, ], - "https://purl.imsglobal.org/spec/lti/claim/custom" => count($customclaims) > 0 ? $customclaims : null, + LtiMessagePayloadInterface::CLAIM_LTI_CUSTOM => count($customclaims) > 0 ? $customclaims : null, ], ); } @@ -216,7 +257,7 @@ public static function validate_deep_linking_response( throw new LtiException($message->getError()); } - if ($payload->getMessageType() !== "LtiDeepLinkingResponse") { + if ($payload->getMessageType() !== LtiMessage::LTI_MESSAGE_TYPE_DEEP_LINKING_RESPONSE) { throw new LtiException('Expected LtiDeepLinkingResponse'); } @@ -306,7 +347,7 @@ public static function init_deep_link(int $courseid, string $moodleuserid, strin // We provide the course ID as the context ID so that discussion links are scoped to the course. // See https://www.imsglobal.org/spec/lti/v1p3#context-claim. - "https://purl.imsglobal.org/spec/lti/claim/context" => [ + LtiMessagePayloadInterface::CLAIM_LTI_CONTEXT => [ "id" => $courseid, ], ] @@ -340,11 +381,12 @@ public static function lti_auth(): LtiMessageInterface { $payloadbuilder->withClaim('kialo_plugin_version', kialo_config::get_release()); // See https://www.imsglobal.org/spec/lti/v1p3#platform-instance-claim. - $payloadbuilder->withClaim('https://purl.imsglobal.org/spec/lti/claim/tool_platform', [ + $payloadbuilder->withClaim(LtiMessagePayloadInterface::CLAIM_LTI_TOOL_PLATFORM, [ 'guid' => self::PLATFORM_GUID, 'product_family_code' => self::PRODUCT_FAMILY_CODE, 'version' => $CFG->version, ]); + self::add_grading_service($payloadbuilder, $request); // Create the OIDC authenticator. $authenticator = new OidcAuthenticator($registrationrepository, $userauthenticator, $payloadbuilder); @@ -352,4 +394,97 @@ public static function lti_auth(): LtiMessageInterface { // Perform the login authentication (delegating to the $userAuthenticator with the hint 'loginHint'). return $authenticator->authenticate($request); } + + /** + * Adds claims necessary to inform LTI consumers about the assignment and grading service we implemented + * according to https://www.imsglobal.org/spec/lti-ags/v2p0. Essentially it provides the endpoints necessary + * to use the service from the Kialo app (the LTI tool / consumer). + * + * @param MessagePayloadBuilder $payloadbuilder Payload to add claims to (for the LTI authentication response) + * @param ServerRequestInterface $request The LTI authentication request + * @return void + * @throws LtiExceptionInterface + * @throws \moodle_exception + */ + public static function add_grading_service(MessagePayloadBuilder $payloadbuilder, ServerRequestInterface $request): void { + // Get required context for service params from original JWT token. See init_resource_link and init_deep_link. + $originaltoken = (new Parser())->parse(LtiMessage::fromServerRequest($request)->getParameters()->get('lti_message_hint')); + $courseid = $originaltoken->getClaims()->getMandatory(LtiMessagePayloadInterface::CLAIM_LTI_CONTEXT)['id']; + $resourcelink = $originaltoken->getClaims()->get(LtiMessagePayloadInterface::CLAIM_LTI_RESOURCE_LINK); + $serviceparams = [ + "course_id" => $courseid, + ]; + + // Resource link claim is only present in resource link flows, not during deep linking. + if ($resourcelink) { + $serviceparams['resource_link_id'] = $resourcelink['id']; + $serviceparams['cmid'] = self::parse_resource_link_id($resourcelink['id']); + } + + $payloadbuilder->withClaim(LtiMessagePayloadInterface::CLAIM_LTI_AGS, [ + "scope" => MOD_KIALO_LTI_AGS_SCOPES, + + // This is the endpoint used by Kialo to get the line item details and post student scores. + "lineitem" => (new moodle_url('/mod/kialo/lti_lineitem.php', $serviceparams))->out(false), + + // The lineitems (plural) endpoint is used by Kialo to look up line items by resource link ID + // only if the line item URL is not included in the launch data. + // Since our plugin always includes the line item URL in the launch data (in this very claim), + // Kialo never needs to use this endpoint. + // So this endpoint is currently not necessary and therefore not implemented. + // But if we end up implementing it, this is what it will be called. + "lineitems" => (new moodle_url('/mod/kialo/lti_lineitems.php', $serviceparams))->out(false), + ]); + } + + /** + * Generates an access token for the service to use when calling the LTI service endpoints. + * @return ResponseInterface + * @throws \dml_exception + */ + public static function generate_service_access_token(): ResponseInterface { + $kialoconfig = kialo_config::get_instance(); + $registrationrepo = $kialoconfig->get_registration_repository(); + + $factory = new AuthorizationServerFactory( + new ClientRepository($registrationrepo, null, new kialo_logger("ClientRepository")), + new AccessTokenRepository(moodle_cache::access_token_cache(), new kialo_logger("AccessTokenRepository")), + new ScopeRepository(array_map(fn ($scope): Scope => new Scope($scope), MOD_KIALO_LTI_AGS_SCOPES)), + $kialoconfig->get_platform_keychain()->getPrivateKey()->getContent(), + ); + + $keychainrepo = new static_keychain_repository($kialoconfig->get_platform_keychain()); + $generator = new AccessTokenResponseGenerator($keychainrepo, $factory); + $request = ServerRequest::fromGlobals(); + $response = new Response(); + + try { + // Validate assertion, generate and sign access token response, using the key chain private key. + $keychainidentifier = $kialoconfig->get_platform_keychain()->getIdentifier(); + $response = $generator->generate($request, $response, $keychainidentifier); + } catch (OAuthServerException $exception) { + $response = $exception->generateHttpResponse($response); + } + + return $response; + } + + /** + * Authenticates a service request using the LTI access token. Throws an error if authentication fails. + * @param array $scopes The scopes that the service request must have. + * @return void + * @throws \dml_exception + */ + public static function authenticate_service_request(array $scopes): void { + $kialoconfig = kialo_config::get_instance(); + $registrationrepo = $kialoconfig->get_registration_repository(); + $validator = new RequestAccessTokenValidator($registrationrepo, new kialo_logger("RequestAccessTokenValidator")); + + // Validate request provided access token using the registration platform public key, against allowed scopes. + $result = $validator->validate(ServerRequest::fromGlobals(), $scopes); + + if ($result->hasError()) { + throw new \dml_exception($result->getError()); + } + } } diff --git a/classes/moodle_cache.php b/classes/moodle_cache.php index dc877ba..a004f7a 100644 --- a/classes/moodle_cache.php +++ b/classes/moodle_cache.php @@ -44,6 +44,14 @@ public static function nonce_cache() { return new self("nonces"); } + /** + * Session-based cache for access tokens. + * @return self + */ + public static function access_token_cache() { + return new self("access_tokens"); + } + /** * Generic cache using the Moodle cache API. * @param string $name Name of a cache defined in `db/caches.php`. diff --git a/classes/static_keychain_repository.php b/classes/static_keychain_repository.php new file mode 100644 index 0000000..74062db --- /dev/null +++ b/classes/static_keychain_repository.php @@ -0,0 +1,76 @@ +. + +/** + * Key chain repository needed for the LTI implementation. + * + * @package mod_kialo + * @copyright 2023 onwards, Kialo GmbH + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_kialo; + +use OAT\Library\Lti1p3Core\Registration\RegistrationInterface; +use OAT\Library\Lti1p3Core\Security\Key\KeyChainInterface; +use OAT\Library\Lti1p3Core\Security\Key\KeyChainRepositoryInterface; + +// phpcs:disable moodle.NamingConventions.ValidFunctionName.LowercaseMethod +// phpcs:disable moodle.NamingConventions.ValidVariableName.VariableNameLowerCase + +/** + * A static keychain repository that always returns the same keychain. + */ +class static_keychain_repository implements KeyChainRepositoryInterface { + + /** + * The registration that is returned by the repository. + * @var KeyChainInterface + */ + private $keychain; + + /** + * Creates a new registration repository that only contains the given registration. + * @param KeyChainInterface $keychain + */ + public function __construct(KeyChainInterface $keychain) { + $this->keychain = $keychain; + } + + /** + * If the given identifier matches the keychain identifier, the keychain is returned. + * @param string $identifier The registration identifier. + * @return RegistrationInterface|null + */ + public function find(string $identifier): ?KeyChainInterface { + if ($this->keychain->getIdentifier() !== $identifier) { + return null; + } + return $this->keychain; + } + + /** + * If the given key set name matches the keychain set name, the keychain is returned. + * @param string $keySetName + * @return array|KeyChainInterface[] + */ + public function findByKeySetName(string $keySetName): array { + if ($this->keychain->getKeySetName() !== $keySetName) { + return []; + } + return [$this->keychain]; + } +} diff --git a/constants.php b/constants.php index 1145253..620999a 100644 --- a/constants.php +++ b/constants.php @@ -23,6 +23,17 @@ * */ +use Packback\Lti1p3\LtiConstants; + define("MOD_KIALO_TERMS_LINK", "https://www.kialo-edu.com/terms"); define("MOD_KIALO_PRIVACY_LINK", "https://www.kialo-edu.com/privacy"); define("MOD_KIALO_DATA_SECURITY_LINK", "https://support.kialo-edu.com/en/hc/kialo-edu-data-security-and-privacy-plan/"); + +/** + * Scopes required for the Kialo LTI 1.3 assignment and grading service. + */ +const MOD_KIALO_LTI_AGS_SCOPES = [ + LtiConstants::AGS_SCOPE_LINEITEM_READONLY, + LtiConstants::AGS_SCOPE_RESULT_READONLY, + LtiConstants::AGS_SCOPE_SCORE, +]; diff --git a/db/caches.php b/db/caches.php index f722a7f..2951881 100644 --- a/db/caches.php +++ b/db/caches.php @@ -28,4 +28,7 @@ $definitions = [ // Cache for nonces used in LTI 1.3 authentication, stored in the default PHP session. 'nonces' => ['mode' => cache_store::MODE_SESSION], + + // Cache for access tokens for LTI services, stored in the application cache. + 'access_tokens' => ['mode' => cache_store::MODE_APPLICATION], ]; diff --git a/db/install.xml b/db/install.xml index a73ca1f..1f4bd9e 100644 --- a/db/install.xml +++ b/db/install.xml @@ -16,6 +16,7 @@ + diff --git a/db/upgrade.php b/db/upgrade.php index 18cb996..c3459f3 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -22,11 +22,33 @@ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +/** + * In this version grading was first introduced. + */ +const VERSION_GRADING_1 = 2024091805; + /** * Custom upgrade steps. * @param int $oldversion */ function xmldb_kialo_upgrade($oldversion = 0): bool { - // This is the first public version, so there is nothing to do yet. + global $CFG, $DB; + + $dbman = $DB->get_manager(); + + if ($oldversion < VERSION_GRADING_1) { + // Define field 'grade' to be added to kialo. + $table = new xmldb_table('kialo'); + $field = new xmldb_field('grade', XMLDB_TYPE_INTEGER, '10', false, XMLDB_NOTNULL, false, 100, null); + + // Conditionally launch add field 'grade'. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Kialo savepoint reached. + upgrade_mod_savepoint(true, VERSION_GRADING_1, 'kialo'); + } + return true; } diff --git a/development/.env.example b/development/.env.example index 3e95659..5f73843 100644 --- a/development/.env.example +++ b/development/.env.example @@ -15,7 +15,7 @@ MOODLE_HOST=192.168.X.X:8080 # Alternatively, you could also add "127.0.0.1 moodle" to your system's `/etc/hosts` file, # and use that, since `moodle` is the hostname of the Moodle Docker container, -which is accessible from within the Kialo Docker container. +# which is accessible from within the Kialo Docker container. # MOODLE_HOST=moodle:8080 # Moodle branch to use. By default we use the latest version (the `main` branch). diff --git a/development/bundle.sh b/development/bundle.sh index 529ab75..86b3d40 100755 --- a/development/bundle.sh +++ b/development/bundle.sh @@ -14,9 +14,13 @@ cd development ./sync.sh # Create a new ZIP file -rm mod_kialo.zip +rm -f mod_kialo.zip rm -rf mod_kialo/vendor_extra -zip -qr mod_kialo.zip mod_kialo + +# The folder in the zip file needs to be called just "kialo" +mv mod_kialo kialo +zip -qr mod_kialo.zip kialo +mv kialo mod_kialo # restore full dependencies (including dev dependencies) cd .. diff --git a/lang/en/kialo.php b/lang/en/kialo.php index 5ec8c24..71a8084 100644 --- a/lang/en/kialo.php +++ b/lang/en/kialo.php @@ -55,7 +55,7 @@ $string['kialoname'] = 'Activity Name'; $string['kialosettings'] = 'Settings'; $string['kialourl'] = 'Kialo URL'; -$string['kialourl_desc'] = 'The URL of the Kialo instance to use.'; +$string['kialourl_desc'] = 'The URL of the Kialo instance to use. Leave blank to use the default (edu-prod) or to use the value of the TARGET_KIALO_URL environment variable instead.'; $string['modulename'] = 'Kialo Discussion'; $string['modulename_help'] = 'The Kialo Discussion activity allows you to include a Kialo discussion in your Moodle course. Students can participate in the discussion directly from Moodle, without having to manually create Kialo accounts. Kialo discussions are a great way to teach and train critical thinking, argumentation and to facilitate thoughtful classroom discussions.'; $string['modulename_link'] = 'https://support.kialo-edu.com/en/hc/moodle'; diff --git a/lib.php b/lib.php index e131054..75fb9e9 100644 --- a/lib.php +++ b/lib.php @@ -40,6 +40,9 @@ function kialo_supports($feature) { case FEATURE_GROUPINGS: return true; + case FEATURE_GRADE_HAS_GRADE: + return true; + default: return null; } @@ -67,8 +70,16 @@ function kialo_add_instance($moduleinstance, $mform = null) { global $DB; $moduleinstance->timecreated = time(); + if (!isset($moduleinstance->grade)) { + $moduleinstance->grade = 100; + } $id = $DB->insert_record('kialo', $moduleinstance); + $moduleinstance->id = $id; + + if ($id) { + kialo_update_grades($moduleinstance); + } return $id; } @@ -89,7 +100,11 @@ function kialo_update_instance($moduleinstance, $mform = null) { $moduleinstance->timemodified = time(); $moduleinstance->id = $moduleinstance->instance; - return $DB->update_record('kialo', $moduleinstance); + $result = $DB->update_record('kialo', $moduleinstance); + if ($result) { + kialo_update_grades($moduleinstance); + } + return $result; } /** @@ -168,3 +183,85 @@ function kialo_update_visibility_depending_on_accepted_terms(): void { \core_plugin_manager::instance()->reset_caches(); } } + +/** + * Writes grades for the kialo activity module. + * + * @param stdClass $kialo kialo module instance + * @param stdClass|null $grades grade object + * @return int Returns GRADE_UPDATE_OK, GRADE_UPDATE_FAILED, GRADE_UPDATE_MULTIPLE or GRADE_UPDATE_ITEM_LOCKED + * @var moodle_database $DB + */ +function kialo_grade_item_update(stdClass $kialo, ?stdClass $grades = null): int { + global $DB; + + if ($grades !== null) { + // The user should exist. + if (!($user = $DB->get_record('user', ['id' => $grades->userid]))) { + return GRADE_UPDATE_FAILED; + } + + // The user should be enrolled in the course. + $context = \context_course::instance($kialo->course); + if (!is_enrolled($context, $user)) { + return GRADE_UPDATE_FAILED; + } + } + + $params = [ + 'itemname' => $kialo->name, + 'idnumber' => $kialo->cmidnumber ?? '', + ]; + + if ($kialo->grade >= 0) { + $params['gradetype'] = GRADE_TYPE_VALUE; + $params['grademax'] = $kialo->grade; + $params['grademin'] = 0; + } else if ($kialo->grade < 0) { + $params['gradetype'] = GRADE_TYPE_SCALE; + $params['scaleid'] = -$kialo->grade; + } else { + $params['gradetype'] = GRADE_TYPE_TEXT; // Allow text comments only. + } + + if ($grades === 'reset') { + $params['reset'] = true; + $grades = null; + } + + return grade_update('mod/kialo', $kialo->course, 'mod', 'kialo', $kialo->id, 0, $grades, $params); +} + +/** + * Gets the grades of a single user. + * @param stdClass $kialo + * @param int $userid + * @return stdClass + */ +function kialo_get_user_grades(stdClass $kialo, int $userid): stdClass { + return grade_get_grades($kialo->course, 'mod', 'kialo', $kialo->id, $userid); +} + +/** + * Updates the grades for all users in the given kialo activity. + * + * @param stdClass $kialo + * @param int $userid + * @param bool $nullifnone + * @return void + */ +function kialo_update_grades(stdClass $kialo, int $userid = 0, bool $nullifnone = true): void { + global $CFG, $DB; + require_once($CFG->libdir.'/gradelib.php'); + + if ($userid > 0 && $grades = kialo_get_user_grades($kialo, $userid)) { + kialo_grade_item_update($kialo, $grades); + } else if ($userid && $nullifnone) { + $grade = new stdClass(); + $grade->userid = $userid; + $grade->rawgrade = null; + kialo_grade_item_update($kialo, $grade); + } else { + kialo_grade_item_update($kialo); + } +} diff --git a/lti_lineitem.php b/lti_lineitem.php new file mode 100644 index 0000000..fb7aed4 --- /dev/null +++ b/lti_lineitem.php @@ -0,0 +1,64 @@ +. + +/** + * Handles `GET /lti_lineitem.php` and `POST /lti_linteitem.php/scores` requests for + * LTI 1.3 Assignment and Grading Service line items. + * See LTI 1.3 Assignment and Grading Service specification: https://www.imsglobal.org/spec/lti-ags/v2p0. + * + * @package mod_kialo + * @copyright 2023 onwards, Kialo GmbH + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * + * @var moodle_database $DB + * @var stdClass $CFG see moodle's config.php + */ + +// phpcs:disable moodle.Files.RequireLogin.Missing + +require_once(__DIR__ . '/../../config.php'); +require_once(__DIR__ . '/lib.php'); +require_once(__DIR__ . '/constants.php'); +require_once(__DIR__ . '/vendor/autoload.php'); +require_once($CFG->libdir . '/gradelib.php'); + +use mod_kialo\grading\grading_service; +use mod_kialo\lti_flow; + +// This request can only be performed with a valid access token obtained from the token endpoint. +lti_flow::authenticate_service_request(MOD_KIALO_LTI_AGS_SCOPES); + +$courseid = required_param('course_id', PARAM_INT); +$cmid = required_param('cmid', PARAM_INT); +$resourcelinkid = required_param('resource_link_id', PARAM_TEXT); + +if (isset($_SERVER['PATH_INFO']) && $_SERVER['PATH_INFO'] == '/scores') { + // Receive a score for the line item via JSON request body. + $input = file_get_contents('php://input'); + $data = json_decode($input, true); + + if (grading_service::update_grade($courseid, $cmid, $data)) { + http_response_code(204); + } else { + http_response_code(400); + } +} else { + // Return the line item information. + $lineitem = grading_service::get_line_item($courseid, $cmid, $resourcelinkid); + + header('Content-Type: application/json; utf-8'); + echo json_encode($lineitem, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); +} diff --git a/lti_token.php b/lti_token.php new file mode 100644 index 0000000..33a754d --- /dev/null +++ b/lti_token.php @@ -0,0 +1,39 @@ +. + +/** + * This endpoint returns a service access token for the use with the LTI services (e.g. for grading). + * See also https://github.com/oat-sa/lib-lti1p3-core/blob/master/doc/service/service-server.md. + * + * @package mod_kialo + * @copyright 2023 onwards, Kialo GmbH + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +// phpcs:disable moodle.Files.RequireLogin.Missing +define('NO_DEBUG_DISPLAY', true); +define('NO_MOODLE_COOKIES', true); + +require_once(__DIR__ . '/../../config.php'); +require_once(__DIR__ . '/lib.php'); +require_once(__DIR__ . '/constants.php'); +require_once('vendor/autoload.php'); + +use mod_kialo\kialo_view; +use mod_kialo\lti_flow; + +$response = lti_flow::generate_service_access_token(); +kialo_view::write_response($response); diff --git a/mod_form.php b/mod_form.php index 6d63ac9..99cd62f 100644 --- a/mod_form.php +++ b/mod_form.php @@ -135,6 +135,9 @@ public function definition() { // Add standard elements. $this->standard_coursemodule_elements(); + // Add grading elements. + $this->standard_grading_coursemodule_elements(); + // Add standard buttons. $this->add_action_buttons(); } diff --git a/openid-configuration.php b/openid-configuration.php index b28aa60..97f4db0 100644 --- a/openid-configuration.php +++ b/openid-configuration.php @@ -28,35 +28,33 @@ define('NO_MOODLE_COOKIES', true); require_once(__DIR__ . '/../../config.php'); -/** - * Returns the LTI capabilities that are supported by this plugin. - * - * @return array - */ -function lti_get_capabilities() { - $capabilities = [ - 'basic-lti-launch-request' => '', - 'ContentItemSelectionRequest' => '', - 'ResourceLink.id' => 'resource_link_id', - 'ResourceLink.title' => 'resource_link_title', - 'ResourceLink.description' => 'resource_link_description', - 'User.id' => 'user_id', - 'User.username' => '$USER->username', - 'Person.name.full' => 'lis_person_name_full', - 'Person.name.given' => 'lis_person_name_given', - 'Person.name.middle' => 'lis_person_name_given', - 'Person.name.family' => 'lis_person_name_family', - 'Person.email.primary' => 'lis_person_contact_email_primary', - 'Person.sourcedId' => 'lis_person_sourcedid', - 'Membership.role' => 'roles', - 'Result.sourcedId' => 'lis_result_sourcedid', - 'Result.autocreate' => 'lis_outcome_service_url', - ]; +$capabilities = [ + 'basic-lti-launch-request' => '', + 'ContentItemSelectionRequest' => '', + 'ResourceLink.id' => 'resource_link_id', + 'ResourceLink.title' => 'resource_link_title', + 'ResourceLink.description' => 'resource_link_description', + 'User.id' => 'user_id', + 'User.username' => '$USER->username', + 'Person.name.full' => 'lis_person_name_full', + 'Person.name.given' => 'lis_person_name_given', + 'Person.name.middle' => 'lis_person_name_given', + 'Person.name.family' => 'lis_person_name_family', + 'Person.email.primary' => 'lis_person_contact_email_primary', + 'Person.sourcedId' => 'lis_person_sourcedid', + 'Membership.role' => 'roles', + 'Result.sourcedId' => 'lis_result_sourcedid', + 'Result.autocreate' => 'lis_outcome_service_url', +]; - return $capabilities; -} +$scopes = [ + 'openid', + "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly", + "https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly", + "https://purl.imsglobal.org/spec/lti-ags/scope/score", + "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem", +]; -$scopes = ['openid']; $conf = [ 'issuer' => $CFG->wwwroot . '/mod/kialo', 'token_endpoint' => (new moodle_url('/mod/kialo/lti_token.php'))->out(false), @@ -69,7 +67,16 @@ function lti_get_capabilities() { 'response_types_supported' => ['id_token'], 'subject_types_supported' => ['public', 'pairwise'], 'id_token_signing_alg_values_supported' => ['RS256'], - 'claims_supported' => ['sub', 'iss', 'name', 'given_name', 'middle_name', 'family_name', 'email', 'picture', 'locale', + 'claims_supported' => [ + 'sub', + 'iss', + 'name', + 'given_name', + 'middle_name', + 'family_name', + 'email', + 'picture', + 'locale', 'zoneinfo', ], // This is similar to https://www.imsglobal.org/spec/lti/v1p3#platform-instance-claim, but not the same! @@ -77,9 +84,10 @@ function lti_get_capabilities() { 'product_family_code' => 'moodle_kialo_plugin', 'version' => $CFG->release, 'messages_supported' => [ - ['type' => 'LtiResourceLinkRequest'], ['type' => 'LtiDeepLinkingRequest', 'placements' => ['ContentArea']], + ['type' => 'LtiResourceLinkRequest'], + ['type' => 'LtiDeepLinkingRequest', 'placements' => ['ContentArea']], ], - 'variables' => array_keys(lti_get_capabilities()), + 'variables' => array_keys($capabilities), ], ]; diff --git a/settings.php b/settings.php index a441834..fe684d7 100644 --- a/settings.php +++ b/settings.php @@ -64,7 +64,7 @@ 'mod_kialo/kialourl', new lang_string('kialourl', 'mod_kialo'), new lang_string('kialourl_desc', 'mod_kialo'), - 'https://www.kialo-edu.com', + '', // If left blank, this defaults to the TARGET_KIALO_URL env var or 'https://www.kialo-edu.com'. ); $settings->add($kialourl); diff --git a/tests/classes/kialo_config_test.php b/tests/classes/kialo_config_test.php index 6b599a6..24b9140 100644 --- a/tests/classes/kialo_config_test.php +++ b/tests/classes/kialo_config_test.php @@ -103,6 +103,7 @@ public function test_get_platform(): void { $this->assertEquals("https://www.example.com/moodle/mod/kialo", $platform->getAudience()); $this->assertEquals("https://www.example.com/moodle/mod/kialo/lti_auth.php", $platform->getOidcAuthenticationUrl()); + $this->assertEquals("https://www.example.com/moodle/mod/kialo/lti_token.php", $platform->getOAuth2AccessTokenUrl()); $this->assertEquals("kialo-moodle-plugin", $platform->getIdentifier()); $this->assertEquals("Kialo Moodle Plugin", $platform->getName()); } @@ -142,4 +143,19 @@ public function test_registration(): void { $this->assertEquals("https://www.kialo-edu.com/lti/jwks.json", $registration->getToolJwksUrl()); $this->assertEquals("https://www.example.com/moodle/mod/kialo/lti_jwks.php", $registration->getPlatformJwksUrl()); } + + /** + * Tests the registration repository. + * @covers \mod_kialo\kialo_config::get_instance::get_registration_repository + */ + public function test_registration_repository(): void { + $repo = kialo_config::get_instance()->get_registration_repository("DEPLID1234"); + $this->assertNotNull($repo); + + $this->assertNull($repo->find("NONEXISTENT")); + + $registration = $repo->find("kialo-moodle-registration"); + $this->assertNotNull($registration); + $this->assertEquals("kialo-moodle-registration", $registration->getIdentifier()); + } } diff --git a/tests/classes/kialo_view_test.php b/tests/classes/kialo_view_test.php index cec8a2c..264da6e 100644 --- a/tests/classes/kialo_view_test.php +++ b/tests/classes/kialo_view_test.php @@ -25,6 +25,7 @@ namespace mod_kialo; +use GuzzleHttp\Psr7\Response; use stdClass; defined('MOODLE_INTERNAL') || die(); @@ -224,4 +225,38 @@ public function test_group_info_grouping(): void { $this->assertEquals("grouping-{$grouping->id}", $groupinfo->groupid); $this->assertEquals($grouping->name, $groupinfo->groupname); } + + /** + * Tests writing an HTTP response. + * + * @return void + * @covers \mod_kialo\kialo_view::write_response + */ + public function test_write_response(): void { + $headers = [ + 'Content-Type' => 'application/json', + 'X-Test-Header' => 'test', + ]; + $json = json_encode(['message' => 'Hello, world!']); + $response = new Response(200, $headers, $json); + + ob_start(); + kialo_view::write_response($response); + $output = ob_get_clean(); + + $this->assertStringContainsString('HTTP/1.1 200 OK', $output); + $this->assertStringContainsString('Content-Type: application/json', $output); + $this->assertStringContainsString('X-Test-Header: test', $output); + $this->assertStringContainsString($json, $output); + } +} + +/** + * Mocks the header function, simpling printing to stdout so it can be asserted. + * + * @param string $str + * @return void + */ +function header(string $str): void { + echo $str . '\n'; } diff --git a/tests/classes/lti_flow_test.php b/tests/classes/lti_flow_test.php index 690d7d8..0b9bfeb 100644 --- a/tests/classes/lti_flow_test.php +++ b/tests/classes/lti_flow_test.php @@ -39,6 +39,7 @@ use Lcobucci\JWT\Validation\Constraint\SignedWith; use Lcobucci\JWT\Validation\Validator; use OAT\Library\Lti1p3Core\Exception\LtiException; +use OAT\Library\Lti1p3Core\Message\Payload\LtiMessagePayloadInterface; use OAT\Library\Lti1p3Core\Security\Jwt\Converter\KeyConverter; use OAT\Library\Lti1p3Core\Security\Key\Key; use OAT\Library\Lti1p3Core\Security\Key\KeyChainFactory; @@ -520,6 +521,9 @@ private function prepare_lti_auth_request($signer = self::SIGNER_PLATFORM, ?call "https://purl.imsglobal.org/spec/lti/claim/target_link_uri", kialo_config::get_instance()->get_tool_url() ) + ->withClaim(LtiMessagePayloadInterface::CLAIM_LTI_CONTEXT, [ + "id" => $this->course->id, + ]) ->withClaim("registration_id", "kialo-moodle-registration"); if ($callback) { @@ -544,7 +548,11 @@ public function test_lti_auth(): void { $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id, "student"); // Given a redirect GET request from Kialo with the LTI auth response. - $this->prepare_lti_auth_request(self::SIGNER_PLATFORM); + $this->prepare_lti_auth_request(self::SIGNER_PLATFORM, function (Builder $builder) { + $builder->withClaim(LtiMessagePayloadInterface::CLAIM_LTI_RESOURCE_LINK, [ + "id" => lti_flow::resource_link_id($this->cmid), + ]); + }); // Do the actual LTI auth step, as if the user was just redirected to the plugin by Kialo. $message = lti_flow::lti_auth(); @@ -586,6 +594,47 @@ public function test_lti_auth(): void { $this->assertEquals($expectedpicture->get_url($PAGE), $token->claims()->get("picture")); } + /** + * The LTI authentication response should contain information about the AGS (Assignment and Grading) service endpoints. + * + * @covers \mod_kialo\lti_flow::lti_auth + */ + public function test_lti_auth_response_contains_grading_service(): void { + // The current user must be at least a student in the course. But this LTI step works the same for students and teachers. + $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id, "student"); + + // Given a redirect GET request from Kialo with the LTI auth response. + $this->prepare_lti_auth_request(self::SIGNER_PLATFORM, function (Builder $builder) { + $builder->withClaim(LtiMessagePayloadInterface::CLAIM_LTI_RESOURCE_LINK, [ + "id" => lti_flow::resource_link_id($this->cmid), + ]); + }); + + // The response message should contain information about the Assignment and Grading service endpoints of the plugin. + $message = lti_flow::lti_auth(); + $token = $this->assert_jwt_signed_by_platform($message->getParameters()->get("id_token")); + $claim = $token->claims()->get(LtiMessagePayloadInterface::CLAIM_LTI_AGS); + + $this->assertEquals(MOD_KIALO_LTI_AGS_SCOPES, $claim["scope"]); + + $courseid = $this->course->id; + $cmid = $this->cmid; + $resourcelinkid = lti_flow::resource_link_id($cmid); + + $this->assertEquals( + "https://www.example.com/moodle" . + "/mod/kialo/lti_lineitem.php?course_id={$courseid}&resource_link_id={$resourcelinkid}&cmid={$cmid}", + $claim["lineitem"] + ); + + // Unused, but included for potential future use. + $this->assertEquals( + "https://www.example.com/moodle" . + "/mod/kialo/lti_lineitems.php?course_id={$courseid}&resource_link_id={$resourcelinkid}&cmid={$cmid}", + $claim["lineitems"] + ); + } + /** * Provides test scenarios of invalid LTI auth requests. * diff --git a/tests/classes/lti_token_test.php b/tests/classes/lti_token_test.php new file mode 100644 index 0000000..f866fe1 --- /dev/null +++ b/tests/classes/lti_token_test.php @@ -0,0 +1,55 @@ +. + +/** + * LTI access token endpoint tests. + * + * @package mod_kialo + * @category test + * @copyright 2023 onwards, Kialo GmbH + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_kialo; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/../../vendor/autoload.php'); + +/** + * Tests the LTI flow. + */ +final class lti_token_test extends \advanced_testcase { + + /** + * Tests the expected result when just calling this endpoint with a GET request without necessary parameters. + * @return void + * @covers \mod_kialo\lti_flow::generate_service_access_token + */ + public function test_access_token_request_invalid_get(): void { + $response = lti_flow::generate_service_access_token(); + $this->assertEquals(400, $response->getStatusCode()); + $this->assertStringContainsString("unsupported_grant_type", $response->getBody()); + } + + // Not testing the successful cases for the service access request and validation here due to effort and complexity. + // It's already covered by the library's tests and end-to-end tests. + // However, if there is time, eventually tests for these shoul be added: + // - generate_service_access_token + // - authenticate_service_request + // + // The tests should ensure that access tokens can be requested and used successfully. +} diff --git a/tests/classes/moodle_cache_test.php b/tests/classes/moodle_cache_test.php index 1e0aeda..632456b 100644 --- a/tests/classes/moodle_cache_test.php +++ b/tests/classes/moodle_cache_test.php @@ -53,6 +53,17 @@ protected function setUp(): void { $this->cache = moodle_cache::nonce_cache(); } + /** + * Tests that the nonce cache is created correctly. + * + * @return void + * @covers \mod_kialo\moodle_cache::nonce_cache + */ + public function test_access_token_cache(): void { + $this->cache = moodle_cache::access_token_cache(); + $this->assertInstanceOf(moodle_cache::class, $this->cache); + } + /** * Tests that setting and getting of individual cache items works. * diff --git a/tests/classes/user_authenticator_test.php b/tests/classes/user_authenticator_test.php index a176279..12ae296 100644 --- a/tests/classes/user_authenticator_test.php +++ b/tests/classes/user_authenticator_test.php @@ -68,7 +68,9 @@ protected function setUp(): void { $this->userauthenticator = new user_authenticator(); // Creates a Kialo activity. - $this->module = $this->getDataGenerator()->create_module('kialo', ['course' => $this->course->id]); + $this->module = $this->getDataGenerator()->create_module('kialo', [ + 'course' => $this->course->id, + ]); } /** diff --git a/tests/generator/lib.php b/tests/generator/lib.php index 6bf3b56..ac06b71 100644 --- a/tests/generator/lib.php +++ b/tests/generator/lib.php @@ -47,6 +47,9 @@ public function create_instance($record = null, array $options = null): stdClass if (!isset($record->discussion_url)) { $record->discussion_url = "https://www.kialo-edu.com/42"; } + if (!isset($record->grade)) { + $record->grade = 100; + } return parent::create_instance($record, $options); } diff --git a/tests/grading/grading_service_test.php b/tests/grading/grading_service_test.php new file mode 100644 index 0000000..35a7d2a --- /dev/null +++ b/tests/grading/grading_service_test.php @@ -0,0 +1,286 @@ +. + +/** + * Grading tests. + * + * @package mod_kialo + * @category test + * @copyright 2023 onwards, Kialo GmbH + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_kialo; + +use mod_kialo\grading\grading_service; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/../../vendor/autoload.php'); + +/** + * Tests the LTI flow. + */ +final class grading_service_test extends \advanced_testcase { + + /** + * Copy of $_SERVER superglobal before the test. + * @var array|null + */ + private $server; + + /** + * Copy of $_ENV superglobal before the test. + * @var array|null + */ + private $env; + + /** + * Copy of $_GET superglobal before the test. + * @var array|null + */ + private $get; + + protected function setUp(): void { + parent::setUp(); + + $this->backup_globals(); + $this->resetAfterTest(); + } + + protected function tearDown(): void { + $this->restore_globals(); + parent::tearDown(); + } + + /** + * Backs up superglobal variables modified by this test. + * + * @return void + */ + private function backup_globals(): void { + $this->server = $_SERVER; + $this->env = $_ENV; + $this->get = $_GET; + } + + /** + * Restores superglobal variables modified by this test. + * + * @return void + */ + private function restore_globals(): void { + if (null !== $this->server) { + $_SERVER = $this->server; + } + if (null !== $this->env) { + $_ENV = $this->env; + } + if (null !== $this->get) { + $_GET = $this->get; + } + } + + /** + * Tests getting the line item for a course module. This is used by Kialo to get the max. grade configured in the LMS, + * as well as the endpoint to send grades to. + * + * @return void + * @throws \OAT\Library\Lti1p3Core\Exception\LtiException + * @throws \coding_exception + * @throws \moodle_exception + * @covers \mod_kialo\grading\grading_service::get_line_item + */ + public function test_get_line_item(): void { + $maxgrade = 123; + $course = $this->getDataGenerator()->create_course(); + $kialo = $this->getDataGenerator()->create_module('kialo', ['course' => $course->id, 'grade' => $maxgrade]); + $coursemodule = get_coursemodule_from_instance("kialo", $kialo->id); + + $courseid = $course->id; + $coursemoduleid = $coursemodule->id; + $resourcelinkid = lti_flow::resource_link_id($coursemoduleid); + + $endpoint = "/mod/kialo/lti_lineitem.php?course_id={$courseid}&cmid={$coursemoduleid}&resource_link_id={$resourcelinkid}"; + $_SERVER['REQUEST_URI'] = $endpoint; + + $lineitem = grading_service::get_line_item($courseid, $coursemoduleid, $resourcelinkid); + $this->assertEquals("https://www.example.com/moodle" . $endpoint, $lineitem->id); + $this->assertEquals($coursemodule->name, $lineitem->label); + $this->assertEquals($maxgrade, $lineitem->scoreMaximum); + $this->assertEquals($resourcelinkid, $lineitem->resourceLinkId); + } + + /** + * Tests getting the line item for a course module. This is used by Kialo to get the max. grade configured in the LMS, + * as well as the endpoint to send grades to. + * Activities created with previous versions have no grade book item. + * We just return the max grade default value of 100 in this case. + * + * @return void + * @throws \OAT\Library\Lti1p3Core\Exception\LtiException + * @throws \coding_exception + * @throws \moodle_exception + * @covers \mod_kialo\grading\grading_service::get_line_item + * @var moodle_database $DB + */ + public function test_get_line_item_with_missing_grade_book_entry(): void { + global $DB; + + $maxgrade = 100; + $course = $this->getDataGenerator()->create_course(); + $kialo = $this->getDataGenerator()->create_module('kialo', ['course' => $course->id, 'grade' => null]); + $coursemodule = get_coursemodule_from_instance("kialo", $kialo->id); + + // Delete the grade book item. + $DB->delete_records("grade_items", ["iteminstance" => $kialo->id]); + + $courseid = $course->id; + $coursemoduleid = $coursemodule->id; + $resourcelinkid = lti_flow::resource_link_id($coursemoduleid); + + $endpoint = "/mod/kialo/lti_lineitem.php?course_id={$courseid}&cmid={$coursemoduleid}&resource_link_id={$resourcelinkid}"; + $_SERVER['REQUEST_URI'] = $endpoint; + + $lineitem = grading_service::get_line_item($courseid, $coursemoduleid, $resourcelinkid); + $this->assertEquals("https://www.example.com/moodle" . $endpoint, $lineitem->id); + $this->assertEquals($coursemodule->name, $lineitem->label); + $this->assertEquals($maxgrade, $lineitem->scoreMaximum); + $this->assertEquals($resourcelinkid, $lineitem->resourceLinkId); + } + + /** + * Scores should be written to the gradebook as expected. + * + * @return void + * @throws \OAT\Library\Lti1p3Core\Exception\LtiException + * @throws \coding_exception + * @throws \dml_exception + * @covers \mod_kialo\grading\grading_service::update_grade + */ + public function test_write_scores(): void { + // Given a Kialo activity with a user without grades. + $course = $this->getDataGenerator()->create_course(); + $kialo = $this->getDataGenerator()->create_module('kialo', ['course' => $course->id]); + $coursemodule = get_coursemodule_from_instance("kialo", $kialo->id); + $user = $this->getDataGenerator()->create_and_enrol($course); + + $courseid = $course->id; + $coursemoduleid = $coursemodule->id; + $resourcelinkid = lti_flow::resource_link_id($coursemoduleid); + + // When a score is posted to the LTI line item endpoint. + $endpoint = "/mod/kialo/lti_lineitem.php?course_id={$courseid}&cmid={$coursemoduleid}&resource_link_id={$resourcelinkid}"; + $_SERVER['REQUEST_URI'] = $endpoint; + + $score = 72; + $feedback = "nice try"; + $data = [ + 'userId' => $user->id, + 'comment' => $feedback, + 'scoreGiven' => $score, + 'timestamp' => '2023-01-01T00:00:00Z', + ]; + + $result = grading_service::update_grade($courseid, $coursemoduleid, $data); + $this->assertTrue($result); + + // The gradebook entry should have been created accordingly. + $grades = kialo_get_user_grades($kialo, $user->id); + $this->assertCount(1, $grades->items); + + $gradeitem = current($grades->items); + $this->assertEquals('mod', $gradeitem->itemtype); + $this->assertEquals('kialo', $gradeitem->itemmodule); + $this->assertEquals($coursemodule->instance, $gradeitem->iteminstance); + $this->assertEquals($kialo->name, $gradeitem->name); + $this->assertEquals($gradeitem->grademax, 100); + + $this->assertCount(1, $gradeitem->grades); + $grade = current($gradeitem->grades); + $this->assertEquals($score, $grade->grade); + $this->assertEquals($feedback, $grade->feedback); + $this->assertEquals(FORMAT_MOODLE, $grade->feedbackformat); + + // I don't know why these warnings appear. The test itself works as expected, and the plugin code itself, as well. + $this->expectOutputRegex('/(The instance of this module does not exist)+/'); + } + + + /** + * Activities created with previous versions have no grade book item. + * It should be created when the score is written. + * + * @return void + * @throws \OAT\Library\Lti1p3Core\Exception\LtiException + * @throws \coding_exception + * @throws \dml_exception + * @covers \mod_kialo\grading\grading_service::update_grade + * @var moodle_database $DB + */ + public function test_write_scores_without_grade_book_item(): void { + global $DB; + + // Given a Kialo activity with a user without grades. + $course = $this->getDataGenerator()->create_course(); + $kialo = $this->getDataGenerator()->create_module('kialo', ['course' => $course->id]); + $coursemodule = get_coursemodule_from_instance("kialo", $kialo->id); + $user = $this->getDataGenerator()->create_and_enrol($course); + + $courseid = $course->id; + $coursemoduleid = $coursemodule->id; + $resourcelinkid = lti_flow::resource_link_id($coursemoduleid); + + // Delete the grade book item. + $DB->delete_records("grade_items", ["iteminstance" => $kialo->id]); + + // When a score is posted to the LTI line item endpoint. + $endpoint = "/mod/kialo/lti_lineitem.php?course_id={$courseid}&cmid={$coursemoduleid}&resource_link_id={$resourcelinkid}"; + $_SERVER['REQUEST_URI'] = $endpoint; + + $score = 72; + $feedback = "nice try"; + $data = [ + 'userId' => $user->id, + 'comment' => $feedback, + 'scoreGiven' => $score, + 'timestamp' => '2023-01-01T00:00:00Z', + ]; + + $result = grading_service::update_grade($courseid, $coursemoduleid, $data); + $this->assertTrue($result); + + // The gradebook entry should have been created accordingly. + $grades = kialo_get_user_grades($kialo, $user->id); + $this->assertCount(1, $grades->items); + + $gradeitem = current($grades->items); + $this->assertEquals('mod', $gradeitem->itemtype); + $this->assertEquals('kialo', $gradeitem->itemmodule); + $this->assertEquals($coursemodule->instance, $gradeitem->iteminstance); + $this->assertEquals($kialo->name, $gradeitem->name); + $this->assertEquals($gradeitem->grademax, 100); + + $this->assertCount(1, $gradeitem->grades); + $grade = current($gradeitem->grades); + $this->assertEquals($score, $grade->grade); + $this->assertEquals($feedback, $grade->feedback); + $this->assertEquals(FORMAT_MOODLE, $grade->feedbackformat); + + // I don't know why these warnings appear. The test itself works as expected, and the plugin code itself, as well. + $this->expectOutputRegex('/(The instance of this module does not exist)+/'); + } +} diff --git a/tests/lib_test.php b/tests/lib_test.php index 12e4808..b55656e 100644 --- a/tests/lib_test.php +++ b/tests/lib_test.php @@ -55,8 +55,10 @@ public function test_kialo_supports(): void { $this->assertTrue(kialo_supports(FEATURE_GROUPS)); $this->assertTrue(kialo_supports(FEATURE_GROUPINGS)); - // Grades are not supported yet, but will be in the future. - $this->assertNull(kialo_supports(FEATURE_GRADE_HAS_GRADE)); + // Basic grades are supported. + $this->assertTrue(kialo_supports(FEATURE_GRADE_HAS_GRADE)); + + // Advanced grading is not supported. $this->assertNull(kialo_supports(FEATURE_ADVANCED_GRADING)); // Moodle 4.0 and newer. @@ -69,14 +71,24 @@ public function test_kialo_supports(): void { * Check add instance * * @covers ::kialo_add_instance + * @var $DB \DB */ public function test_kialo_add_instance(): void { + global $DB; $this->resetAfterTest(); $course = $this->getDataGenerator()->create_course(); - $id = kialo_add_instance((object) ["name" => "Test", "course" => $course->id]); + $id = kialo_add_instance((object) ["name" => "Test", "course" => $course->id, "grade" => 100]); $this->assertNotNull($id); + + // By default, the activity is created with a maximum grade of 100 points. + $instance = $DB->get_record('kialo', ['id' => $id], '*', MUST_EXIST); + $this->assertEquals(100, $instance->grade); + + // A line item should be created in the gradebook. + $gradeitem = $DB->get_record('grade_items', ['iteminstance' => $id, 'itemmodule' => 'kialo'], '*', MUST_EXIST); + $this->assertEquals(100, $gradeitem->grademax); } /** @@ -214,4 +226,158 @@ public function test_disable_module_when_terms_have_not_been_accepted(): void { $this->assertNotContains('kialo', \core_plugin_manager::instance()->get_enabled_plugins('mod')); } + + /** + * Check the kialo_grade_item_update function. + * + * @covers ::kialo_grade_item_update + * @var $DB \DB + */ + public function test_kialo_grade_item_update_no_scale(): void { + global $DB; + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $kialo = $this->getDataGenerator()->create_module('kialo', ['course' => $course]); + + $this->assertEquals(GRADE_UPDATE_OK, kialo_grade_item_update($kialo)); + + // Line item should have been updated/created accordingly. + $gradeitem = $DB->get_record('grade_items', ['iteminstance' => $kialo->id, 'itemmodule' => 'kialo'], '*', MUST_EXIST); + $this->assertEquals(100, $gradeitem->grademax); + $this->assertEquals(0, $gradeitem->grademin); + $this->assertEquals(GRADE_TYPE_VALUE, $gradeitem->gradetype); + } + + /** + * Check the kialo_grade_item_update function when using scales instead of regular points. + * + * @covers ::kialo_grade_item_update + * @var $DB \DB + */ + public function test_kialo_grade_item_update_scale(): void { + global $DB; + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $kialo = $this->getDataGenerator()->create_module('kialo', ['course' => $course]); + $kialo->grade = -1; + + $this->assertEquals(GRADE_UPDATE_OK, kialo_grade_item_update($kialo)); + + // Line item should have been updated/created accordingly. + $gradeitem = $DB->get_record('grade_items', ['iteminstance' => $kialo->id, 'itemmodule' => 'kialo'], '*', MUST_EXIST); + $this->assertEquals(1, $gradeitem->scaleid); // The value of `-grade` is the scale ID. + $this->assertEquals(GRADE_TYPE_SCALE, $gradeitem->gradetype); + } + + /** + * Check that updating and reading grades works. + * + * @covers ::kialo_grade_item_update + * @covers ::kialo_get_user_grades + */ + public function test_kialo_get_and_set_user_grades(): void { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $kialo = $this->getDataGenerator()->create_module('kialo', ['course' => $course]); + $user = $this->getDataGenerator()->create_and_enrol($course); + + // Initially there should be no grade. + $grades = kialo_get_user_grades($kialo, $user->id); + $this->assertCount(1, $grades->items); + + $gradeitem = $grades->items[0]; + $this->assertEquals(0, $gradeitem->grademin); + $this->assertEquals(100, $gradeitem->grademax); + $this->assertEquals('mod', $gradeitem->itemtype); + $this->assertEquals('kialo', $gradeitem->itemmodule); + $this->assertEquals(0, $gradeitem->itemnumber); + $this->assertEquals(0, $gradeitem->scaleid); + + $this->assertCount(1, $gradeitem->grades); + $grade = current($gradeitem->grades); + $this->assertNull($grade->grade); + $this->assertNull($grade->feedback); + $this->assertNull($grade->datesubmitted); + + // Set a grade. + $grade = new \stdClass(); + $grade->userid = $user->id; + $grade->rawgrade = 50; + $grade->feedback = 'Good job!'; + $grade->datesubmitted = time(); + kialo_grade_item_update($kialo, $grade); + + // The grade should be set now. + $grades = kialo_get_user_grades($kialo, $user->id); + $this->assertCount(1, $grades->items); + + $gradeitem = $grades->items[0]; + $this->assertCount(1, $gradeitem->grades); + $grade = current($gradeitem->grades); + $this->assertEquals(50, $grade->grade); + $this->assertEquals('Good job!', $grade->feedback); + $this->assertNotNull($grade->datesubmitted); + + // I don't know why these warnings appear. The test itself works as expected, and the plugin code itself, as well. + $this->expectOutputRegex('/(The instance of this module does not exist)+/'); + } + + /** + * Cannot grade non-existent users. It should return an error. + * + * @return void + * @covers ::kialo_grade_item_update + */ + public function test_kialo_grade_item_update_error_on_invalid_user(): void { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $kialo = $this->getDataGenerator()->create_module('kialo', ['course' => $course]); + $user = $this->getDataGenerator()->create_and_enrol($course); + + // Set a grade but use an invalid user id. + $invaliduserid = 1234; + $this->assertNotEquals($invaliduserid, $user->id); + $grade = new \stdClass(); + $grade->userid = $invaliduserid; + $grade->rawgrade = 50; + $grade->feedback = 'Good job!'; + $grade->datesubmitted = time(); + $result = kialo_grade_item_update($kialo, $grade); + + // Cannot grade a non-existent user. + $this->assertEquals(GRADE_UPDATE_FAILED, $result); + } + + /** + * Cannot grade users that are not participants in the course. This should return an error. + * + * @return void + * @covers ::kialo_grade_item_update + */ + public function test_kialo_grade_item_update_error_on_non_participant(): void { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $kialo = $this->getDataGenerator()->create_module('kialo', ['course' => $course]); + $user = $this->getDataGenerator()->create_user(); + + // User is not enrolled in the course. + $context = \context_course::instance($course->id); + $this->assertFalse(is_enrolled($context, $user->id)); + + // Set a grade but use a user that is not enrolled in the course. + $grade = new \stdClass(); + $grade->userid = $user->id; + $grade->rawgrade = 50; + $grade->feedback = 'Good job!'; + $grade->datesubmitted = time(); + $result = kialo_grade_item_update($kialo, $grade); + + // Cannot grade a non-participant. + $this->assertEquals(GRADE_UPDATE_FAILED, $result); + } } diff --git a/version.php b/version.php index 5c9192b..8342e90 100644 --- a/version.php +++ b/version.php @@ -28,8 +28,8 @@ $plugin->component = 'mod_kialo'; // See https://moodledev.io/docs/apis/commonfiles/version.php. -$plugin->version = 2024091201; // Must be incremented for each new release! -$plugin->release = '1.2.1'; // Semantic version. +$plugin->version = 2024101601; // Must be incremented for each new release! +$plugin->release = '1.3.0'; // Semantic version. // Officially we require PHP 7.4. The first Moodle version that requires this as a minimum is Moodle 4.1. // But technically this plugin also runs on older Moodle versions, as long as they run on PHP 7.4,