diff --git a/plugin/ims_lti/src/ImsLti.php b/plugin/ims_lti/src/ImsLti.php index dde693221d..547b9e04a3 100644 --- a/plugin/ims_lti/src/ImsLti.php +++ b/plugin/ims_lti/src/ImsLti.php @@ -220,4 +220,24 @@ class ImsLti return $clientId; } + + /** + * Validate the format ISO 8601 for date strings coming from JSON or JavaScript. + * + * @link https://www.myintervals.com/blog/2009/05/20/iso-8601-date-validation-that-doesnt-suck/ Pattern source. + * + * @param string $strDate + * + * @return bool + */ + public static function validateFormatDateIso8601($strDate) + { + $pattern = '/^([\+-]?\d{4}(?!\d{2}\b))((-?)(' + .'(0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W' + .'([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))(' + .'[T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?' + .'([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/'; + + return preg_match($pattern, $strDate) !== false; + } } diff --git a/plugin/ims_lti/src/Service/LtiAssignmentGradesService.php b/plugin/ims_lti/src/Service/LtiAssignmentGradesService.php index 128b5143df..0e984e8589 100644 --- a/plugin/ims_lti/src/Service/LtiAssignmentGradesService.php +++ b/plugin/ims_lti/src/Service/LtiAssignmentGradesService.php @@ -20,10 +20,12 @@ class LtiAssignmentGradesService extends LtiAdvantageService const SCOPE_LINE_ITEM = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem'; const SCOPE_LINE_ITEM_READ = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly'; const SCOPE_RESULT_READ = 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly'; + const SCOPE_SCORE_WRITE = 'https://purl.imsglobal.org/spec/lti-ags/scope/score'; const TYPE_LINE_ITEM_CONTAINER = 'application/vnd.ims.lis.v2.lineitemcontainer+json'; const TYPE_LINE_ITEM = 'application/vnd.ims.lis.v2.lineitem+json'; const TYPE_RESULT_CONTAINER = 'application/vnd.ims.lis.v2.resultcontainer+json'; + const TYPE_SCORE = 'application/vnd.ims.lis.v1.score+json'; /** * @return array @@ -33,6 +35,7 @@ class LtiAssignmentGradesService extends LtiAdvantageService $scopes = [ self::SCOPE_LINE_ITEM_READ, self::SCOPE_RESULT_READ, + self::SCOPE_SCORE_WRITE, ]; $toolServices = $this->tool->getAdvantageServices(); @@ -84,6 +87,14 @@ class LtiAssignmentGradesService extends LtiAdvantageService ); } + if (isset($parts[4]) && 'scores' === $parts[4]) { + $resource = new LtiScoresResource( + $request->query->get('t'), + $parts[1], + $parts[3] + ); + } + if (!$resource) { throw new NotFoundHttpException('Line item resource not found.'); } diff --git a/plugin/ims_lti/src/Service/Resource/LtiScoresResource.php b/plugin/ims_lti/src/Service/Resource/LtiScoresResource.php new file mode 100644 index 0000000000..ecbacfc042 --- /dev/null +++ b/plugin/ims_lti/src/Service/Resource/LtiScoresResource.php @@ -0,0 +1,213 @@ +lineItem = Database::getManager()->find('ChamiloPluginBundle:ImsLti\LineItem', (int)$lineItemId); + } + + /** + * @inheritDoc + */ + public function validate() + { + if (!$this->course) { + throw new NotFoundHttpException('Course not found.'); + } + + if (!$this->lineItem) { + throw new NotFoundHttpException('Line item not found'); + } + + if ($this->lineItem->getTool()->getId() !== $this->tool->getId()) { + throw new AccessDeniedHttpException('Line item not found for the tool.'); + } + + if (!$this->tool) { + throw new BadRequestHttpException('Tool not found.'); + } + + if ($this->tool->getCourse()->getId() !== $this->course->getId()) { + throw new AccessDeniedHttpException('Tool not found in course.'); + } + + if ($this->request->server->get('HTTP_ACCEPT') !== LtiAssignmentGradesService::TYPE_SCORE) { + throw new UnsupportedMediaTypeHttpException('Unsupported media type.'); + } + + $parentTool = $this->tool->getParent(); + + if ($parentTool) { + $advServices = $parentTool->getAdvantageServices(); + + if (LtiAssignmentGradesService::AGS_NONE === $advServices['ags']) { + throw new AccessDeniedHttpException('Assigment and grade service is not enabled for this tool.'); + } + } + } + + public function process() + { + switch ($this->request->getMethod()) { + case Request::METHOD_POST: + $this->processPost(); + break; + default: + throw new MethodNotAllowedHttpException([Request::METHOD_POST]); + } + } + + /** + * @throws Exception + */ + private function processPost() + { + $data = json_decode($this->request->getContent(), true); + + if (empty($data) || + !isset($data['userId']) || + !isset($data['gradingProgress']) || + !isset($data['activityProgress']) || + !isset($data['timestamp']) || + (isset($data['timestamp']) && !ImsLti::validateFormatDateIso8601($data['timestamp'])) || + (isset($data['scoreGiven']) && !is_numeric($data['scoreGiven'])) || + (isset($data['scoreGiven']) && !isset($data['scoreMaximum'])) || + (isset($data['scoreMaximum']) && !is_numeric($data['scoreMaximum'])) + ) { + throw new BadRequestHttpException('Missing data to create score.'); + } + + $student = api_get_user_entity($data['userId']); + + if (!$student) { + throw new BadRequestHttpException("User (id: {$data['userId']}) not found."); + } + + $data['scoreMaximum'] = isset($data['scoreMaximum']) ? $data['scoreMaximum'] : 1; + + $evaluation = $this->lineItem->getEvaluation(); + + $result = Database::getManager() + ->getRepository('ChamiloCoreBundle:GradebookResult') + ->findOneBy( + [ + 'userId' => $data['userId'], + 'evaluationId' => $evaluation->getId(), + ] + ); + + if ($result && $result->getCreatedAt() >= new DateTime($data['timestamp'])) { + throw new ConflictHttpException('The timestamp on record is later than the incoming score.'); + } + + if (isset($data['scoreGiven'])) { + if (self::GRADING_FULLY_GRADED !== $data['gradingProgress']) { + $data['scoreGiven'] = null; + } else { + $data['scoreGiven'] = (float) $data['scoreGiven']; + + if ($data['scoreMaximum'] > 0 && $data['scoreMaximum'] != $evaluation->getMax()) { + $data['scoreGiven'] = $data['scoreGiven'] * $evaluation->getMax() / $data['scoreMaximum']; + } + } + } + + if (!$result) { + $this->response->setStatusCode(Response::HTTP_CREATED); + } + + $this->saveScore($data, $student, $result); + } + + /** + * @param GradebookResult $result + * @param array $data + * @param User $student + * + * @throws OptimisticLockException + */ + private function saveScore(array $data, User $student, GradebookResult $result = null) + { + $em = Database::getManager(); + + $evaluation = $this->lineItem->getEvaluation(); + + if ($result) { + $resultLog = new GradebookResultLog(); + $resultLog + ->setCreatedAt(api_get_utc_datetime(null, false, true)) + ->setUserId($student->getId()) + ->setEvaluationId($evaluation->getId()) + ->setIdResult($result->getId()) + ->setScore($result->getScore()); + + $em->persist($resultLog); + } else { + $result = new GradebookResult(); + $result + ->setUserId($student->getId()) + ->setEvaluationId($evaluation->getId()); + } + + $result + ->setCreatedAt(new DateTime($data['timestamp'])) + ->setScore($data['scoreGiven']); + + $em->persist($result); + + $em->flush(); + } +}