getIid() : 0;
+ $vueUrl = ExerciseLib::buildVueOverviewUrl(
+ (int) $id,
+ [
+ 'origin' => 'learnpath',
+ 'lp_init' => 1,
+ 'learnpath_item_view_id' => $learnpathItemViewId,
+ 'learnpath_id' => $learningPathId,
+ 'learnpath_item_id' => $id_in_path,
+ 'isStudentView' => isset($_REQUEST['isStudentView']) ? Security::remove_XSS((string) $_REQUEST['isStudentView']) : 'true',
+ ]
+ );
+ if (null !== $vueUrl) {
+ return $vueUrl;
+ }
+
return $main_dir_path.'exercise/overview.php?'.$extraParams.'&'
.http_build_query([
+ 'origin' => 'learnpath',
'lp_init' => 1,
'learnpath_item_view_id' => $learnpathItemViewId,
'learnpath_id' => $learningPathId,
'learnpath_item_id' => $id_in_path,
+ 'isStudentView' => isset($_REQUEST['isStudentView']) ? Security::remove_XSS((string) $_REQUEST['isStudentView']) : 'true',
'exerciseId' => $id,
]);
case TOOL_HOTPOTATOES:
diff --git a/public/main/my_space/myStudents.php b/public/main/my_space/myStudents.php
index 58d13b556cb..68a89dc68ba 100644
--- a/public/main/my_space/myStudents.php
+++ b/public/main/my_space/myStudents.php
@@ -2170,10 +2170,32 @@
if ($allowToQualify) {
$qualifyLink = '&action=qualify';
}
- $attemptLink =
- '../exercise/exercise_show.php?id='.$id_last_attempt.'&cid='.$courseId
- .'&sid='.$sessionId.'&student='.$studentId.'&origin='
- .(empty($origin) ? 'tracking' : $origin).$qualifyLink;
+ $exerciseResourceNodeId = null !== $course && null !== $course->getResourceNode()
+ ? (int) $course->getResourceNode()->getId()
+ : 0;
+
+ if (0 < $exerciseResourceNodeId) {
+ $attemptQueryParams = [
+ 'cid' => $courseId,
+ 'sid' => $sessionId,
+ 'gid' => 0,
+ 'student' => $studentId,
+ 'origin' => empty($origin) ? 'tracking' : $origin,
+ ];
+
+ if ($allowToQualify) {
+ $attemptQueryParams['action'] = 'qualify';
+ }
+
+ $attemptLink = api_get_path(WEB_PATH).'resources/exercise/'
+ .$exerciseResourceNodeId.'/'.$exercise_id.'/result/'.$id_last_attempt.'?'
+ .http_build_query($attemptQueryParams);
+ } else {
+ $attemptLink =
+ '../exercise/exercise_show.php?id='.$id_last_attempt.'&cid='.$courseId
+ .'&sid='.$sessionId.'&student='.$studentId.'&origin='
+ .(empty($origin) ? 'tracking' : $origin).$qualifyLink;
+ }
echo Display::url(
Display::getMdiIcon(ToolIcon::QUIZ, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Test')),
$attemptLink
@@ -2184,8 +2206,23 @@
echo '';
if ($count_attempts > 0) {
- $all_attempt_url = "../exercise/exercise_report.php?id=$exercise_id&"
- ."cid=".$courseId."&filter_by_user=$studentId&sid=$sessionId";
+ $exerciseResourceNodeId = null !== $course && null !== $course->getResourceNode()
+ ? (int) $course->getResourceNode()->getId()
+ : 0;
+
+ if (0 < $exerciseResourceNodeId) {
+ $all_attempt_url = api_get_path(WEB_PATH).'resources/exercise/'
+ .$exerciseResourceNodeId.'/'.$exercise_id.'/report?'
+ .http_build_query([
+ 'cid' => $courseId,
+ 'sid' => $sessionId,
+ 'gid' => 0,
+ 'filter_by_user' => $studentId,
+ ]);
+ } else {
+ $all_attempt_url = "../exercise/exercise_report.php?id=$exercise_id&"
+ ."cid=".$courseId."&filter_by_user=$studentId&sid=$sessionId";
+ }
echo Display::url(
Display::getMdiIcon('format-annotation-plus', 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('All attempts')),
$all_attempt_url
diff --git a/public/plugin/Onlyoffice/callback.php b/public/plugin/Onlyoffice/callback.php
index 901f076d5f7..0039614c2d3 100644
--- a/public/plugin/Onlyoffice/callback.php
+++ b/public/plugin/Onlyoffice/callback.php
@@ -18,6 +18,7 @@
require_once __DIR__.'/../../main/inc/global.inc.php';
use Chamilo\CoreBundle\Entity\ResourceFile;
+use Chamilo\CoreBundle\Entity\ResourceNode;
use Chamilo\CoreBundle\Framework\Container;
use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
use Chamilo\CourseBundle\Entity\CDocument;
@@ -63,6 +64,7 @@
$courseId = (int) getHashValue($hashData, 'courseId', 0);
$userId = (int) getHashValue($hashData, 'userId', 0);
$docId = (int) getHashValue($hashData, 'docId', 0);
+$resourceNodeId = (int) getHashValue($hashData, 'resourceNodeId', 0);
$groupId = (int) getHashValue($hashData, 'groupId', 0);
$sessionId = (int) getHashValue($hashData, 'sessionId', 0);
@@ -85,6 +87,7 @@
'courseCode' => $courseCode,
'userId' => $userId,
'docId' => $docId,
+ 'resourceNodeId' => $resourceNodeId,
'groupId' => $groupId,
'sessionId' => $sessionId,
'docPath' => $docPath,
@@ -160,6 +163,7 @@ function track(): array
global $jwtManager;
global $courseCode;
global $docId;
+ global $resourceNodeId;
global $docPath;
global $sessionId;
@@ -204,7 +208,7 @@ function track(): array
return ['error' => 0];
}
- $resolved = resolveDocumentSource($docId, $courseCode, $sessionId, $docPath);
+ $resolved = resolveDocumentSource($docId, $resourceNodeId, $courseCode, $sessionId, $docPath);
if (null === $resolved) {
onlyofficeLog('ERROR', 'File not found for save', [
'docId' => $docId,
@@ -265,10 +269,11 @@ function download(): void
{
global $courseCode;
global $docId;
+ global $resourceNodeId;
global $docPath;
global $sessionId;
- $resolved = resolveDocumentSource($docId, $courseCode, $sessionId, $docPath);
+ $resolved = resolveDocumentSource($docId, $resourceNodeId, $courseCode, $sessionId, $docPath);
if (null === $resolved) {
sendNoCacheHeaders();
@@ -365,7 +370,7 @@ function emptyFile(): void
/**
* Resolve a document source in C2 first, then legacy.
*/
-function resolveDocumentSource(int $docId, string $courseCode, int $sessionId, string $docPath): ?array
+function resolveDocumentSource(int $docId, int $resourceNodeId, string $courseCode, int $sessionId, string $docPath): ?array
{
if ('' !== $docPath) {
$filePath = api_get_path(SYS_COURSE_PATH).$docPath;
@@ -397,6 +402,13 @@ function resolveDocumentSource(int $docId, string $courseCode, int $sessionId, s
]);
}
+ if ($resourceNodeId > 0) {
+ $resolved = resolveResourceNodeSourceFromC2($resourceNodeId);
+ if (null !== $resolved) {
+ return $resolved;
+ }
+ }
+
if ($docId > 0) {
$resolved = resolveDocumentSourceFromC2($docId);
if (null !== $resolved) {
@@ -435,6 +447,7 @@ function resolveDocumentSource(int $docId, string $courseCode, int $sessionId, s
onlyofficeLog('ERROR', 'resolveDocumentSource failed', [
'docId' => $docId,
+ 'resourceNodeId' => $resourceNodeId,
'docPath' => $docPath,
'courseCode' => $courseCode,
'sessionId' => $sessionId,
@@ -552,6 +565,93 @@ function resolveDocumentSourceFromC2(int $docId): ?array
];
}
+/**
+ * Resolve a C2 ResourceNode directly. Used for exercise AttemptFile documents.
+ */
+function resolveResourceNodeSourceFromC2(int $resourceNodeId): ?array
+{
+ $entityManager = getEntityManager();
+ if (null === $entityManager) {
+ onlyofficeLog('ERROR', 'Entity manager could not be resolved');
+
+ return null;
+ }
+
+ /** @var ResourceNode|null $resourceNode */
+ $resourceNode = $entityManager->getRepository(ResourceNode::class)->find($resourceNodeId);
+ if (!$resourceNode instanceof ResourceNode) {
+ onlyofficeLog('ERROR', 'ResourceNode not found', [
+ 'resourceNodeId' => $resourceNodeId,
+ ]);
+
+ return null;
+ }
+
+ $resourceFile = $resourceNode->getFirstResourceFile();
+ if (!$resourceFile instanceof ResourceFile) {
+ onlyofficeLog('ERROR', 'ResourceFile missing for ResourceNode', [
+ 'resourceNodeId' => $resourceNodeId,
+ ]);
+
+ return null;
+ }
+
+ $resourceNodeRepository = getResourceNodeRepository();
+ if (null === $resourceNodeRepository) {
+ onlyofficeLog('ERROR', 'ResourceNodeRepository could not be resolved');
+
+ return null;
+ }
+
+ $storagePath = '';
+ try {
+ $storagePath = (string) $resourceNodeRepository->getFilename($resourceFile);
+ } catch (\Throwable $e) {
+ onlyofficeLog('WARNING', 'Failed to resolve storage filename', [
+ 'message' => $e->getMessage(),
+ ]);
+ }
+
+ try {
+ $stream = $resourceNodeRepository->getResourceNodeFileStream($resourceNode, $resourceFile);
+ } catch (\Throwable $e) {
+ onlyofficeLog('ERROR', 'Failed to open resource stream', [
+ 'message' => $e->getMessage(),
+ 'resourceNodeId' => $resourceNodeId,
+ ]);
+
+ return null;
+ }
+
+ if (!\is_resource($stream)) {
+ onlyofficeLog('ERROR', 'Resource stream is not available', [
+ 'resourceNodeId' => $resourceNodeId,
+ 'resourceFileId' => (int) $resourceFile->getId(),
+ 'storagePath' => $storagePath,
+ ]);
+
+ return null;
+ }
+
+ $title = (string) ($resourceFile->getOriginalName() ?: $resourceFile->getTitle() ?: $resourceNode->getTitle());
+ $size = (int) ($resourceFile->getSize() ?? 0);
+ $mimeType = (string) ($resourceFile->getMimeType() ?: getMimeTypeFromFilename($title));
+
+ return [
+ 'filePath' => null,
+ 'stream' => $stream,
+ 'storagePath' => $storagePath,
+ 'title' => $title,
+ 'documentKey' => 'rn'.$resourceNodeId,
+ 'resourceFileId' => (int) $resourceFile->getId(),
+ 'resourceNodeId' => (int) $resourceNode->getId(),
+ 'entityManager' => $entityManager,
+ 'resourceNodeRepository' => $resourceNodeRepository,
+ 'size' => $size,
+ 'mimeType' => $mimeType,
+ ];
+}
+
/**
* Write content back to the resolved storage.
*/
diff --git a/public/plugin/Onlyoffice/editor.php b/public/plugin/Onlyoffice/editor.php
index b13a739445d..27591dd2f4e 100644
--- a/public/plugin/Onlyoffice/editor.php
+++ b/public/plugin/Onlyoffice/editor.php
@@ -18,6 +18,9 @@
require_once __DIR__.'/../../main/inc/global.inc.php';
use Chamilo\CoreBundle\Entity\ResourceNode;
+use Chamilo\CoreBundle\Entity\TrackEAttempt;
+use Chamilo\CoreBundle\Entity\ResourceFile;
+use Chamilo\CoreBundle\Entity\AttemptFile;
use Chamilo\CoreBundle\Framework\Container;
use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
use Chamilo\CourseBundle\Entity\CDocument;
@@ -47,6 +50,7 @@
$isMetaRequest = isset($_GET['meta']) && '1' === (string) $_GET['meta'];
$docId = isset($_GET['docId']) ? (int) $_GET['docId'] : null;
+$resourceNodeId = isset($_GET['resourceNodeId']) ? (int) $_GET['resourceNodeId'] : null;
$docPath = isset($_GET['doc']) ? urldecode((string) $_GET['doc']) : null;
$groupId = isset($_GET['groupId']) && !empty($_GET['groupId'])
@@ -89,6 +93,7 @@
onlyofficeEditorLog('DEBUG', 'Editor entry', [
'docId' => $docId,
+ 'resourceNodeId' => $resourceNodeId,
'docPath' => $docPath,
'courseId' => $courseId,
'courseCode' => $courseCode,
@@ -107,7 +112,102 @@
'learnpathEmbedded' => $isLearnpathEmbedded,
]);
-if (!empty($docPath)) {
+if (!empty($resourceNodeId)) {
+ if (!userCanOpenExerciseResourceNodeForOnlyofficeEditor($resourceNodeId, (int) $exerciseId, (int) $exeId, (int) $questionId, $userId)) {
+ onlyofficeEditorLog('ERROR', 'Resource node is not linked to the current exercise attempt', [
+ 'resourceNodeId' => $resourceNodeId,
+ 'exerciseId' => $exerciseId,
+ 'exeId' => $exeId,
+ 'questionId' => $questionId,
+ 'userId' => $userId,
+ ]);
+ api_not_allowed(true);
+ }
+
+ $resolvedC2 = resolveResourceNodeSourceFromC2ForEditor($resourceNodeId);
+ if (null !== $resolvedC2) {
+ $fileId = 'rn'.$resourceNodeId;
+ $versionToken = $resolvedC2['versionToken'];
+
+ $downloadPayload = [
+ 'type' => 'download',
+ 'courseId' => $courseId,
+ 'userId' => $userId,
+ 'resourceNodeId' => $resourceNodeId,
+ 'sessionId' => $sessionId,
+ ];
+
+ $trackPayload = [
+ 'type' => 'track',
+ 'courseId' => $courseId,
+ 'userId' => $userId,
+ 'resourceNodeId' => $resourceNodeId,
+ 'sessionId' => $sessionId,
+ ];
+
+ if (!empty($groupId)) {
+ $downloadPayload['groupId'] = $groupId;
+ $trackPayload['groupId'] = $groupId;
+ }
+
+ $downloadHash = $jwtManager->getHash($downloadPayload);
+ $trackHash = $jwtManager->getHash($trackPayload);
+
+ $fileUrl = appendVersionTokenToUrl(
+ api_get_path(WEB_PLUGIN_PATH).'Onlyoffice/callback.php?hash='.$downloadHash,
+ $versionToken
+ );
+
+ $callbackUrl = appendVersionTokenToUrl(
+ api_get_path(WEB_PLUGIN_PATH).'Onlyoffice/callback.php?hash='.$trackHash,
+ $versionToken
+ );
+
+ $docInfo = [
+ 'iid' => null,
+ 'id' => null,
+ 'c_id' => $courseId,
+ 'path' => $resolvedC2['storagePath'],
+ 'comment' => null,
+ 'title' => $resolvedC2['title'],
+ 'filetype' => 'file',
+ 'size' => $resolvedC2['size'],
+ 'readonly' => (int) $isReadOnly,
+ 'session_id' => $sessionId,
+ 'url' => api_get_path(WEB_PLUGIN_PATH).'Onlyoffice/editor.php?resourceNodeId='.$resourceNodeId
+ .($exerciseId ? '&exerciseId='.$exerciseId : '')
+ .($exeId ? '&exeId='.$exeId : '')
+ .($questionId ? '&questionId='.$questionId : '')
+ .($isReadOnly ? '&readOnly='.$isReadOnly : '')
+ .($groupId ? '&groupId='.$groupId : '')
+ .($forceEdit ? '&forceEdit=true' : '')
+ .($origin ? '&origin='.rawurlencode($origin) : '')
+ .($isEmbedded ? '&embedded=1' : '')
+ .($returnUrl ? '&returnUrl='.rawurlencode($returnUrl) : ''),
+ 'document_url' => $callbackUrl,
+ 'direct_url' => $fileUrl,
+ 'basename' => basename((string) $resolvedC2['title']),
+ 'parent_id' => 0,
+ 'parents' => [],
+ 'forceEdit' => $forceEdit,
+ 'exercise_id' => $exerciseId,
+ 'creator_id' => $resolvedC2['creatorId'],
+ 'resource_node_id' => $resolvedC2['resourceNodeId'],
+ 'resource_file_id' => $resolvedC2['resourceFileId'],
+ 'version_token' => $versionToken,
+ 'return_url' => $returnUrl,
+ 'origin' => $origin,
+ 'embedded' => $isEmbedded,
+ ];
+
+ onlyofficeEditorLog('DEBUG', 'Resolved C2 resource node for exercise', [
+ 'resourceNodeId' => $resourceNodeId,
+ 'title' => $resolvedC2['title'],
+ 'storagePath' => $resolvedC2['storagePath'],
+ 'versionToken' => $versionToken,
+ ]);
+ }
+} elseif (!empty($docPath)) {
$resolvedDocPath = resolveOnlyofficeEditorDocPath($docPath);
if (null === $resolvedDocPath) {
onlyofficeEditorLog('ERROR', 'Invalid document path', [
@@ -374,6 +474,7 @@
if (empty($docInfo) || empty($fileId)) {
onlyofficeEditorLog('ERROR', 'Document not found', [
'docId' => $docId,
+ 'resourceNodeId' => $resourceNodeId,
'docPath' => $docPath,
]);
exit('Error: Document not found.');
@@ -394,13 +495,14 @@
$exeId
);
-$fileIdentifier = $docId ? (string) $docId : md5((string) $docPath);
+$fileIdentifier = $docId ? (string) $docId : (!empty($resourceNodeId) ? 'rn'.(string) $resourceNodeId : md5((string) $docPath));
$versionToken = (string) ($docInfo['version_token'] ?? buildOnlyofficeVersionTokenFromLegacyDocInfo($docInfo, $fileIdentifier));
$runtimeIdentifier = buildOnlyofficeRuntimeFileIdentifier($fileIdentifier, $versionToken);
$runtimeKey = buildOnlyofficeRuntimeDocumentKey($fileIdentifier, $courseCode, $docInfo, $versionToken);
$documentIdentity = buildOnlyofficeDocumentIdentity($courseId, $sessionId, $groupId, $fileIdentifier);
$metaUrl = buildOnlyofficeMetaUrl(
$docId,
+ $resourceNodeId,
$docPath,
$groupId,
$exerciseId,
@@ -885,6 +987,121 @@ function resolveDocumentSourceFromC2ForEditor(int $docId): ?array
];
}
+/**
+ * Resolve a C2 resource node directly. Used by exercise AttemptFile resources.
+ */
+function resolveResourceNodeSourceFromC2ForEditor(int $resourceNodeId): ?array
+{
+ $entityManager = getEntityManagerForOnlyofficeEditor();
+ if (null === $entityManager) {
+ onlyofficeEditorLog('ERROR', 'Entity manager could not be resolved');
+
+ return null;
+ }
+
+ /** @var ResourceNode|null $resourceNode */
+ $resourceNode = $entityManager->getRepository(ResourceNode::class)->find($resourceNodeId);
+ if (!$resourceNode instanceof ResourceNode) {
+ onlyofficeEditorLog('ERROR', 'ResourceNode not found', [
+ 'resourceNodeId' => $resourceNodeId,
+ ]);
+
+ return null;
+ }
+
+ $resourceFile = $resourceNode->getFirstResourceFile();
+ if (!$resourceFile instanceof ResourceFile) {
+ onlyofficeEditorLog('ERROR', 'ResourceFile not found for ResourceNode', [
+ 'resourceNodeId' => $resourceNodeId,
+ ]);
+
+ return null;
+ }
+
+ $resourceNodeRepository = getResourceNodeRepositoryForOnlyofficeEditor();
+ if (null === $resourceNodeRepository) {
+ onlyofficeEditorLog('ERROR', 'ResourceNodeRepository could not be resolved');
+
+ return null;
+ }
+
+ $storagePath = '';
+ try {
+ $storagePath = (string) $resourceNodeRepository->getFilename($resourceFile);
+ } catch (\Throwable $e) {
+ onlyofficeEditorLog('WARNING', 'Failed to resolve storage path', [
+ 'message' => $e->getMessage(),
+ ]);
+ }
+
+ $title = (string) ($resourceFile->getOriginalName() ?: $resourceFile->getTitle() ?: $resourceNode->getTitle());
+ $size = (int) ($resourceFile->getSize() ?? 0);
+ $versionToken = buildOnlyofficeVersionTokenFromDatabase(
+ $entityManager,
+ (int) $resourceNode->getId(),
+ (int) $resourceFile->getId(),
+ $size,
+ $storagePath ?: $title
+ );
+
+ return [
+ 'title' => $title,
+ 'size' => $size,
+ 'storagePath' => $storagePath,
+ 'resourceNodeId' => (int) $resourceNode->getId(),
+ 'resourceFileId' => (int) $resourceFile->getId(),
+ 'creatorId' => $resourceNode->getCreator() ? (int) $resourceNode->getCreator()->getId() : (int) api_get_user_id(),
+ 'versionToken' => $versionToken,
+ ];
+}
+
+function userCanOpenExerciseResourceNodeForOnlyofficeEditor(int $resourceNodeId, int $exerciseId, int $exeId, int $questionId, int $userId): bool
+{
+ if ($resourceNodeId <= 0 || $exerciseId <= 0 || $exeId <= 0 || $questionId <= 0 || $userId <= 0) {
+ return false;
+ }
+
+ $entityManager = getEntityManagerForOnlyofficeEditor();
+ if (null === $entityManager) {
+ return false;
+ }
+
+ try {
+ $row = $entityManager->createQueryBuilder()
+ ->select('saved.id')
+ ->from(TrackEAttempt::class, 'saved')
+ ->innerJoin('saved.trackExercise', 'exerciseAttempt')
+ ->innerJoin('saved.attemptFiles', 'attemptFile')
+ ->andWhere('saved.questionId = :questionId')
+ ->andWhere('IDENTITY(saved.trackExercise) = :exeId')
+ ->andWhere('IDENTITY(exerciseAttempt.quiz) = :exerciseId')
+ ->andWhere('IDENTITY(exerciseAttempt.user) = :userId')
+ ->andWhere('IDENTITY(attemptFile.resourceNode) = :resourceNodeId')
+ ->setParameter('questionId', $questionId)
+ ->setParameter('exeId', $exeId)
+ ->setParameter('exerciseId', $exerciseId)
+ ->setParameter('userId', $userId)
+ ->setParameter('resourceNodeId', $resourceNodeId)
+ ->setMaxResults(1)
+ ->getQuery()
+ ->getOneOrNullResult()
+ ;
+
+ return null !== $row;
+ } catch (\Throwable $exception) {
+ onlyofficeEditorLog('ERROR', 'Failed to validate exercise resource node ownership', [
+ 'message' => $exception->getMessage(),
+ 'resourceNodeId' => $resourceNodeId,
+ 'exerciseId' => $exerciseId,
+ 'exeId' => $exeId,
+ 'questionId' => $questionId,
+ 'userId' => $userId,
+ ]);
+
+ return false;
+ }
+}
+
/**
* Resolve Doctrine entity manager.
*/
@@ -1079,6 +1296,7 @@ function buildOnlyofficeDocumentIdentity(int $courseId, int $sessionId, int $gro
*/
function buildOnlyofficeMetaUrl(
?int $docId,
+ ?int $resourceNodeId,
?string $docPath,
int $groupId,
?int $exerciseId,
@@ -1098,7 +1316,11 @@ function buildOnlyofficeMetaUrl(
$params['docId'] = (string) $docId;
}
- if (!empty($docPath)) {
+ if (!empty($resourceNodeId)) {
+ $params['resourceNodeId'] = (string) $resourceNodeId;
+ }
+
+ if (empty($resourceNodeId) && !empty($docPath)) {
$params['doc'] = $docPath;
}
diff --git a/src/CoreBundle/AiProvider/AiCourseAnalyzerService.php b/src/CoreBundle/AiProvider/AiCourseAnalyzerService.php
index 77adf6e3bdf..656897afec1 100644
--- a/src/CoreBundle/AiProvider/AiCourseAnalyzerService.php
+++ b/src/CoreBundle/AiProvider/AiCourseAnalyzerService.php
@@ -530,9 +530,22 @@ private function buildDocumentEditUrl(CDocument $document, Course $course, ?Sess
private function buildExerciseEditUrl(CQuiz $quiz, Course $course, ?Session $session): string
{
- return $this->buildLegacyCourseUrl('/main/exercise/exercise_admin.php', $course, $session, [
- 'exerciseId' => (int) $quiz->getIid(),
- ]);
+ $courseResourceNode = $course->getResourceNode();
+ if (!$courseResourceNode instanceof ResourceNode || null === $courseResourceNode->getId()) {
+ return $this->buildLegacyCourseUrl('/main/exercise/exercise_admin.php', $course, $session, [
+ 'exerciseId' => (int) $quiz->getIid(),
+ ]);
+ }
+
+ $query = [
+ 'cid' => (int) $course->getId(),
+ 'sid' => $session instanceof Session ? (int) $session->getId() : 0,
+ 'gid' => 0,
+ 'gradebook' => 0,
+ 'origin' => '',
+ ];
+
+ return '/resources/exercise/'.(int) $courseResourceNode->getId().'/'.(int) $quiz->getIid().'/edit?'.http_build_query($query);
}
/**
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseAiAikenGenerator.php b/src/CoreBundle/ApiResource/Exercise/ExerciseAiAikenGenerator.php
new file mode 100644
index 00000000000..7e58f4e9a65
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseAiAikenGenerator.php
@@ -0,0 +1,101 @@
+ 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_ai_aiken_generator',
+ provider: ExerciseAiAikenGeneratorProvider::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_ai_aiken_generator:read']],
+)]
+final class ExerciseAiAikenGenerator
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_ai_aiken_generator:read'])]
+ public string $id = 'exercise_ai_aiken_generator';
+
+ #[Groups(['exercise_ai_aiken_generator:read'])]
+ public bool $canManage = false;
+
+ #[Groups(['exercise_ai_aiken_generator:read'])]
+ public bool $enabled = false;
+
+ #[Groups(['exercise_ai_aiken_generator:read'])]
+ public bool $courseExerciseGeneratorEnabled = false;
+
+ #[Groups(['exercise_ai_aiken_generator:read'])]
+ public bool $aiHelpersEnabled = false;
+
+ #[Groups(['exercise_ai_aiken_generator:read'])]
+ public string $language = 'en';
+
+ #[Groups(['exercise_ai_aiken_generator:read'])]
+ public string $csrfToken = '';
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_ai_aiken_generator:read'])]
+ public array $textProviders = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_ai_aiken_generator:read'])]
+ public array $documentProviders = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_ai_aiken_generator:read'])]
+ public array $questionTypes = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_ai_aiken_generator:read'])]
+ public array $documents = [];
+
+ #[Groups(['exercise_ai_aiken_generator:read'])]
+ public int $defaultNumberOfQuestions = 10;
+
+ #[Groups(['exercise_ai_aiken_generator:read'])]
+ public int $maxNumberOfQuestions = 100;
+
+ #[Groups(['exercise_ai_aiken_generator:read'])]
+ public int $defaultTotalWeight = 20;
+
+ #[Groups(['exercise_ai_aiken_generator:read'])]
+ public string $message = '';
+
+ public function getId(): string
+ {
+ return $this->id;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseCategoryManagement.php b/src/CoreBundle/ApiResource/Exercise/ExerciseCategoryManagement.php
new file mode 100644
index 00000000000..c73fc309e5f
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseCategoryManagement.php
@@ -0,0 +1,128 @@
+ 'exercise|question'],
+ openapi: new Operation(
+ summary: 'Exercise category management data',
+ parameters: [
+ new Parameter(name: 'categoryType', in: 'path', required: true, schema: ['type' => 'string']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_category_management',
+ provider: ExerciseCategoryManagementProvider::class,
+ ),
+ new Post(
+ uriTemplate: '/exercise/categories/{categoryType}/action',
+ requirements: ['categoryType' => 'exercise|question'],
+ openapi: new Operation(
+ summary: 'Run an exercise category action',
+ parameters: [
+ new Parameter(name: 'categoryType', in: 'path', required: true, schema: ['type' => 'string']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'post_exercise_category_management_action',
+ processor: ExerciseCategoryManagementProcessor::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_category_management:read']],
+ denormalizationContext: ['groups' => ['exercise_category_management:write']],
+)]
+final class ExerciseCategoryManagement
+{
+ #[ApiProperty(identifier: false)]
+ #[Groups(['exercise_category_management:read'])]
+ public string $id = 'exercise_categories';
+
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_category_management:read', 'exercise_category_management:write'])]
+ public string $categoryType = '';
+
+ #[Groups(['exercise_category_management:read'])]
+ public string $title = '';
+
+ #[Groups(['exercise_category_management:read', 'exercise_category_management:write'])]
+ public string $action = '';
+
+ #[Groups(['exercise_category_management:read', 'exercise_category_management:write'])]
+ public ?int $categoryId = null;
+
+ #[Groups(['exercise_category_management:read', 'exercise_category_management:write'])]
+ public string $categoryTitle = '';
+
+ #[Groups(['exercise_category_management:read', 'exercise_category_management:write'])]
+ public string $description = '';
+
+ #[Groups(['exercise_category_management:write'])]
+ public string $submittedCsrfToken = '';
+
+ #[Groups(['exercise_category_management:write'])]
+ public string $csvContent = '';
+
+ #[Groups(['exercise_category_management:read'])]
+ public int $importedCount = 0;
+
+ #[Groups(['exercise_category_management:read'])]
+ public int $skippedCount = 0;
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_category_management:read'])]
+ public array $items = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_category_management:read'])]
+ public array $legacyUrls = [];
+
+ #[Groups(['exercise_category_management:read'])]
+ public string $csrfToken = '';
+
+ #[Groups(['exercise_category_management:read'])]
+ public bool $canManage = false;
+
+ #[Groups(['exercise_category_management:read'])]
+ public bool $success = false;
+
+ #[Groups(['exercise_category_management:read'])]
+ public string $message = '';
+
+ public function getId(): string
+ {
+ return $this->id;
+ }
+
+ public function getCategoryType(): string
+ {
+ return $this->categoryType;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseConfiguration.php b/src/CoreBundle/ApiResource/Exercise/ExerciseConfiguration.php
new file mode 100644
index 00000000000..1080f1ce940
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseConfiguration.php
@@ -0,0 +1,293 @@
+ 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_configuration_create',
+ provider: ExerciseConfigurationProvider::class,
+ ),
+ new Get(
+ uriTemplate: '/exercise/configuration/{exerciseId}',
+ requirements: ['exerciseId' => '\\d+'],
+ openapi: new Operation(
+ summary: 'Exercise configuration for edition',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_configuration_edit',
+ provider: ExerciseConfigurationProvider::class,
+ ),
+ new Post(
+ uriTemplate: '/exercise/configuration',
+ openapi: new Operation(
+ summary: 'Create an exercise configuration',
+ parameters: [
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'post_exercise_configuration',
+ processor: ExerciseConfigurationProcessor::class,
+ ),
+ new Put(
+ uriTemplate: '/exercise/configuration/{exerciseId}',
+ requirements: ['exerciseId' => '\\d+'],
+ openapi: new Operation(
+ summary: 'Update an exercise configuration',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ read: false,
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'put_exercise_configuration',
+ processor: ExerciseConfigurationProcessor::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_configuration:read']],
+ denormalizationContext: ['groups' => ['exercise_configuration:write']],
+)]
+final class ExerciseConfiguration
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public ?int $exerciseId = null;
+
+ #[Groups(['exercise_configuration:read'])]
+ public string $mode = 'create';
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public string $title = '';
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public string $description = '';
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public int $type = 2;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public ?int $categoryId = null;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public string $language = '';
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public bool $updateTitleInLearningPaths = false;
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public array $skillIds = [];
+
+ /**
+ * Legacy extra field values keyed by extra field variable.
+ *
+ * @var array
+ */
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public array $extraFieldValues = [];
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public string $extraNotification = '';
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public ?string $startTime = null;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public ?string $endTime = null;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public ?int $duration = null;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public int $maxAttempt = 0;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public int $passPercentage = 0;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public int $random = 0;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public int $randomByCategory = 0;
+
+ /**
+ * Legacy category matrix stored in c_quiz_rel_category.
+ *
+ * @var array>
+ */
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public array $categoryMatrix = [];
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public bool $randomAnswers = false;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public bool $showPreviousButton = true;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public bool $preventBackwards = false;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public bool $hideAttemptsTable = false;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public bool $autoLaunch = false;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public bool $addToGradebook = false;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public ?int $gradebookCategoryId = null;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public int $gradebookWeight = 100;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public bool $gradebookVisible = true;
+
+ /**
+ * Legacy comma-separated notification ids stored as an array for Vue.
+ *
+ * @var array
+ */
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public array $notifications = [];
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public string $accessCondition = '';
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public string $sound = '';
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public int $feedbackType = 0;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public int $resultsDisabled = 0;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public int $questionSelectionType = 0;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public bool $displayCategoryName = true;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public bool $hideQuestionTitle = false;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public bool $hideQuestionNumber = false;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public bool $propagateNeg = false;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public int $saveCorrectAnswers = 0;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public bool $reviewAnswers = false;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public int $expiredTime = 0;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public int $displayChartDegreeCertainty = 0;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public int $sendEmailChartDegreeCertainty = 0;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public int $notDisplayBalancePercentageCategorieQuestion = 0;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public int $displayChartDegreeCertaintyCategory = 0;
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public int $gatherQuestionsCategories = 0;
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public array $pageResultConfiguration = [];
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public string $textWhenFinished = '';
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public string $textWhenFinishedFailure = '';
+
+ #[Groups(['exercise_configuration:read', 'exercise_configuration:write'])]
+ public string $csrfToken = '';
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_configuration:read'])]
+ public array $settings = [];
+
+ /**
+ * @var array>>
+ */
+ #[Groups(['exercise_configuration:read'])]
+ public array $options = [];
+
+ #[Groups(['exercise_configuration:read'])]
+ public bool $canEdit = false;
+
+ /**
+ * Legacy freezes these fields after an exercise has been created.
+ *
+ * @var array
+ */
+ #[Groups(['exercise_configuration:read'])]
+ public array $lockedFields = [];
+
+ #[Groups(['exercise_configuration:read'])]
+ public bool $canCreate = false;
+
+ #[Groups(['exercise_configuration:read'])]
+ public string $listUrl = '';
+
+ #[Groups(['exercise_configuration:read'])]
+ public string $questionsUrl = '';
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseGlobalQuestion.php b/src/CoreBundle/ApiResource/Exercise/ExerciseGlobalQuestion.php
new file mode 100644
index 00000000000..bb43dabd259
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseGlobalQuestion.php
@@ -0,0 +1,65 @@
+ 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_global_question_types',
+ provider: ExerciseGlobalQuestionProvider::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_global_question:read']],
+)]
+final class ExerciseGlobalQuestion
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_global_question:read'])]
+ public string $id = 'global';
+
+ #[Groups(['exercise_global_question:read'])]
+ public string $title = '';
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_global_question:read'])]
+ public array $questionTypes = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_global_question:read'])]
+ public array $exercises = [];
+
+ #[Groups(['exercise_global_question:read'])]
+ public bool $canManage = false;
+
+ public function getId(): string
+ {
+ return $this->id;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseGlobalReport.php b/src/CoreBundle/ApiResource/Exercise/ExerciseGlobalReport.php
new file mode 100644
index 00000000000..72bcdda8942
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseGlobalReport.php
@@ -0,0 +1,64 @@
+ 'integer']),
+ new Parameter(name: 'courseId', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_ADMIN')",
+ name: 'get_exercise_global_report',
+ provider: ExerciseGlobalReportProvider::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_global_report:read']],
+)]
+final class ExerciseGlobalReport
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_global_report:read'])]
+ public string $id = 'exercise_global_report';
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_global_report:read'])]
+ public array $courseOptions = [];
+
+ #[Groups(['exercise_global_report:read'])]
+ public int $selectedCourseId = 0;
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_global_report:read'])]
+ public array $actionUrls = [];
+
+ #[Groups(['exercise_global_report:read'])]
+ public bool $canExport = false;
+
+ public function getId(): string
+ {
+ return $this->id;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseLearningPathItem.php b/src/CoreBundle/ApiResource/Exercise/ExerciseLearningPathItem.php
new file mode 100644
index 00000000000..84ebe74ee71
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseLearningPathItem.php
@@ -0,0 +1,60 @@
+ '\\d+'],
+ openapi: new Operation(
+ summary: 'Attach an exercise created from the LP flow as a learning path item',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'origin', in: 'query', required: false, schema: ['type' => 'string']),
+ new Parameter(name: 'lp_id', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'node', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'post_exercise_learning_path_item',
+ processor: ExerciseLearningPathItemProcessor::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_learning_path_item:read']],
+ denormalizationContext: ['groups' => ['exercise_learning_path_item:write']],
+)]
+final class ExerciseLearningPathItem
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_learning_path_item:read', 'exercise_learning_path_item:write'])]
+ public ?int $exerciseId = null;
+
+ #[Groups(['exercise_learning_path_item:write'])]
+ public string $submittedCsrfToken = '';
+
+ #[Groups(['exercise_learning_path_item:read'])]
+ public ?int $lpItemId = null;
+
+ #[Groups(['exercise_learning_path_item:read'])]
+ public bool $success = false;
+
+ #[Groups(['exercise_learning_path_item:read'])]
+ public string $message = '';
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseList.php b/src/CoreBundle/ApiResource/Exercise/ExerciseList.php
new file mode 100644
index 00000000000..03f619fe1e2
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseList.php
@@ -0,0 +1,123 @@
+ 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'search', in: 'query', required: false, schema: ['type' => 'string']),
+ new Parameter(name: 'categoryId', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT') or is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_list',
+ provider: ExerciseListProvider::class,
+ ),
+ new Post(
+ uriTemplate: '/exercise/list/action',
+ openapi: new Operation(
+ summary: 'Run an exercise list action',
+ parameters: [
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'post_exercise_list_action',
+ processor: ExerciseListActionProcessor::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_list:read']],
+ denormalizationContext: ['groups' => ['exercise_list:write']],
+)]
+final class ExerciseList
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_list:read'])]
+ public string $id = 'exercise_list';
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_list:read'])]
+ public array $items = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_list:read'])]
+ public array $categories = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_list:read'])]
+ public array $settings = [];
+
+ #[Groups(['exercise_list:read'])]
+ public int $totalItems = 0;
+
+ #[Groups(['exercise_list:read'])]
+ public bool $canManage = false;
+
+ #[Groups(['exercise_list:read'])]
+ public bool $canCreate = false;
+
+ #[Groups(['exercise_list:read'])]
+ public bool $usesLegacyActions = false;
+
+ #[Groups(['exercise_list:read', 'exercise_list:write'])]
+ public int $exerciseId = 0;
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_list:read', 'exercise_list:write'])]
+ public array $exerciseIds = [];
+
+ #[Groups(['exercise_list:read', 'exercise_list:write'])]
+ public string $action = '';
+
+ #[Groups(['exercise_list:read'])]
+ public bool $success = false;
+
+ #[Groups(['exercise_list:read'])]
+ public string $message = '';
+
+ #[Groups(['exercise_list:read'])]
+ public int $processedCount = 0;
+
+ #[Groups(['exercise_list:read'])]
+ public int $skippedCount = 0;
+
+ #[Groups(['exercise_list:read', 'exercise_list:write'])]
+ public string $submittedCsrfToken = '';
+
+ public function getId(): string
+ {
+ return $this->id;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseLiveResults.php b/src/CoreBundle/ApiResource/Exercise/ExerciseLiveResults.php
new file mode 100644
index 00000000000..a1f9bdc59c2
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseLiveResults.php
@@ -0,0 +1,87 @@
+ '\\d+'],
+ openapi: new Operation(
+ summary: 'Exercise live results',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'minutes', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'status', in: 'query', required: false, schema: ['type' => 'string']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_live_results',
+ provider: ExerciseLiveResultsProvider::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_live_results:read']],
+)]
+final class ExerciseLiveResults
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_live_results:read'])]
+ public ?int $exerciseId = null;
+
+ #[Groups(['exercise_live_results:read'])]
+ public string $title = '';
+
+ #[Groups(['exercise_live_results:read'])]
+ public string $description = '';
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_live_results:read'])]
+ public array $attempts = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_live_results:read'])]
+ public array $summary = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_live_results:read'])]
+ public array $filters = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_live_results:read'])]
+ public array $legacyUrls = [];
+
+ #[Groups(['exercise_live_results:read'])]
+ public int $totalItems = 0;
+
+ #[Groups(['exercise_live_results:read'])]
+ public bool $canManage = true;
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseOverview.php b/src/CoreBundle/ApiResource/Exercise/ExerciseOverview.php
new file mode 100644
index 00000000000..7894c3e3054
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseOverview.php
@@ -0,0 +1,151 @@
+ '\\d+'],
+ openapi: new Operation(
+ summary: 'Exercise overview for a course',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT') or is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_overview',
+ provider: ExerciseOverviewProvider::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_overview:read']],
+)]
+final class ExerciseOverview
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_overview:read'])]
+ public ?int $exerciseId = null;
+
+ #[Groups(['exercise_overview:read'])]
+ public string $title = '';
+
+ #[Groups(['exercise_overview:read'])]
+ public string $description = '';
+
+ #[Groups(['exercise_overview:read'])]
+ public bool $visible = false;
+
+ #[Groups(['exercise_overview:read'])]
+ public string $categoryTitle = '';
+
+ #[Groups(['exercise_overview:read'])]
+ public int $questionCount = 0;
+
+ #[Groups(['exercise_overview:read'])]
+ public int $attemptCount = 0;
+
+ #[Groups(['exercise_overview:read'])]
+ public float $averageScore = 0.0;
+
+ #[Groups(['exercise_overview:read'])]
+ public float $maxScore = 0.0;
+
+ #[Groups(['exercise_overview:read'])]
+ public int $passPercentage = 0;
+
+ #[Groups(['exercise_overview:read'])]
+ public ?string $startTime = null;
+
+ #[Groups(['exercise_overview:read'])]
+ public ?string $endTime = null;
+
+ #[Groups(['exercise_overview:read'])]
+ public ?int $duration = null;
+
+ #[Groups(['exercise_overview:read'])]
+ public int $maxAttempt = 0;
+
+ #[Groups(['exercise_overview:read'])]
+ public int $feedbackType = 0;
+
+ #[Groups(['exercise_overview:read'])]
+ public int $resultsDisabled = 0;
+
+ #[Groups(['exercise_overview:read'])]
+ public bool $oneQuestionPerPage = false;
+
+ #[Groups(['exercise_overview:read'])]
+ public bool $randomAnswers = false;
+
+ #[Groups(['exercise_overview:read'])]
+ public int $random = 0;
+
+ #[Groups(['exercise_overview:read'])]
+ public int $randomByCategory = 0;
+
+ #[Groups(['exercise_overview:read'])]
+ public bool $canManage = false;
+
+ #[Groups(['exercise_overview:read'])]
+ public bool $canOpen = false;
+
+ #[Groups(['exercise_overview:read'])]
+ public bool $canReport = false;
+
+ #[Groups(['exercise_overview:read'])]
+ public string $availabilityStatus = 'open';
+
+ #[Groups(['exercise_overview:read'])]
+ public int $currentUserAttemptCount = 0;
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_overview:read'])]
+ public array $currentUserAttempts = [];
+
+ #[Groups(['exercise_overview:read'])]
+ public bool $showAttemptsTable = false;
+
+ #[Groups(['exercise_overview:read'])]
+ public bool $showScoreColumn = false;
+
+ #[Groups(['exercise_overview:read'])]
+ public bool $showDetailsColumn = false;
+
+ #[Groups(['exercise_overview:read'])]
+ public bool $attemptLimitReached = false;
+
+ #[Groups(['exercise_overview:read'])]
+ public bool $canStart = false;
+
+ #[Groups(['exercise_overview:read'])]
+ public string $startButtonLabel = 'Start test';
+
+ #[Groups(['exercise_overview:read'])]
+ public bool $browserCheckEnabled = false;
+
+ #[Groups(['exercise_overview:read'])]
+ public string $notice = '';
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExercisePendingAttempts.php b/src/CoreBundle/ApiResource/Exercise/ExercisePendingAttempts.php
new file mode 100644
index 00000000000..d7baa5a3d0b
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExercisePendingAttempts.php
@@ -0,0 +1,93 @@
+ 'integer']),
+ new Parameter(name: 'exerciseId', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'filterByUser', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'status', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'questionTypeId', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'startDate', in: 'query', required: false, schema: ['type' => 'string']),
+ new Parameter(name: 'endDate', in: 'query', required: false, schema: ['type' => 'string']),
+ ],
+ ),
+ security: "is_granted('ROLE_USER')",
+ name: 'get_exercise_pending_attempts',
+ provider: ExercisePendingAttemptsProvider::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_pending_attempts:read']],
+)]
+final class ExercisePendingAttempts
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_pending_attempts:read'])]
+ public string $id = 'exercise_pending_attempts';
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_pending_attempts:read'])]
+ public array $items = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_pending_attempts:read'])]
+ public array $filters = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_pending_attempts:read'])]
+ public array $courseOptions = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_pending_attempts:read'])]
+ public array $exerciseOptions = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_pending_attempts:read'])]
+ public array $settings = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_pending_attempts:read'])]
+ public array $actionUrls = [];
+
+ #[Groups(['exercise_pending_attempts:read'])]
+ public int $totalItems = 0;
+
+ #[Groups(['exercise_pending_attempts:read'])]
+ public bool $canManage = false;
+
+ public function getId(): string
+ {
+ return $this->id;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseQuestion.php b/src/CoreBundle/ApiResource/Exercise/ExerciseQuestion.php
new file mode 100644
index 00000000000..44bda49c919
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseQuestion.php
@@ -0,0 +1,100 @@
+ '\\d+'],
+ openapi: new Operation(
+ summary: 'Exercise question type selector and current questions',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_questions',
+ provider: ExerciseQuestionProvider::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_question:read']],
+)]
+final class ExerciseQuestion
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_question:read'])]
+ public ?int $exerciseId = null;
+
+ #[Groups(['exercise_question:read'])]
+ public string $title = '';
+
+ #[Groups(['exercise_question:read'])]
+ public int $questionCount = 0;
+
+ #[Groups(['exercise_question:read'])]
+ public float $totalScore = 0.0;
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_question:read'])]
+ public array $questionTypes = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_question:read'])]
+ public array $questions = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_question:read'])]
+ public array $legacyUrls = [];
+
+ #[Groups(['exercise_question:read'])]
+ public bool $canManage = false;
+
+ #[Groups(['exercise_question:read'])]
+ public bool $isAdaptiveFeedback = false;
+
+ #[Groups(['exercise_question:read'])]
+ public bool $canRecycleQuestions = true;
+
+ #[Groups(['exercise_question:read'])]
+ public bool $usesLegacyQuestionEditors = true;
+
+ #[Groups(['exercise_question:read'])]
+ public bool $isLinkedToLearningPath = false;
+
+ #[Groups(['exercise_question:read'])]
+ public bool $isReadOnlyFromLearningPath = false;
+
+ #[Groups(['exercise_question:read'])]
+ public string $learningPathReadOnlyMessage = '';
+
+ #[Groups(['exercise_question:read'])]
+ public string $csrfToken = '';
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseQuestionAction.php b/src/CoreBundle/ApiResource/Exercise/ExerciseQuestionAction.php
new file mode 100644
index 00000000000..d6c89f57683
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseQuestionAction.php
@@ -0,0 +1,71 @@
+ '\\d+'],
+ openapi: new Operation(
+ summary: 'Run an exercise question action',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'post_exercise_question_action',
+ processor: ExerciseQuestionActionProcessor::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_question_action:read']],
+ denormalizationContext: ['groups' => ['exercise_question_action:write']],
+)]
+final class ExerciseQuestionAction
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_question_action:read', 'exercise_question_action:write'])]
+ public ?int $exerciseId = null;
+
+ #[Groups(['exercise_question_action:read', 'exercise_question_action:write'])]
+ public string $action = '';
+
+ #[Groups(['exercise_question_action:read', 'exercise_question_action:write'])]
+ public ?int $questionId = null;
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_question_action:read', 'exercise_question_action:write'])]
+ public array $questionIds = [];
+
+ #[Groups(['exercise_question_action:write'])]
+ public string $submittedCsrfToken = '';
+
+ #[Groups(['exercise_question_action:read'])]
+ public bool $success = false;
+
+ #[Groups(['exercise_question_action:read'])]
+ public string $message = '';
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseQuestionBank.php b/src/CoreBundle/ApiResource/Exercise/ExerciseQuestionBank.php
new file mode 100644
index 00000000000..7c9b4d029c8
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseQuestionBank.php
@@ -0,0 +1,203 @@
+ 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'page', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'itemsPerPage', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'search', in: 'query', required: false, schema: ['type' => 'string']),
+ new Parameter(name: 'categoryId', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'sourceExerciseId', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'difficulty', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'questionType', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_global_question_bank',
+ provider: ExerciseQuestionBankProvider::class,
+ ),
+ new Get(
+ uriTemplate: '/exercise/questions/{exerciseId}/bank',
+ requirements: ['exerciseId' => '\\d+'],
+ openapi: new Operation(
+ summary: 'Exercise question bank data',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'page', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'itemsPerPage', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'search', in: 'query', required: false, schema: ['type' => 'string']),
+ new Parameter(name: 'categoryId', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'sourceExerciseId', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'difficulty', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'questionType', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_question_bank',
+ provider: ExerciseQuestionBankProvider::class,
+ ),
+ new Post(
+ uriTemplate: '/exercise/questions/bank/action',
+ openapi: new Operation(
+ summary: 'Run a global exercise question bank action',
+ parameters: [
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'post_exercise_global_question_bank_action',
+ processor: ExerciseQuestionBankProcessor::class,
+ ),
+ new Post(
+ uriTemplate: '/exercise/questions/{exerciseId}/bank/action',
+ requirements: ['exerciseId' => '\\d+'],
+ openapi: new Operation(
+ summary: 'Run an exercise question bank action',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'post_exercise_question_bank_action',
+ processor: ExerciseQuestionBankProcessor::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_question_bank:read']],
+ denormalizationContext: ['groups' => ['exercise_question_bank:write']],
+)]
+final class ExerciseQuestionBank
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_question_bank:read', 'exercise_question_bank:write'])]
+ public ?int $exerciseId = null;
+
+ #[Groups(['exercise_question_bank:read', 'exercise_question_bank:write'])]
+ public string $action = '';
+
+ #[Groups(['exercise_question_bank:read', 'exercise_question_bank:write'])]
+ public ?int $questionId = null;
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_question_bank:read', 'exercise_question_bank:write'])]
+ public array $questionIds = [];
+
+ #[Groups(['exercise_question_bank:write'])]
+ public string $submittedCsrfToken = '';
+
+ #[Groups(['exercise_question_bank:read'])]
+ public string $title = '';
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_question_bank:read'])]
+ public array $items = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_question_bank:read'])]
+ public array $categoryOptions = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_question_bank:read'])]
+ public array $exerciseOptions = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_question_bank:read'])]
+ public array $difficultyOptions = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_question_bank:read'])]
+ public array $questionTypeOptions = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_question_bank:read'])]
+ public array $filters = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_question_bank:read'])]
+ public array $legacyUrls = [];
+
+ #[Groups(['exercise_question_bank:read'])]
+ public int $page = 1;
+
+ #[Groups(['exercise_question_bank:read'])]
+ public int $itemsPerPage = 20;
+
+ #[Groups(['exercise_question_bank:read'])]
+ public int $totalItems = 0;
+
+ #[Groups(['exercise_question_bank:read'])]
+ public string $csrfToken = '';
+
+ #[Groups(['exercise_question_bank:read'])]
+ public bool $canManage = false;
+
+ #[Groups(['exercise_question_bank:read'])]
+ public bool $globalMode = false;
+
+ #[Groups(['exercise_question_bank:read'])]
+ public bool $canDelete = false;
+
+ #[Groups(['exercise_question_bank:read'])]
+ public bool $success = false;
+
+ #[Groups(['exercise_question_bank:read'])]
+ public int $addedCount = 0;
+
+ #[Groups(['exercise_question_bank:read'])]
+ public int $skippedCount = 0;
+
+ #[Groups(['exercise_question_bank:read'])]
+ public string $message = '';
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseQuestionEditor.php b/src/CoreBundle/ApiResource/Exercise/ExerciseQuestionEditor.php
new file mode 100644
index 00000000000..378dfd31317
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseQuestionEditor.php
@@ -0,0 +1,401 @@
+ 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_global_question_editor_create',
+ provider: ExerciseQuestionEditorProvider::class,
+ ),
+ new Get(
+ uriTemplate: '/exercise/questions/global/editor/{questionId}',
+ requirements: ['questionId' => '\\d+'],
+ openapi: new Operation(
+ summary: 'Exercise question editor for an existing global question',
+ parameters: [
+ new Parameter(name: 'questionId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_global_question_editor_edit',
+ provider: ExerciseQuestionEditorProvider::class,
+ ),
+ new Get(
+ uriTemplate: '/exercise/questions/{exerciseId}/editor',
+ requirements: ['exerciseId' => '\\d+'],
+ openapi: new Operation(
+ summary: 'Exercise question editor for a new question',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'type', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_question_editor_create',
+ provider: ExerciseQuestionEditorProvider::class,
+ ),
+ new Get(
+ uriTemplate: '/exercise/questions/{exerciseId}/editor/{questionId}',
+ requirements: ['exerciseId' => '\\d+', 'questionId' => '\\d+'],
+ openapi: new Operation(
+ summary: 'Exercise question editor for an existing choice question',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'questionId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_question_editor_edit',
+ provider: ExerciseQuestionEditorProvider::class,
+ ),
+ new Post(
+ uriTemplate: '/exercise/questions/global/editor',
+ openapi: new Operation(
+ summary: 'Create a global exercise question',
+ parameters: [
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'post_exercise_global_question_editor_create',
+ processor: ExerciseQuestionEditorProcessor::class,
+ ),
+ new Post(
+ uriTemplate: '/exercise/questions/global/editor/{questionId}',
+ requirements: ['questionId' => '\\d+'],
+ openapi: new Operation(
+ summary: 'Update a global exercise question',
+ parameters: [
+ new Parameter(name: 'questionId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'post_exercise_global_question_editor_update',
+ processor: ExerciseQuestionEditorProcessor::class,
+ ),
+ new Post(
+ uriTemplate: '/exercise/questions/{exerciseId}/editor',
+ requirements: ['exerciseId' => '\\d+'],
+ openapi: new Operation(
+ summary: 'Create an exercise question',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'post_exercise_question_editor_create',
+ processor: ExerciseQuestionEditorProcessor::class,
+ ),
+ new Post(
+ uriTemplate: '/exercise/questions/{exerciseId}/editor/{questionId}',
+ requirements: ['exerciseId' => '\\d+', 'questionId' => '\\d+'],
+ openapi: new Operation(
+ summary: 'Update an exercise question',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'questionId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'post_exercise_question_editor_update',
+ processor: ExerciseQuestionEditorProcessor::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_question_editor:read']],
+ denormalizationContext: ['groups' => ['exercise_question_editor:write']],
+)]
+final class ExerciseQuestionEditor
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public ?int $exerciseId = null;
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public ?int $questionId = null;
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public int $type = 1;
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $typeLabel = '';
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $title = '';
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $description = '';
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $feedback = '';
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $dropdownListText = '';
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $fillBlanksText = '';
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public array $fillBlankItems = [];
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public int $fillBlanksSeparator = 0;
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public bool $fillBlanksSwitchable = false;
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public bool $fillBlanksCaseInsensitive = false;
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $fillBlanksComment = '';
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $calculatedText = '';
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $calculatedFormula = '';
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public array $calculatedRanges = [];
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public int $calculatedVariations = 1;
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $calculatedComment = '';
+
+ #[Groups(['exercise_question_editor:read'])]
+ public string $annotationImageUrl = '';
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $annotationImageData = '';
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $annotationImageName = '';
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $annotationImageMimeType = '';
+
+ #[Groups(['exercise_question_editor:read'])]
+ public string $hotspotImageUrl = '';
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $hotspotImageData = '';
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $hotspotImageName = '';
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $hotspotImageMimeType = '';
+
+ #[Groups(['exercise_question_editor:read'])]
+ public string $onlyofficeTemplateUrl = '';
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $onlyofficeTemplateName = '';
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $onlyofficeTemplateData = '';
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $onlyofficeTemplateMimeType = '';
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public array $hotspotItems = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_question_editor:read'])]
+ public array $hotspotScenarioOptions = [];
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $hotspotScenarioSuccessType = '';
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $hotspotScenarioSuccessUrl = '';
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $hotspotScenarioFailureType = '';
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $hotspotScenarioFailureUrl = '';
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public float $score = 0.0;
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public float $globalScore = 0.0;
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public float $correctScore = 1.0;
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public float $wrongScore = -0.5;
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public float $unknownScore = 0.0;
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public bool $noNegativeScore = false;
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public bool $mandatory = false;
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public ?int $duration = null;
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public int $difficulty = 1;
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public int $categoryId = 0;
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public int $parentMediaId = 0;
+
+ #[Groups(['exercise_question_editor:read'])]
+ public int $questionCount = 0;
+
+ #[Groups(['exercise_question_editor:read'])]
+ public float $totalScore = 0.0;
+
+ #[Groups(['exercise_question_editor:read'])]
+ public bool $usesGlobalScore = false;
+
+ #[Groups(['exercise_question_editor:read'])]
+ public bool $hasFixedUnknownAnswer = false;
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_question_editor:read'])]
+ public array $categoryOptions = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_question_editor:read'])]
+ public array $mediaOptions = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public array $answers = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public array $matchingOptions = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public array $matchingPairs = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public array $draggableItems = [];
+
+ #[Groups(['exercise_question_editor:read', 'exercise_question_editor:write'])]
+ public string $matchingOrientation = 'h';
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_question_editor:read'])]
+ public array $legacyUrls = [];
+
+ #[Groups(['exercise_question_editor:read'])]
+ public string $csrfToken = '';
+
+ #[Groups(['exercise_question_editor:read'])]
+ public bool $allowQuestionFeedback = false;
+
+ #[Groups(['exercise_question_editor:read'])]
+ public bool $imageZoomEnabled = false;
+
+ #[Groups(['exercise_question_editor:read'])]
+ public bool $allowMandatoryQuestion = false;
+
+ #[Groups(['exercise_question_editor:read'])]
+ public bool $canUseHotspotDelineationScenario = false;
+
+ #[Groups(['exercise_question_editor:read'])]
+ public bool $isLinkedToLearningPath = false;
+
+ #[Groups(['exercise_question_editor:read'])]
+ public bool $isReadOnlyFromLearningPath = false;
+
+ #[Groups(['exercise_question_editor:read'])]
+ public string $learningPathReadOnlyMessage = '';
+
+ #[Groups(['exercise_question_editor:write'])]
+ public string $submittedCsrfToken = '';
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseQuestionImport.php b/src/CoreBundle/ApiResource/Exercise/ExerciseQuestionImport.php
new file mode 100644
index 00000000000..3827796e5ce
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseQuestionImport.php
@@ -0,0 +1,154 @@
+ 'aiken|excel|qti2'],
+ openapi: new Operation(
+ summary: 'Exercise question import data',
+ parameters: [
+ new Parameter(name: 'importType', in: 'path', required: true, schema: ['type' => 'string']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'origin', in: 'query', required: false, schema: ['type' => 'string']),
+ new Parameter(name: 'returnToLp', in: 'query', required: false, schema: ['type' => 'string']),
+ new Parameter(name: 'lp_id', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'learnpath_id', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'lp_parent_id', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'parent', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_question_import',
+ provider: ExerciseQuestionImportProvider::class,
+ ),
+ new Post(
+ uriTemplate: '/exercise/import/{importType}',
+ requirements: ['importType' => 'aiken|excel|qti2'],
+ openapi: new Operation(
+ summary: 'Import exercise questions',
+ parameters: [
+ new Parameter(name: 'importType', in: 'path', required: true, schema: ['type' => 'string']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'origin', in: 'query', required: false, schema: ['type' => 'string']),
+ new Parameter(name: 'returnToLp', in: 'query', required: false, schema: ['type' => 'string']),
+ new Parameter(name: 'lp_id', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'learnpath_id', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'lp_parent_id', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'parent', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ requestBody: new RequestBody(
+ content: new ArrayObject([
+ 'multipart/form-data' => [
+ 'schema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'submittedCsrfToken' => ['type' => 'string'],
+ 'totalWeight' => ['type' => 'number'],
+ 'useCustomScore' => ['type' => 'boolean'],
+ 'correctScore' => ['type' => 'number'],
+ 'incorrectScore' => ['type' => 'number'],
+ 'file' => [
+ 'type' => 'string',
+ 'format' => 'binary',
+ 'description' => 'Aiken .txt/.zip file, Excel .xls/.xlsx file or QTI2 .zip file',
+ ],
+ ],
+ ],
+ ],
+ ]),
+ ),
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ deserialize: false,
+ name: 'post_exercise_question_import',
+ processor: ExerciseQuestionImportProcessor::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_question_import:read']],
+)]
+final class ExerciseQuestionImport
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_question_import:read'])]
+ public string $importType = 'aiken';
+
+ #[Groups(['exercise_question_import:read'])]
+ public string $title = '';
+
+ #[Groups(['exercise_question_import:read'])]
+ public string $csrfToken = '';
+
+ #[Groups(['exercise_question_import:read'])]
+ public bool $canManage = false;
+
+ #[Groups(['exercise_question_import:read'])]
+ public bool $success = false;
+
+ #[Groups(['exercise_question_import:read'])]
+ public string $message = '';
+
+ #[Groups(['exercise_question_import:read'])]
+ public ?int $exerciseId = null;
+
+ #[Groups(['exercise_question_import:read'])]
+ public string $exerciseTitle = '';
+
+ #[Groups(['exercise_question_import:read'])]
+ public int $importedQuestionCount = 0;
+
+ #[Groups(['exercise_question_import:read'])]
+ public int $skippedQuestionCount = 0;
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_question_import:read'])]
+ public array $errors = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_question_import:read'])]
+ public array $actionUrls = [];
+
+ #[Groups(['exercise_question_import:read'])]
+ public string $sample = '';
+
+ #[Groups(['exercise_question_import:read'])]
+ public bool $learningPathContext = false;
+
+ #[Groups(['exercise_question_import:read'])]
+ public ?int $learningPathItemId = null;
+
+ #[Groups(['exercise_question_import:read'])]
+ public string $learningPathMessage = '';
+
+ public function getImportType(): string
+ {
+ return $this->importType;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseQuestionStats.php b/src/CoreBundle/ApiResource/Exercise/ExerciseQuestionStats.php
new file mode 100644
index 00000000000..d409ea352ed
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseQuestionStats.php
@@ -0,0 +1,73 @@
+ '\\d+'],
+ openapi: new Operation(
+ summary: 'Exercise question statistics',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_question_stats',
+ provider: ExerciseQuestionStatsProvider::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_question_stats:read']],
+)]
+final class ExerciseQuestionStats
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_question_stats:read'])]
+ public ?int $exerciseId = null;
+
+ #[Groups(['exercise_question_stats:read'])]
+ public string $title = '';
+
+ #[Groups(['exercise_question_stats:read'])]
+ public string $description = '';
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_question_stats:read'])]
+ public array $questions = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_question_stats:read'])]
+ public array $summary = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_question_stats:read'])]
+ public array $actionUrls = [];
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseReportByQuestion.php b/src/CoreBundle/ApiResource/Exercise/ExerciseReportByQuestion.php
new file mode 100644
index 00000000000..a92cdcbb2f9
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseReportByQuestion.php
@@ -0,0 +1,73 @@
+ '\\d+'],
+ openapi: new Operation(
+ summary: 'Exercise report by question',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_report_by_question',
+ provider: ExerciseReportByQuestionProvider::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_report_by_question:read']],
+)]
+final class ExerciseReportByQuestion
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_report_by_question:read'])]
+ public ?int $exerciseId = null;
+
+ #[Groups(['exercise_report_by_question:read'])]
+ public string $title = '';
+
+ #[Groups(['exercise_report_by_question:read'])]
+ public string $description = '';
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_report_by_question:read'])]
+ public array $questions = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_report_by_question:read'])]
+ public array $summary = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_report_by_question:read'])]
+ public array $actionUrls = [];
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseRuntime.php b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntime.php
new file mode 100644
index 00000000000..ce0ecd05572
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntime.php
@@ -0,0 +1,98 @@
+ '\\d+'],
+ openapi: new Operation(
+ summary: 'Exercise runtime player data',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT') or is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_runtime',
+ provider: ExerciseRuntimeProvider::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_runtime:read']],
+)]
+final class ExerciseRuntime
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime:read'])]
+ public ?int $exerciseId = null;
+
+ #[Groups(['exercise_runtime:read'])]
+ public string $title = '';
+
+ #[Groups(['exercise_runtime:read'])]
+ public string $description = '';
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime:read'])]
+ public array $settings = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_runtime:read'])]
+ public array $questions = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime:read'])]
+ public array $legacyUrls = [];
+
+ #[Groups(['exercise_runtime:read'])]
+ public int $questionCount = 0;
+
+ #[Groups(['exercise_runtime:read'])]
+ public float $totalScore = 0.0;
+
+ #[Groups(['exercise_runtime:read'])]
+ public bool $canManage = false;
+
+
+ /**
+ * @var array|null
+ */
+ #[Groups(['exercise_runtime:read'])]
+ public ?array $attempt = null;
+
+ #[Groups(['exercise_runtime:read'])]
+ public bool $canStartAttempt = false;
+
+ #[Groups(['exercise_runtime:read'])]
+ public bool $canSubmit = false;
+
+ #[Groups(['exercise_runtime:read'])]
+ public bool $usesLegacySubmit = true;
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeAnswer.php b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeAnswer.php
new file mode 100644
index 00000000000..d4b51bdf133
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeAnswer.php
@@ -0,0 +1,121 @@
+ '\\d+',
+ 'attemptId' => '\\d+',
+ ],
+ openapi: new Operation(
+ summary: 'Save a Vue exercise runtime draft answer',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'attemptId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')",
+ name: 'post_exercise_runtime_answer',
+ processor: ExerciseRuntimeAnswerProcessor::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_runtime_answer:read']],
+ denormalizationContext: ['groups' => ['exercise_runtime_answer:write']],
+)]
+final class ExerciseRuntimeAnswer
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_answer:read', 'exercise_runtime_answer:write'])]
+ public ?int $exerciseId = null;
+
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_answer:read', 'exercise_runtime_answer:write'])]
+ public ?int $attemptId = null;
+
+ #[Groups(['exercise_runtime_answer:read', 'exercise_runtime_answer:write'])]
+ public ?int $questionId = null;
+
+ #[Groups(['exercise_runtime_answer:write'])]
+ public mixed $answer = null;
+
+ #[Groups(['exercise_runtime_answer:read', 'exercise_runtime_answer:write'])]
+ public ?bool $reviewLater = null;
+
+ #[Groups(['exercise_runtime_answer:write'])]
+ public bool $reviewLaterOnly = false;
+
+ #[Groups(['exercise_runtime_answer:read', 'exercise_runtime_answer:write'])]
+ public int $secondsSpent = 0;
+
+ #[Groups(['exercise_runtime_answer:write'])]
+ public string $navigationAction = '';
+
+ #[Groups(['exercise_runtime_answer:write'])]
+ public bool $confirmCategory = false;
+
+ #[Groups(['exercise_runtime_answer:read'])]
+ public bool $success = false;
+
+ #[Groups(['exercise_runtime_answer:read'])]
+ public string $message = '';
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_answer:read'])]
+ public array $savedAnswer = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_answer:read'])]
+ public array $answeredQuestionIds = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_answer:read'])]
+ public array $reviewQuestionIds = [];
+
+ #[Groups(['exercise_runtime_answer:read'])]
+ public int $answeredCount = 0;
+
+ #[Groups(['exercise_runtime_answer:read'])]
+ public bool $canFinish = false;
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_answer:read'])]
+ public array $feedback = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_answer:read'])]
+ public array $categoryReminder = [];
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeAttempt.php b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeAttempt.php
new file mode 100644
index 00000000000..17c8131d27c
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeAttempt.php
@@ -0,0 +1,120 @@
+ '\\d+'],
+ openapi: new Operation(
+ summary: 'Start or resume an exercise runtime attempt',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'origin', in: 'query', required: false, schema: ['type' => 'string']),
+ new Parameter(name: 'learnpath_id', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'learnpath_item_id', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'learnpath_item_view_id', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT') or is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'post_exercise_runtime_attempt',
+ processor: ExerciseRuntimeAttemptProcessor::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_runtime_attempt:read']],
+ denormalizationContext: ['groups' => ['exercise_runtime_attempt:write']],
+)]
+final class ExerciseRuntimeAttempt
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_attempt:read', 'exercise_runtime_attempt:write'])]
+ public ?int $exerciseId = null;
+
+ #[Groups(['exercise_runtime_attempt:read'])]
+ public ?int $attemptId = null;
+
+ #[Groups(['exercise_runtime_attempt:read'])]
+ public ?int $attemptNumber = null;
+
+ #[Groups(['exercise_runtime_attempt:read'])]
+ public string $status = '';
+
+ #[Groups(['exercise_runtime_attempt:read'])]
+ public bool $success = false;
+
+ #[Groups(['exercise_runtime_attempt:read'])]
+ public bool $preview = false;
+
+ #[Groups(['exercise_runtime_attempt:read'])]
+ public bool $usesLegacyRuntime = false;
+
+ #[Groups(['exercise_runtime_attempt:read'])]
+ public string $message = '';
+
+ #[Groups(['exercise_runtime_attempt:read'])]
+ public int $currentQuestionIndex = 0;
+
+ #[Groups(['exercise_runtime_attempt:read'])]
+ public ?int $currentQuestionId = null;
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_attempt:read'])]
+ public array $questionIds = [];
+
+ #[Groups(['exercise_runtime_attempt:read'])]
+ public int $totalQuestions = 0;
+
+ #[Groups(['exercise_runtime_attempt:read'])]
+ public ?string $startedAt = null;
+
+ #[Groups(['exercise_runtime_attempt:read'])]
+ public ?string $expiredAt = null;
+
+ #[Groups(['exercise_runtime_attempt:read'])]
+ public ?int $remainingSeconds = null;
+
+ #[Groups(['exercise_runtime_attempt:read'])]
+ public bool $canNavigatePrevious = false;
+
+ #[Groups(['exercise_runtime_attempt:read'])]
+ public bool $canNavigateNext = false;
+
+ #[Groups(['exercise_runtime_attempt:read'])]
+ public bool $canFinish = false;
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_runtime_attempt:read'])]
+ public array $savedAnswers = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_attempt:read'])]
+ public array $legacyUrls = [];
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeAttemptClose.php b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeAttemptClose.php
new file mode 100644
index 00000000000..5e18aa49945
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeAttemptClose.php
@@ -0,0 +1,64 @@
+ '\\d+',
+ 'attemptId' => '\\d+',
+ ],
+ openapi: new Operation(
+ summary: 'Close an incomplete exercise attempt from the migrated report',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'attemptId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'post_exercise_runtime_attempt_close',
+ processor: ExerciseRuntimeAttemptCloseProcessor::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_runtime_attempt_close:read']],
+ denormalizationContext: ['groups' => ['exercise_runtime_attempt_close:write']],
+)]
+final class ExerciseRuntimeAttemptClose
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_attempt_close:read', 'exercise_runtime_attempt_close:write'])]
+ public ?int $exerciseId = null;
+
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_attempt_close:read', 'exercise_runtime_attempt_close:write'])]
+ public ?int $attemptId = null;
+
+ #[Groups(['exercise_runtime_attempt_close:read'])]
+ public bool $success = false;
+
+ #[Groups(['exercise_runtime_attempt_close:read'])]
+ public string $message = '';
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeAttemptDelete.php b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeAttemptDelete.php
new file mode 100644
index 00000000000..0faaf25e2e3
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeAttemptDelete.php
@@ -0,0 +1,64 @@
+ '\\d+',
+ 'attemptId' => '\\d+',
+ ],
+ openapi: new Operation(
+ summary: 'Delete an exercise attempt from the migrated report',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'attemptId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'post_exercise_runtime_attempt_delete',
+ processor: ExerciseRuntimeAttemptDeleteProcessor::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_runtime_attempt_delete:read']],
+ denormalizationContext: ['groups' => ['exercise_runtime_attempt_delete:write']],
+)]
+final class ExerciseRuntimeAttemptDelete
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_attempt_delete:read', 'exercise_runtime_attempt_delete:write'])]
+ public ?int $exerciseId = null;
+
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_attempt_delete:read', 'exercise_runtime_attempt_delete:write'])]
+ public ?int $attemptId = null;
+
+ #[Groups(['exercise_runtime_attempt_delete:read'])]
+ public bool $success = false;
+
+ #[Groups(['exercise_runtime_attempt_delete:read'])]
+ public string $message = '';
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeAttemptEmail.php b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeAttemptEmail.php
new file mode 100644
index 00000000000..c5b49faedad
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeAttemptEmail.php
@@ -0,0 +1,76 @@
+ '\\d+',
+ 'attemptId' => '\\d+',
+ ],
+ openapi: new Operation(
+ summary: 'Send one exercise attempt result email from the migrated report',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'attemptId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'post_exercise_runtime_attempt_email',
+ processor: ExerciseRuntimeAttemptEmailProcessor::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_runtime_attempt_email:read']],
+ denormalizationContext: ['groups' => ['exercise_runtime_attempt_email:write']],
+)]
+final class ExerciseRuntimeAttemptEmail
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_attempt_email:read', 'exercise_runtime_attempt_email:write'])]
+ public ?int $exerciseId = null;
+
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_attempt_email:read', 'exercise_runtime_attempt_email:write'])]
+ public ?int $attemptId = null;
+
+ #[Groups(['exercise_runtime_attempt_email:write'])]
+ public ?string $node = null;
+
+ #[Groups(['exercise_runtime_attempt_email:read'])]
+ public bool $success = false;
+
+ #[Groups(['exercise_runtime_attempt_email:read'])]
+ public string $message = '';
+
+ #[Groups(['exercise_runtime_attempt_email:read'])]
+ public ?int $recipientId = null;
+
+ #[Groups(['exercise_runtime_attempt_email:read'])]
+ public string $recipientName = '';
+
+ #[Groups(['exercise_runtime_attempt_email:read'])]
+ public string $recipientEmail = '';
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeAttemptRecalculate.php b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeAttemptRecalculate.php
new file mode 100644
index 00000000000..99154b122df
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeAttemptRecalculate.php
@@ -0,0 +1,70 @@
+ '\\d+',
+ 'attemptId' => '\\d+',
+ ],
+ openapi: new Operation(
+ summary: 'Recalculate an exercise attempt score from the migrated report',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'attemptId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'post_exercise_runtime_attempt_recalculate',
+ processor: ExerciseRuntimeAttemptRecalculateProcessor::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_runtime_attempt_recalculate:read']],
+ denormalizationContext: ['groups' => ['exercise_runtime_attempt_recalculate:write']],
+)]
+final class ExerciseRuntimeAttemptRecalculate
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_attempt_recalculate:read', 'exercise_runtime_attempt_recalculate:write'])]
+ public ?int $exerciseId = null;
+
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_attempt_recalculate:read', 'exercise_runtime_attempt_recalculate:write'])]
+ public ?int $attemptId = null;
+
+ #[Groups(['exercise_runtime_attempt_recalculate:read'])]
+ public bool $success = false;
+
+ #[Groups(['exercise_runtime_attempt_recalculate:read'])]
+ public string $message = '';
+
+ #[Groups(['exercise_runtime_attempt_recalculate:read'])]
+ public float $score = 0.0;
+
+ #[Groups(['exercise_runtime_attempt_recalculate:read'])]
+ public float $maxScore = 0.0;
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeCorrection.php b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeCorrection.php
new file mode 100644
index 00000000000..f6026174c02
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeCorrection.php
@@ -0,0 +1,94 @@
+ '\\d+',
+ 'attemptId' => '\\d+',
+ ],
+ openapi: new Operation(
+ summary: 'Correct a manual Vue exercise runtime answer',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'attemptId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'post_exercise_runtime_correction',
+ processor: ExerciseRuntimeCorrectionProcessor::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_runtime_correction:read']],
+ denormalizationContext: ['groups' => ['exercise_runtime_correction:write']],
+)]
+final class ExerciseRuntimeCorrection
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_correction:read', 'exercise_runtime_correction:write'])]
+ public ?int $exerciseId = null;
+
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_correction:read', 'exercise_runtime_correction:write'])]
+ public ?int $attemptId = null;
+
+ #[Groups(['exercise_runtime_correction:read', 'exercise_runtime_correction:write'])]
+ public ?int $questionId = null;
+
+ #[Groups(['exercise_runtime_correction:read', 'exercise_runtime_correction:write'])]
+ public float $marks = 0.0;
+
+ #[Groups(['exercise_runtime_correction:read', 'exercise_runtime_correction:write'])]
+ public string $teacherComment = '';
+
+ #[Groups(['exercise_runtime_correction:write'])]
+ public bool $sendNotification = false;
+
+ #[Groups(['exercise_runtime_correction:write'])]
+ public string $notificationContent = '';
+
+ #[Groups(['exercise_runtime_correction:read'])]
+ public bool $success = false;
+
+ #[Groups(['exercise_runtime_correction:read'])]
+ public bool $notificationSent = false;
+
+ #[Groups(['exercise_runtime_correction:read'])]
+ public string $message = '';
+
+ #[Groups(['exercise_runtime_correction:read'])]
+ public float $score = 0.0;
+
+ #[Groups(['exercise_runtime_correction:read'])]
+ public float $maxScore = 0.0;
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_correction:read'])]
+ public array $questionsToCheck = [];
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeFinish.php b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeFinish.php
new file mode 100644
index 00000000000..ddc084ec483
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeFinish.php
@@ -0,0 +1,94 @@
+ '\\d+',
+ 'attemptId' => '\\d+',
+ ],
+ openapi: new Operation(
+ summary: 'Finish a Vue exercise runtime attempt using native migrated scoring',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'attemptId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'origin', in: 'query', required: false, schema: ['type' => 'string']),
+ new Parameter(name: 'learnpath_id', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'learnpath_item_id', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'learnpath_item_view_id', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')",
+ name: 'post_exercise_runtime_finish',
+ processor: ExerciseRuntimeFinishProcessor::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_runtime_finish:read']],
+ denormalizationContext: ['groups' => ['exercise_runtime_finish:write']],
+)]
+final class ExerciseRuntimeFinish
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_finish:read', 'exercise_runtime_finish:write'])]
+ public ?int $exerciseId = null;
+
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_finish:read', 'exercise_runtime_finish:write'])]
+ public ?int $attemptId = null;
+
+ #[Groups(['exercise_runtime_finish:read'])]
+ public bool $success = false;
+
+ #[Groups(['exercise_runtime_finish:read'])]
+ public string $message = '';
+
+ #[Groups(['exercise_runtime_finish:read'])]
+ public string $status = '';
+
+ #[Groups(['exercise_runtime_finish:read'])]
+ public float $score = 0.0;
+
+ #[Groups(['exercise_runtime_finish:read'])]
+ public float $maxScore = 0.0;
+
+ #[Groups(['exercise_runtime_finish:read'])]
+ public ?string $completedAt = null;
+
+ #[Groups(['exercise_runtime_finish:read'])]
+ public string $resultUrl = '';
+
+ /**
+ * Runtime data used by the embedded learning path page to update its TOC and progress bar without reloading.
+ *
+ * @var array
+ */
+ #[Groups(['exercise_runtime_finish:read'])]
+ public array $learnpathTracking = [];
+
+ #[Groups(['exercise_runtime_finish:write'])]
+ public bool $confirmedSavedAnswers = false;
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeReport.php b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeReport.php
new file mode 100644
index 00000000000..712012f4e88
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeReport.php
@@ -0,0 +1,122 @@
+ '\\d+'],
+ openapi: new Operation(
+ summary: 'Exercise learner attempts report',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'firstName', in: 'query', required: false, schema: ['type' => 'string']),
+ new Parameter(name: 'lastName', in: 'query', required: false, schema: ['type' => 'string']),
+ new Parameter(name: 'status', in: 'query', required: false, schema: ['type' => 'string']),
+ new Parameter(name: 'groupId', in: 'query', required: false, schema: ['type' => 'string']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_runtime_report',
+ provider: ExerciseRuntimeReportProvider::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_runtime_report:read']],
+)]
+final class ExerciseRuntimeReport
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_report:read'])]
+ public ?int $exerciseId = null;
+
+ #[Groups(['exercise_runtime_report:read'])]
+ public string $title = '';
+
+ #[Groups(['exercise_runtime_report:read'])]
+ public string $description = '';
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_runtime_report:read'])]
+ public array $attempts = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_report:read'])]
+ public array $filters = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_runtime_report:read'])]
+ public array $groupOptions = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_report:read'])]
+ public array $actionUrls = [];
+
+ #[Groups(['exercise_runtime_report:read'])]
+ public int $totalItems = 0;
+
+ #[Groups(['exercise_runtime_report:read'])]
+ public bool $canManage = true;
+
+ #[Groups(['exercise_runtime_report:read'])]
+ public bool $lockedByGradebook = false;
+
+ #[Groups(['exercise_runtime_report:read'])]
+ public bool $canBulkDelete = false;
+
+ #[Groups(['exercise_runtime_report:read'])]
+ public bool $canCleanResults = false;
+
+ #[Groups(['exercise_runtime_report:read'])]
+ public bool $canBulkRecalculate = false;
+
+ #[Groups(['exercise_runtime_report:read'])]
+ public bool $showOfficialCode = false;
+
+ #[Groups(['exercise_runtime_report:read'])]
+ public bool $showUsername = false;
+
+ #[Groups(['exercise_runtime_report:read'])]
+ public bool $showIp = true;
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_runtime_report:read'])]
+ public array $extraFields = [];
+
+ #[Groups(['exercise_runtime_report:read'])]
+ public string $bulkActionToken = '';
+
+ #[Groups(['exercise_runtime_report:read'])]
+ public string $emailActionToken = '';
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeReportBulkAction.php b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeReportBulkAction.php
new file mode 100644
index 00000000000..6a06fb28c87
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeReportBulkAction.php
@@ -0,0 +1,77 @@
+ '\\d+'],
+ openapi: new Operation(
+ summary: 'Run a migrated bulk action on exercise report attempts',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'post_exercise_runtime_report_bulk_action',
+ processor: ExerciseRuntimeReportBulkActionProcessor::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_runtime_report_bulk_action:read']],
+ denormalizationContext: ['groups' => ['exercise_runtime_report_bulk_action:write']],
+)]
+final class ExerciseRuntimeReportBulkAction
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_report_bulk_action:read', 'exercise_runtime_report_bulk_action:write'])]
+ public ?int $exerciseId = null;
+
+ #[Groups(['exercise_runtime_report_bulk_action:read', 'exercise_runtime_report_bulk_action:write'])]
+ public string $action = '';
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_report_bulk_action:read', 'exercise_runtime_report_bulk_action:write'])]
+ public array $attemptIds = [];
+
+ #[Groups(['exercise_runtime_report_bulk_action:read', 'exercise_runtime_report_bulk_action:write'])]
+ public string $beforeDate = '';
+
+ #[Groups(['exercise_runtime_report_bulk_action:write'])]
+ public string $submittedCsrfToken = '';
+
+ #[Groups(['exercise_runtime_report_bulk_action:read'])]
+ public bool $success = false;
+
+ #[Groups(['exercise_runtime_report_bulk_action:read'])]
+ public string $message = '';
+
+ #[Groups(['exercise_runtime_report_bulk_action:read'])]
+ public int $processedCount = 0;
+
+ #[Groups(['exercise_runtime_report_bulk_action:read'])]
+ public int $failedCount = 0;
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeReportEmail.php b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeReportEmail.php
new file mode 100644
index 00000000000..e07b555aa20
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeReportEmail.php
@@ -0,0 +1,83 @@
+ '\\d+'],
+ openapi: new Operation(
+ summary: 'Send reviewed exercise attempt result emails from the migrated report',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'firstName', in: 'query', required: false, schema: ['type' => 'string']),
+ new Parameter(name: 'lastName', in: 'query', required: false, schema: ['type' => 'string']),
+ new Parameter(name: 'status', in: 'query', required: false, schema: ['type' => 'string']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'post_exercise_runtime_report_email',
+ processor: ExerciseRuntimeReportEmailProcessor::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_runtime_report_email:read']],
+ denormalizationContext: ['groups' => ['exercise_runtime_report_email:write']],
+)]
+final class ExerciseRuntimeReportEmail
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_report_email:read', 'exercise_runtime_report_email:write'])]
+ public ?int $exerciseId = null;
+
+ #[Groups(['exercise_runtime_report_email:write'])]
+ public string $node = '';
+
+ #[Groups(['exercise_runtime_report_email:write'])]
+ public string $submittedCsrfToken = '';
+
+ #[Groups(['exercise_runtime_report_email:read'])]
+ public bool $success = false;
+
+ #[Groups(['exercise_runtime_report_email:read'])]
+ public string $message = '';
+
+ #[Groups(['exercise_runtime_report_email:read'])]
+ public int $totalCount = 0;
+
+ #[Groups(['exercise_runtime_report_email:read'])]
+ public int $sentCount = 0;
+
+ #[Groups(['exercise_runtime_report_email:read'])]
+ public int $skippedCount = 0;
+
+ #[Groups(['exercise_runtime_report_email:read'])]
+ public int $failedCount = 0;
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_runtime_report_email:read'])]
+ public array $failures = [];
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeResult.php b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeResult.php
new file mode 100644
index 00000000000..c9ffed44adc
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeResult.php
@@ -0,0 +1,126 @@
+ '\\d+',
+ 'attemptId' => '\\d+',
+ ],
+ openapi: new Operation(
+ summary: 'Exercise runtime attempt result and review data',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'attemptId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT') or is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')",
+ name: 'get_exercise_runtime_result',
+ provider: ExerciseRuntimeResultProvider::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_runtime_result:read']],
+)]
+final class ExerciseRuntimeResult
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_result:read'])]
+ public ?int $exerciseId = null;
+
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_result:read'])]
+ public ?int $attemptId = null;
+
+ #[Groups(['exercise_runtime_result:read'])]
+ public string $title = '';
+
+ #[Groups(['exercise_runtime_result:read'])]
+ public string $description = '';
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_result:read'])]
+ public array $attempt = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_result:read'])]
+ public array $visibility = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_runtime_result:read'])]
+ public array $questions = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_runtime_result:read'])]
+ public array $categoryScores = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_runtime_result:read'])]
+ public array $ranking = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_result:read'])]
+ public array $legacyUrls = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_result:read'])]
+ public array $finalActions = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_result:read'])]
+ public array $aiCorrection = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_result:read'])]
+ public array $progressiveAdaptiveResult = [];
+
+ /**
+ * @var array>
+ */
+ #[Groups(['exercise_runtime_result:read'])]
+ public array $correctionHistory = [];
+
+ #[Groups(['exercise_runtime_result:read'])]
+ public bool $canManage = false;
+
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+}
diff --git a/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeUploadAnswer.php b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeUploadAnswer.php
new file mode 100644
index 00000000000..c37ac0aba10
--- /dev/null
+++ b/src/CoreBundle/ApiResource/Exercise/ExerciseRuntimeUploadAnswer.php
@@ -0,0 +1,122 @@
+ '\\d+',
+ 'attemptId' => '\\d+',
+ ],
+ openapi: new Operation(
+ summary: 'Upload a file or oral recording as a Vue exercise runtime answer',
+ parameters: [
+ new Parameter(name: 'exerciseId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'attemptId', in: 'path', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'cid', in: 'query', required: true, schema: ['type' => 'integer']),
+ new Parameter(name: 'sid', in: 'query', required: false, schema: ['type' => 'integer']),
+ new Parameter(name: 'gid', in: 'query', required: false, schema: ['type' => 'integer']),
+ ],
+ requestBody: new RequestBody(
+ content: new ArrayObject([
+ 'multipart/form-data' => [
+ 'schema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'questionId' => ['type' => 'integer'],
+ 'secondsSpent' => ['type' => 'integer'],
+ 'reviewLater' => ['type' => 'boolean'],
+ 'file' => [
+ 'type' => 'string',
+ 'format' => 'binary',
+ 'description' => 'Upload answer file or oral expression audio file',
+ ],
+ ],
+ ],
+ ],
+ ]),
+ ),
+ ),
+ security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')",
+ deserialize: false,
+ name: 'post_exercise_runtime_upload_answer',
+ processor: ExerciseRuntimeUploadAnswerProcessor::class,
+ ),
+ ],
+ normalizationContext: ['groups' => ['exercise_runtime_upload_answer:read']],
+)]
+final class ExerciseRuntimeUploadAnswer
+{
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_upload_answer:read'])]
+ public ?int $exerciseId = null;
+
+ #[ApiProperty(identifier: true)]
+ #[Groups(['exercise_runtime_upload_answer:read'])]
+ public ?int $attemptId = null;
+
+ #[Groups(['exercise_runtime_upload_answer:read'])]
+ public ?int $questionId = null;
+
+ #[Groups(['exercise_runtime_upload_answer:read'])]
+ public bool $success = false;
+
+ #[Groups(['exercise_runtime_upload_answer:read'])]
+ public string $message = '';
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_upload_answer:read'])]
+ public array $files = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_upload_answer:read'])]
+ public array $savedAnswer = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_upload_answer:read'])]
+ public array $answeredQuestionIds = [];
+
+ /**
+ * @var array
+ */
+ #[Groups(['exercise_runtime_upload_answer:read'])]
+ public array $reviewQuestionIds = [];
+
+ #[Groups(['exercise_runtime_upload_answer:read'])]
+ public int $answeredCount = 0;
+
+ #[Groups(['exercise_runtime_upload_answer:read'])]
+ public bool $canFinish = false;
+ public function getExerciseId(): ?int
+ {
+ return $this->exerciseId;
+ }
+
+ public function getAttemptId(): ?int
+ {
+ return $this->attemptId;
+ }
+}
diff --git a/src/CoreBundle/Controller/AiController.php b/src/CoreBundle/Controller/AiController.php
index 8f4282215b8..7c6968e8076 100644
--- a/src/CoreBundle/Controller/AiController.php
+++ b/src/CoreBundle/Controller/AiController.php
@@ -1553,6 +1553,12 @@ public function generateAikenFromDocument(Request $request): JsonResponse
#[Route('/open_answer_grade', name: 'chamilo_core_ai_open_answer_grade', methods: ['POST'])]
public function openAnswerGrade(Request $request): JsonResponse
{
+ if ('true' !== api_get_setting('ai_helpers.enable_ai_helpers')
+ || 'true' !== api_get_setting('ai_helpers.open_answers_grader')
+ ) {
+ return $this->json(['error' => 'AI open answer grading is disabled.'], 403);
+ }
+
$exeId = $request->request->getInt('exeId', 0);
$questionId = $request->request->getInt('questionId', 0);
$courseId = $request->request->getInt('courseId', 0);
diff --git a/src/CoreBundle/Controller/Api/ExerciseExcelTemplateController.php b/src/CoreBundle/Controller/Api/ExerciseExcelTemplateController.php
new file mode 100644
index 00000000000..b614b138c01
--- /dev/null
+++ b/src/CoreBundle/Controller/Api/ExerciseExcelTemplateController.php
@@ -0,0 +1,32 @@
+templateService->downloadTemplate($request);
+ }
+}
diff --git a/src/CoreBundle/Controller/Api/ExerciseGlobalReportExportController.php b/src/CoreBundle/Controller/Api/ExerciseGlobalReportExportController.php
new file mode 100644
index 00000000000..2fe7af9bafb
--- /dev/null
+++ b/src/CoreBundle/Controller/Api/ExerciseGlobalReportExportController.php
@@ -0,0 +1,32 @@
+exportService->exportCsv($request);
+ }
+}
diff --git a/src/CoreBundle/Controller/Api/ExercisePendingAttemptsExportController.php b/src/CoreBundle/Controller/Api/ExercisePendingAttemptsExportController.php
new file mode 100644
index 00000000000..09bb489564f
--- /dev/null
+++ b/src/CoreBundle/Controller/Api/ExercisePendingAttemptsExportController.php
@@ -0,0 +1,60 @@
+provider->buildData($request);
+ $exportData = [
+ 'settings' => $data->settings,
+ 'items' => $data->items,
+ ];
+ $headers = $this->provider->getCsvHeaders($exportData);
+ $rows = $this->provider->getCsvRows($exportData);
+
+ $response = new StreamedResponse(static function () use ($headers, $rows): void {
+ $handle = fopen('php://output', 'w');
+ if (!\is_resource($handle)) {
+ return;
+ }
+
+ fputcsv($handle, $headers);
+ foreach ($rows as $row) {
+ fputcsv($handle, $row);
+ }
+ fclose($handle);
+ });
+
+ $response->headers->set('Content-Type', 'text/csv; charset=UTF-8');
+ $response->headers->set(
+ 'Content-Disposition',
+ $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'pending_exercise_attempts.csv')
+ );
+
+ return $response;
+ }
+}
diff --git a/src/CoreBundle/Controller/Api/ExerciseQti2ExportController.php b/src/CoreBundle/Controller/Api/ExerciseQti2ExportController.php
new file mode 100644
index 00000000000..1608d498bd1
--- /dev/null
+++ b/src/CoreBundle/Controller/Api/ExerciseQti2ExportController.php
@@ -0,0 +1,33 @@
+ '\\d+'],
+ methods: ['GET']
+ )]
+ public function __invoke(int $exerciseId, Request $request): BinaryFileResponse
+ {
+ return $this->exportService->exportExerciseZip($exerciseId, $request);
+ }
+}
diff --git a/src/CoreBundle/Controller/Api/ExerciseQuestionReportPdfController.php b/src/CoreBundle/Controller/Api/ExerciseQuestionReportPdfController.php
new file mode 100644
index 00000000000..956efe03afe
--- /dev/null
+++ b/src/CoreBundle/Controller/Api/ExerciseQuestionReportPdfController.php
@@ -0,0 +1,43 @@
+ '\\d+'],
+ methods: ['GET']
+ )]
+ public function reportByQuestion(int $exerciseId): Response
+ {
+ return $this->pdfService->exportReportByQuestionPdf($exerciseId);
+ }
+
+ #[Route(
+ '/api/exercise/runtime/{exerciseId}/question-stats.pdf',
+ name: 'chamilo_core_exercise_question_stats_pdf',
+ requirements: ['exerciseId' => '\\d+'],
+ methods: ['GET']
+ )]
+ public function questionStats(int $exerciseId): Response
+ {
+ return $this->pdfService->exportQuestionStatsPdf($exerciseId);
+ }
+}
diff --git a/src/CoreBundle/Controller/Api/ExerciseRuntimeAllAttemptsExportController.php b/src/CoreBundle/Controller/Api/ExerciseRuntimeAllAttemptsExportController.php
new file mode 100644
index 00000000000..36257e2c75d
--- /dev/null
+++ b/src/CoreBundle/Controller/Api/ExerciseRuntimeAllAttemptsExportController.php
@@ -0,0 +1,33 @@
+ '\\d+'],
+ methods: ['GET']
+ )]
+ public function __invoke(int $exerciseId, Request $request): BinaryFileResponse
+ {
+ return $this->exportService->exportAllAttemptsZip($exerciseId, $request);
+ }
+}
diff --git a/src/CoreBundle/Controller/Api/ExerciseRuntimeAttemptFileDownloadAction.php b/src/CoreBundle/Controller/Api/ExerciseRuntimeAttemptFileDownloadAction.php
new file mode 100644
index 00000000000..dd29229fdf2
--- /dev/null
+++ b/src/CoreBundle/Controller/Api/ExerciseRuntimeAttemptFileDownloadAction.php
@@ -0,0 +1,212 @@
+ '\\d+',
+ 'attemptId' => '\\d+',
+ 'resourceNodeId' => '\\d+',
+ ],
+ methods: ['GET']
+ )]
+ public function __invoke(int $exerciseId, int $attemptId, int $resourceNodeId, Request $request): Response
+ {
+ $user = $this->security->getUser();
+ if (!$user instanceof User) {
+ throw new AccessDeniedHttpException('A valid authenticated user is required.');
+ }
+
+ $course = $this->getCourse($request);
+ $session = $this->getSession($request);
+ $attemptRow = $this->getAttemptRowWithFile($exerciseId, $attemptId, $resourceNodeId, $course, $session);
+ $attempt = $attemptRow->getTrackEExercise();
+
+ if (!$this->canDownloadAttemptFile($attempt, $user)) {
+ throw new AccessDeniedHttpException('You are not allowed to download this exercise attempt file.');
+ }
+
+ $resourceNode = $this->getAttachedResourceNode($attemptRow, $resourceNodeId);
+ if (!$resourceNode instanceof ResourceNode) {
+ throw new NotFoundHttpException('The requested exercise attempt file was not found.');
+ }
+
+ $resourceFile = $resourceNode->getResourceFiles()->first();
+ if (!$resourceFile instanceof ResourceFile) {
+ throw new NotFoundHttpException('The requested exercise attempt file was not found.');
+ }
+
+ $stream = $this->resourceNodeRepository->getResourceNodeFileStream($resourceNode, $resourceFile);
+ if (!\is_resource($stream)) {
+ throw new NotFoundHttpException('The requested exercise attempt file could not be opened.');
+ }
+
+ $fileName = $resourceFile->getOriginalName() ?: $resourceNode->getTitle() ?: 'answer-file';
+ $response = new StreamedResponse(static function () use ($stream): void {
+ fpassthru($stream);
+ if (\is_resource($stream)) {
+ fclose($stream);
+ }
+ });
+
+ $response->headers->set('Content-Type', $resourceFile->getMimeType() ?: 'application/octet-stream');
+ if (0 < (int) $resourceFile->getSize()) {
+ $response->headers->set('Content-Length', (string) $resourceFile->getSize());
+ }
+ $response->headers->set(
+ 'Content-Disposition',
+ $response->headers->makeDisposition(
+ true === $request->query->getBoolean('inline') ? ResponseHeaderBag::DISPOSITION_INLINE : ResponseHeaderBag::DISPOSITION_ATTACHMENT,
+ $fileName,
+ $this->getAsciiFileName($fileName)
+ )
+ );
+
+ return $response;
+ }
+
+ private function getCourse(Request $request): Course
+ {
+ $courseId = $request->query->getInt('cid');
+ if (0 >= $courseId) {
+ throw new BadRequestHttpException('A valid course id is required.');
+ }
+
+ $course = $this->entityManager->getRepository(Course::class)->find($courseId);
+ if (!$course instanceof Course) {
+ throw new BadRequestHttpException('The requested course was not found.');
+ }
+
+ return $course;
+ }
+
+ private function getSession(Request $request): ?Session
+ {
+ $sessionId = $request->query->getInt('sid');
+ if (0 >= $sessionId) {
+ return null;
+ }
+
+ $session = $this->entityManager->getRepository(Session::class)->find($sessionId);
+ if (!$session instanceof Session) {
+ throw new BadRequestHttpException('The requested session was not found.');
+ }
+
+ return $session;
+ }
+
+ private function getAttemptRowWithFile(
+ int $exerciseId,
+ int $attemptId,
+ int $resourceNodeId,
+ Course $course,
+ ?Session $session,
+ ): TrackEAttempt {
+ $queryBuilder = $this->entityManager->createQueryBuilder()
+ ->select('attemptRow')
+ ->addSelect('attempt', 'attemptFile', 'resourceNode', 'resourceFile')
+ ->from(TrackEAttempt::class, 'attemptRow')
+ ->innerJoin('attemptRow.trackExercise', 'attempt')
+ ->innerJoin('attemptRow.attemptFiles', 'attemptFile')
+ ->innerJoin('attemptFile.resourceNode', 'resourceNode')
+ ->leftJoin('resourceNode.resourceFiles', 'resourceFile')
+ ->andWhere('attempt.exeId = :attemptId')
+ ->andWhere('IDENTITY(attempt.quiz) = :exerciseId')
+ ->andWhere('IDENTITY(attempt.course) = :courseId')
+ ->andWhere('resourceNode.id = :resourceNodeId')
+ ->setParameter('attemptId', $attemptId, Types::INTEGER)
+ ->setParameter('exerciseId', $exerciseId, Types::INTEGER)
+ ->setParameter('courseId', (int) $course->getId(), Types::INTEGER)
+ ->setParameter('resourceNodeId', $resourceNodeId, Types::INTEGER)
+ ->setMaxResults(1)
+ ;
+
+ if (null !== $session) {
+ $queryBuilder
+ ->andWhere('IDENTITY(attempt.session) = :sessionId')
+ ->setParameter('sessionId', (int) $session->getId(), Types::INTEGER)
+ ;
+ } else {
+ $queryBuilder->andWhere('attempt.session IS NULL');
+ }
+
+ $attemptRow = $queryBuilder->getQuery()->getOneOrNullResult();
+ if (!$attemptRow instanceof TrackEAttempt) {
+ throw new NotFoundHttpException('The requested exercise attempt file was not found.');
+ }
+
+ return $attemptRow;
+ }
+
+ private function canDownloadAttemptFile(TrackEExercise $attempt, User $user): bool
+ {
+ if ((int) $attempt->getUser()->getId() === (int) $user->getId()) {
+ return true;
+ }
+
+ return $this->security->isGranted('ROLE_CURRENT_COURSE_TEACHER')
+ || $this->security->isGranted('ROLE_CURRENT_COURSE_SESSION_TEACHER')
+ || $this->security->isGranted('ROLE_ADMIN');
+ }
+
+ private function getAttachedResourceNode(TrackEAttempt $attemptRow, int $resourceNodeId): ?ResourceNode
+ {
+ foreach ($attemptRow->getAttemptFiles() as $attemptFile) {
+ $resourceNode = $attemptFile->getResourceNode();
+ if ($resourceNode instanceof ResourceNode && (int) $resourceNode->getId() === $resourceNodeId) {
+ return $resourceNode;
+ }
+ }
+
+ return null;
+ }
+
+ private function getAsciiFileName(string $fileName): string
+ {
+ $asciiFileName = preg_replace('/[^A-Za-z0-9._-]+/', '_', $fileName);
+ if (!\is_string($asciiFileName)) {
+ return 'answer-file';
+ }
+
+ $asciiFileName = trim($asciiFileName, '._-');
+
+ return '' !== $asciiFileName ? $asciiFileName : 'answer-file';
+ }
+}
diff --git a/src/CoreBundle/Controller/Api/ExerciseRuntimeAttemptPdfController.php b/src/CoreBundle/Controller/Api/ExerciseRuntimeAttemptPdfController.php
new file mode 100644
index 00000000000..505f4d83e84
--- /dev/null
+++ b/src/CoreBundle/Controller/Api/ExerciseRuntimeAttemptPdfController.php
@@ -0,0 +1,33 @@
+ '\\d+', 'attemptId' => '\\d+'],
+ methods: ['GET']
+ )]
+ public function __invoke(int $exerciseId, int $attemptId, Request $request): Response
+ {
+ return $this->pdfService->exportAttemptPdf($exerciseId, $attemptId, $request);
+ }
+}
diff --git a/src/CoreBundle/Controller/Api/ExerciseRuntimeReportExportController.php b/src/CoreBundle/Controller/Api/ExerciseRuntimeReportExportController.php
new file mode 100644
index 00000000000..bf38e8bbf98
--- /dev/null
+++ b/src/CoreBundle/Controller/Api/ExerciseRuntimeReportExportController.php
@@ -0,0 +1,45 @@
+ '\\d+'],
+ methods: ['GET']
+ )]
+ public function csv(int $exerciseId, Request $request): StreamedResponse
+ {
+ return $this->exportService->exportCsv($exerciseId, $request);
+ }
+
+ #[Route(
+ '/api/exercise/runtime/{exerciseId}/attempts/export.xlsx',
+ name: 'chamilo_core_exercise_runtime_report_export_xlsx',
+ requirements: ['exerciseId' => '\\d+'],
+ methods: ['GET']
+ )]
+ public function xlsx(int $exerciseId, Request $request): BinaryFileResponse
+ {
+ return $this->exportService->exportXlsx($exerciseId, $request);
+ }
+}
diff --git a/src/CoreBundle/Controller/CourseController.php b/src/CoreBundle/Controller/CourseController.php
index 25b7f925577..649da8af86e 100644
--- a/src/CoreBundle/Controller/CourseController.php
+++ b/src/CoreBundle/Controller/CourseController.php
@@ -825,6 +825,10 @@ public function redirectTool(
CToolRepository $repo,
ToolChain $toolChain
): RedirectResponse {
+ if (null === $this->getCourse()) {
+ throw new NotFoundHttpException($this->trans('Course not found'));
+ }
+
/** @var CTool|null $tool */
$tool = $repo->findOneBy([
'title' => $toolName,
@@ -834,12 +838,16 @@ public function redirectTool(
throw new NotFoundHttpException($this->trans('Tool not found'));
}
- $tool = $toolChain->getToolFromName($tool->getTool()->getTitle());
- $link = $tool->getLink();
+ $toolDefinition = $toolChain->getToolFromName($tool->getTool()->getTitle());
+ $link = $toolDefinition->getLink();
- if (null === $this->getCourse()) {
- throw new NotFoundHttpException($this->trans('Course not found'));
+ if ($this->isExerciseToolEntry($toolName, $tool->getTool()->getTitle(), $link)) {
+ $exerciseUrl = $this->buildExerciseVueToolUrl();
+ if (null !== $exerciseUrl) {
+ return $this->redirect($exerciseUrl);
+ }
}
+
$optionalParams = '';
$optionalParams = $request->query->get('cert') ? '&cert='.$request->query->get('cert') : '';
@@ -1879,8 +1887,11 @@ private function autoLaunch(): void
);
}
} else {
- // Redirecting to the document
- $url = api_get_path(WEB_CODE_PATH).'exercise/exercise.php?'.api_get_cidreq();
+ $url = $this->buildExerciseVueToolUrl();
+ if (null === $url) {
+ $url = api_get_path(WEB_CODE_PATH).'exercise/exercise.php?'.api_get_cidreq();
+ }
+
header(\sprintf('Location: %s', $url));
exit;
@@ -1915,8 +1926,12 @@ private function autoLaunch(): void
if (Database::num_rows($result) > 0) {
$row = Database::fetch_array($result);
$exerciseId = $row['iid'];
- $url = api_get_path(WEB_CODE_PATH).
- 'exercise/overview.php?exerciseId='.$exerciseId.'&'.api_get_cidreq();
+ $url = $this->buildExerciseVueToolUrl((int) $exerciseId);
+ if (null === $url) {
+ $url = api_get_path(WEB_CODE_PATH).
+ 'exercise/overview.php?exerciseId='.$exerciseId.'&'.api_get_cidreq();
+ }
+
header(\sprintf('Location: %s', $url));
exit;
@@ -1955,6 +1970,50 @@ private function autoLaunch(): void
}
}
+ private function buildExerciseVueToolUrl(?int $exerciseId = null): ?string
+ {
+ $course = $this->getCourse();
+ if (!$course instanceof Course) {
+ $course = api_get_course_entity();
+ }
+
+ if (!$course instanceof Course || null === $course->getResourceNode()) {
+ return null;
+ }
+
+ $nodeId = $course->getResourceNode()->getId();
+ if (null === $nodeId) {
+ return null;
+ }
+
+ $path = '/resources/exercise/'.(int) $nodeId.'/';
+ if (null !== $exerciseId && 0 < $exerciseId) {
+ $path .= $exerciseId.'/overview';
+ }
+
+ $query = http_build_query([
+ 'cid' => (int) api_get_course_int_id(),
+ 'sid' => (int) api_get_session_id(),
+ 'gid' => (int) api_get_group_id(),
+ ]);
+
+ return rtrim(api_get_path(WEB_PATH), '/').$path.'?'.$query;
+ }
+
+ private function isExerciseToolEntry(string $toolName, string $toolTitle, string $link): bool
+ {
+ $toolCandidates = array_map('strtolower', [$toolName, $toolTitle]);
+
+ foreach ($toolCandidates as $candidate) {
+ if (\in_array($candidate, ['quiz', 'exercise', 'exercises', 'tests'], true)) {
+ return true;
+ }
+ }
+
+ return str_contains($link, 'exercise/exercise.php')
+ || str_contains($link, '/main/exercise/exercise.php');
+ }
+
private function isCourseHomeResourcePublished(
AbstractResource $resource,
Course $course,
diff --git a/src/CoreBundle/Controller/IndexController.php b/src/CoreBundle/Controller/IndexController.php
index 9b853157988..e57970f1e60 100644
--- a/src/CoreBundle/Controller/IndexController.php
+++ b/src/CoreBundle/Controller/IndexController.php
@@ -44,6 +44,7 @@ class IndexController extends BaseController
#[Route('/resources/lp/{node}/categories/{categoryId}/subscriptions', name: 'resources_lp_category_subscriptions', requirements: ['node' => '\d+', 'categoryId' => '\d+'], methods: ['GET'])]
#[Route('/resources/forum/{vueRouting}', name: 'resources_forum_vue_entrypoint', requirements: ['vueRouting' => '.+'], methods: ['GET'])]
#[Route('/resources/survey/{vueRouting}', name: 'resources_survey_vue_entrypoint', requirements: ['vueRouting' => '.+'], methods: ['GET'])]
+ #[Route('/resources/exercise/{vueRouting}', name: 'resources_exercise_vue_entrypoint', requirements: ['vueRouting' => '.+'], methods: ['GET'])]
#[Route('/resources/course-description/{vueRouting}', name: 'resources_course_description_vue_entrypoint', requirements: ['vueRouting' => '.+'], methods: ['GET'])]
#[Route('/resources/notebook/{vueRouting}', name: 'resources_notebook_vue_entrypoint', requirements: ['vueRouting' => '.+'], methods: ['GET'])]
#[Route('/resources/portfolio/{vueRouting}', name: 'resources_portfolio_vue_entrypoint', requirements: ['vueRouting' => '.+'], methods: ['GET'])]
diff --git a/src/CoreBundle/Controller/SearchController.php b/src/CoreBundle/Controller/SearchController.php
index e28ac6e7217..c9c48267a41 100644
--- a/src/CoreBundle/Controller/SearchController.php
+++ b/src/CoreBundle/Controller/SearchController.php
@@ -8,6 +8,7 @@
use Chamilo\CoreBundle\Entity\Course;
use Chamilo\CoreBundle\Entity\ResourceIllustrationInterface;
+use Chamilo\CoreBundle\Entity\ResourceLink;
use Chamilo\CoreBundle\Entity\ResourceNode;
use Chamilo\CoreBundle\Entity\SequenceResource;
use Chamilo\CoreBundle\Entity\Session;
@@ -17,6 +18,7 @@
use Chamilo\CoreBundle\Search\Xapian\XapianSearchService;
use Chamilo\CoreBundle\Security\Authorization\Voter\ResourceNodeVoter;
use Chamilo\CoreBundle\Settings\SettingsManager;
+use Chamilo\CourseBundle\Entity\CQuiz;
use Chamilo\CourseBundle\Entity\CQuizRelQuestion;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -151,8 +153,9 @@ public function xapianSearchPageAction(Request $request): Response
$estimatedTotal = (int) ($searchResult['count'] ?? 0);
$results = $searchResult['results'] ?? [];
- $results = $this->hydrateResultsWithCourseRootNode($results);
+ $results = $this->hydrateQuizResultsWithExerciseContext($results);
$results = $this->hydrateQuestionResultsWithQuizIds($results);
+ $results = $this->hydrateResultsWithCourseRootNode($results);
$results = $this->hydrateResultsWithCourseMeta($results);
@@ -207,6 +210,55 @@ public function xapianDemoIndexAction(): JsonResponse
}
}
+ /**
+ * For quiz results, resolve missing exercise/course context from the quiz resource node.
+ *
+ * Older Xapian entries may not contain quiz_id/course_id even if they were indexed as quiz.
+ * This keeps visible search results linked to the migrated Vue exercise overview without
+ * bypassing the normal access check later in decorateResultsForUi().
+ *
+ * @param array> $results
+ *
+ * @return array>
+ */
+ private function hydrateQuizResultsWithExerciseContext(array $results): array
+ {
+ foreach ($results as &$result) {
+ if (!\is_array($result)) {
+ continue;
+ }
+
+ $data = $result['data'] ?? [];
+ if (!\is_array($data)) {
+ $data = [];
+ }
+
+ $kind = (string) ($data['kind'] ?? '');
+ $tool = (string) ($data['tool'] ?? '');
+ $isQuiz = ('quiz' === $kind) || ('quiz' === $tool);
+
+ if (!$isQuiz) {
+ $result['data'] = $data;
+
+ continue;
+ }
+
+ $quiz = $this->findQuizFromSearchData($data, true);
+ if (!$quiz instanceof CQuiz) {
+ $data['search_is_stale'] = true;
+ $data['search_stale_reason'] = 'Quiz not found';
+ unset($data['quiz_id'], $data['course_id'], $data['course_root_node_id']);
+ $result['data'] = $data;
+
+ continue;
+ }
+
+ $result['data'] = $this->applyQuizContextToSearchData($data, $quiz, true);
+ }
+
+ return $results;
+ }
+
/**
* Attach course_root_node_id to each result if we can resolve it from course_id.
*
@@ -313,28 +365,171 @@ private function hydrateQuestionResultsWithQuizIds(array $results): array
$quiz = $rel->getQuiz();
if (!$quiz || null === $quiz->getIid()) {
+ $data['search_is_stale'] = true;
+ $data['search_stale_reason'] = 'Related quiz not found';
+ unset($data['quiz_id'], $data['course_id'], $data['course_root_node_id']);
$result['data'] = $data;
continue;
}
- // Attach quiz id for linking.
- if (empty($data['quiz_id'])) {
- $data['quiz_id'] = (string) $quiz->getIid();
+ $result['data'] = $this->applyQuizContextToSearchData($data, $quiz, false);
+ }
+
+ return $results;
+ }
+
+ private function findQuizFromSearchData(array $data, bool $allowResourceNodeLookup): ?CQuiz
+ {
+ $quizId = isset($data['quiz_id']) && '' !== (string) $data['quiz_id']
+ ? (int) $data['quiz_id']
+ : 0;
+
+ if ($quizId > 0) {
+ $quiz = $this->em->find(CQuiz::class, $quizId);
+ if ($quiz instanceof CQuiz) {
+ return $quiz;
}
+ }
- // Attach quiz title so we can display it instead of exposing full question text.
- if (empty($data['quiz_title']) && method_exists($quiz, 'getTitle')) {
- $quizTitle = (string) $quiz->getTitle();
- if ('' !== $quizTitle) {
- $data['quiz_title'] = $quizTitle;
+ $xapianData = $data['xapian_data'] ?? null;
+ if (\is_string($xapianData) && '' !== trim($xapianData)) {
+ $decoded = json_decode($xapianData, true);
+ if (\is_array($decoded)) {
+ $candidateId = (int) ($decoded['exercise_id'] ?? $decoded['quiz_id'] ?? $decoded['primary_quiz_id'] ?? 0);
+ if ($candidateId > 0) {
+ $quiz = $this->em->find(CQuiz::class, $candidateId);
+ if ($quiz instanceof CQuiz) {
+ return $quiz;
+ }
}
}
+ }
- $result['data'] = $data;
+ if (!$allowResourceNodeLookup) {
+ return null;
}
- return $results;
+ $resourceNodeId = isset($data['resource_node_id']) && '' !== (string) $data['resource_node_id']
+ ? (int) $data['resource_node_id']
+ : 0;
+
+ if ($resourceNodeId <= 0) {
+ return null;
+ }
+
+ /** @var ResourceNode|null $resourceNode */
+ $resourceNode = $this->em->find(ResourceNode::class, $resourceNodeId);
+ if (!$resourceNode instanceof ResourceNode) {
+ return null;
+ }
+
+ /** @var CQuiz|null $quiz */
+ $quiz = $this->em
+ ->getRepository(CQuiz::class)
+ ->findOneBy(['resourceNode' => $resourceNode])
+ ;
+
+ return $quiz instanceof CQuiz ? $quiz : null;
+ }
+
+ private function applyQuizContextToSearchData(array $data, CQuiz $quiz, bool $replaceResourceNode): array
+ {
+ $quizId = $quiz->getIid();
+ if (null !== $quizId) {
+ $data['quiz_id'] = (string) $quizId;
+ }
+
+ if (empty($data['quiz_title']) && method_exists($quiz, 'getTitle')) {
+ $quizTitle = (string) $quiz->getTitle();
+ if ('' !== $quizTitle) {
+ $data['quiz_title'] = $quizTitle;
+ }
+ }
+
+ $resourceNode = $quiz->getResourceNode();
+ if (!$resourceNode instanceof ResourceNode) {
+ $data['search_is_stale'] = true;
+ $data['search_stale_reason'] = 'Quiz resource node not found';
+ unset($data['course_id'], $data['course_root_node_id']);
+
+ return $data;
+ }
+
+ if ($replaceResourceNode || empty($data['resource_node_id'])) {
+ $data['resource_node_id'] = (string) $resourceNode->getId();
+ }
+
+ $link = $this->resolveQuizResourceLink($resourceNode, $data);
+ if (!$link instanceof ResourceLink) {
+ $data['search_is_stale'] = true;
+ $data['search_stale_reason'] = 'Quiz course link not found';
+ unset($data['course_id'], $data['course_root_node_id']);
+
+ return $data;
+ }
+
+ $course = $link->getCourse();
+ if (!$course instanceof Course || !$course->getResourceNode()) {
+ $data['search_is_stale'] = true;
+ $data['search_stale_reason'] = 'Quiz course not found';
+ unset($data['course_id'], $data['course_root_node_id']);
+
+ return $data;
+ }
+
+ // The Xapian document may contain stale course metadata. Always use the
+ // current resource link so the generated Vue route points to an existing
+ // course/exercise pair.
+ $data['course_id'] = (string) $course->getId();
+ $data['course_root_node_id'] = (string) $course->getResourceNode()->getId();
+
+ $session = $link->getSession();
+ if ($session instanceof Session) {
+ $data['session_id'] = (string) $session->getId();
+ } elseif (!empty($data['session_id'])) {
+ unset($data['session_id']);
+ }
+
+ unset($data['search_is_stale'], $data['search_stale_reason']);
+
+ return $data;
+ }
+
+ private function resolveQuizResourceLink(ResourceNode $resourceNode, array $data): ?ResourceLink
+ {
+ $requestedCourseId = isset($data['course_id']) && '' !== (string) $data['course_id']
+ ? (int) $data['course_id']
+ : 0;
+ $requestedSessionId = isset($data['session_id']) && '' !== (string) $data['session_id']
+ ? (int) $data['session_id']
+ : 0;
+
+ $fallback = null;
+
+ foreach ($resourceNode->getResourceLinks() as $link) {
+ if (!$link instanceof ResourceLink) {
+ continue;
+ }
+
+ $course = $link->getCourse();
+ if (!$course instanceof Course || !$course->getResourceNode()) {
+ continue;
+ }
+
+ $fallback ??= $link;
+
+ $session = $link->getSession();
+ $linkSessionId = $session instanceof Session ? (int) $session->getId() : 0;
+
+ if ($requestedCourseId > 0 && (int) $course->getId() === $requestedCourseId) {
+ if ($requestedSessionId <= 0 || $linkSessionId === $requestedSessionId) {
+ return $link;
+ }
+ }
+ }
+
+ return $fallback;
}
/**
@@ -492,8 +687,12 @@ private function decorateResultsForUi(array $results, string $queryString): arra
$data['excerpt_html'] = $this->buildExcerptHtml($content, $terms, 220);
}
- // Session-aware access flag.
- $data['is_accessible'] = $this->isResultAccessible($data, $resolvedSid);
+ // Session-aware access flag. Stale Xapian hits must never build
+ // clickable links; they can only be displayed as unavailable when
+ // search_show_unlinked_results is enabled.
+ $data['is_accessible'] = !empty($data['search_is_stale'])
+ ? false
+ : $this->isResultAccessible($data, $resolvedSid);
$data['thumbnail_url'] = $this->resolveSearchResultThumbnailUrl($data, $ext, $filetype);
$data['thumbnail_source'] = '' !== (string) $data['thumbnail_url']
@@ -643,6 +842,10 @@ private function isResultAccessible(array $data, int $resolvedSessionId = 0): bo
{
$user = $this->getUser();
+ if (!empty($data['search_is_stale'])) {
+ return false;
+ }
+
if (!$user instanceof UserInterface) {
return false;
}
diff --git a/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php b/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php
index 1de1477d059..aa9132295d1 100644
--- a/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php
+++ b/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php
@@ -2462,6 +2462,11 @@ public static function getNewConfigurationSettings(): array
'title' => 'Enable test categories',
'comment' => 'Test categories are not enabled by default because they add a level of complexity. Enable this feature to show all test categories related management icons appear.',
],
+ [
+ 'name' => 'quiz_question_category_destinations',
+ 'title' => 'Enable progressive adaptive tests by category destination',
+ 'comment' => 'Enable progressive adaptive tests where each question category can redirect learners to another category depending on their score.',
+ ],
[
'name' => 'allow_mandatory_question_in_category',
'title' => 'Enable selecting mandatory questions',
diff --git a/src/CoreBundle/Entity/TrackEExercise.php b/src/CoreBundle/Entity/TrackEExercise.php
index 186bd01c36e..cff1b5a84d6 100644
--- a/src/CoreBundle/Entity/TrackEExercise.php
+++ b/src/CoreBundle/Entity/TrackEExercise.php
@@ -354,6 +354,18 @@ public function setQuestionsToCheck(string $questionsToCheck): self
return $this;
}
+ public function getBlockedCategories(): ?string
+ {
+ return $this->blockedCategories;
+ }
+
+ public function setBlockedCategories(?string $blockedCategories): self
+ {
+ $this->blockedCategories = $blockedCategories;
+
+ return $this;
+ }
+
public function getExeId(): int
{
return $this->exeId;
diff --git a/src/CoreBundle/Helpers/CourseStudentInfoHelper.php b/src/CoreBundle/Helpers/CourseStudentInfoHelper.php
index 57df8515957..cb1b9dc19f8 100644
--- a/src/CoreBundle/Helpers/CourseStudentInfoHelper.php
+++ b/src/CoreBundle/Helpers/CourseStudentInfoHelper.php
@@ -595,7 +595,7 @@ private function buildLegacyToolUrl(string $toolKey, int $courseId, int $session
'course_home' => $base.'course/'.$courseId.'/home?'.$qs,
'documents' => $base.'resources/document/'.$parentResourceNodeId.'/?'.$qs,
'learnpaths' => $base.'resources/lp/'.$parentResourceNodeId.'/?'.$qs,
- 'exercises' => $base.'main/exercise/exercise.php?'.$qs,
+ 'exercises' => $base.'resources/exercise/'.$parentResourceNodeId.'/?'.$qs,
'forums' => $base.'main/forum/index.php?'.$qs,
'wikis' => $base.'resources/wiki/'.$parentResourceNodeId.'/?'.$qs,
'links' => $base.'resources/links/'.$parentResourceNodeId.'/?'.$qs,
diff --git a/src/CoreBundle/Migrations/Schema/V210/Version20260618123000.php b/src/CoreBundle/Migrations/Schema/V210/Version20260618123000.php
new file mode 100644
index 00000000000..37ff0cace23
--- /dev/null
+++ b/src/CoreBundle/Migrations/Schema/V210/Version20260618123000.php
@@ -0,0 +1,83 @@
+hasTable('c_quiz_rel_category')) {
+ $table = $schema->getTable('c_quiz_rel_category');
+ if (!$table->hasColumn('destinations')) {
+ $this->addSql('ALTER TABLE c_quiz_rel_category ADD destinations LONGTEXT DEFAULT NULL');
+ }
+ }
+
+ if (!$schema->hasTable('c_quiz_destination_result')) {
+ $this->addSql('CREATE TABLE c_quiz_destination_result (id INT AUTO_INCREMENT NOT NULL, user_id INT DEFAULT NULL, exe_id INT DEFAULT NULL, achieved_level VARCHAR(255) NOT NULL, hash VARCHAR(255) NOT NULL, INDEX IDX_7A2F95B6A76ED395 (user_id), INDEX IDX_7A2F95B6B5A18F57 (exe_id), UNIQUE INDEX UNIQ_7A2F95B6D1B862B8 (hash), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
+ $this->addSql('ALTER TABLE c_quiz_destination_result ADD CONSTRAINT FK_7A2F95B6A76ED395 FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE');
+ $this->addSql('ALTER TABLE c_quiz_destination_result ADD CONSTRAINT FK_7A2F95B6B5A18F57 FOREIGN KEY (exe_id) REFERENCES track_e_exercises (exe_id) ON DELETE CASCADE');
+ }
+
+ if ($schema->hasTable('settings')) {
+ $this->upsertProgressiveAdaptiveSetting('settings');
+ }
+ }
+
+ public function down(Schema $schema): void
+ {
+ if ($schema->hasTable('settings')) {
+ $this->deleteProgressiveAdaptiveSetting('settings');
+ }
+
+ if ($schema->hasTable('c_quiz_destination_result')) {
+ $this->addSql('DROP TABLE c_quiz_destination_result');
+ }
+
+ if ($schema->hasTable('c_quiz_rel_category')) {
+ $table = $schema->getTable('c_quiz_rel_category');
+ if ($table->hasColumn('destinations')) {
+ $this->addSql('ALTER TABLE c_quiz_rel_category DROP destinations');
+ }
+ }
+ }
+
+ private function upsertProgressiveAdaptiveSetting(string $table): void
+ {
+ $this->addSql(
+ 'INSERT INTO '.$table.' (variable, category, title, comment, selected_value, access_url_changeable) '.
+ 'SELECT ?, ?, ?, ?, ?, 0 WHERE NOT EXISTS (SELECT 1 FROM '.$table.' WHERE variable = ?)',
+ [
+ 'quiz_question_category_destinations',
+ 'exercise',
+ 'Enable progressive adaptive tests by category destination',
+ 'Enable progressive adaptive tests where each question category can redirect learners to another category depending on their score.',
+ 'false',
+ 'quiz_question_category_destinations',
+ ]
+ );
+ }
+
+ private function deleteProgressiveAdaptiveSetting(string $table): void
+ {
+ $this->addSql(
+ 'DELETE FROM '.$table.' WHERE variable = ? AND category = ?',
+ [
+ 'quiz_question_category_destinations',
+ 'exercise',
+ ]
+ );
+ }
+}
diff --git a/src/CoreBundle/Repository/ResourceLinkRepository.php b/src/CoreBundle/Repository/ResourceLinkRepository.php
index 84964d5d4e6..de3885f0f3a 100644
--- a/src/CoreBundle/Repository/ResourceLinkRepository.php
+++ b/src/CoreBundle/Repository/ResourceLinkRepository.php
@@ -29,7 +29,7 @@ class ResourceLinkRepository extends SortableRepository
'document' => '/resources/document/%resource_node_id%/',
'learnpath' => '/main/lp/lp_controller.php',
'link' => '/resources/links/%resource_node_id%/',
- 'quiz' => '/main/exercise/exercise.php',
+ 'quiz' => '/resources/exercise/%resource_node_id%/',
'announcement' => '/resources/announcement/%resource_node_id%/',
'glossary' => '/resources/glossary/%resource_node_id%/',
'attendance' => '/main/attendance/index.php',
diff --git a/src/CoreBundle/Resources/views/Search/xapian_search.html.twig b/src/CoreBundle/Resources/views/Search/xapian_search.html.twig
index f5c66257822..6325cf21f20 100644
--- a/src/CoreBundle/Resources/views/Search/xapian_search.html.twig
+++ b/src/CoreBundle/Resources/views/Search/xapian_search.html.twig
@@ -92,6 +92,7 @@
{% set filetype = data.filetype|default('unknown') %}
{% set tool = data.tool|default(data.kind|default('document')) %}
{% set isAccessible = (data.is_accessible is not defined) ? true : data.is_accessible %}
+ {% set isStale = data.search_is_stale|default(false) %}
{% set kind = data.kind|default('document') %}
{% set quizId = data.quiz_id|default('') %}
@@ -107,7 +108,7 @@
{% set openUrl = null %}
{% set actionLabel = 'View'|trans %}
- {% if isCourseDescResult %}
+ {% if not isStale and isCourseDescResult %}
{% if courseRootNodeId is not empty and courseId is not empty %}
{% set openUrl = app.request.basePath ~ '/resources/course-description/' ~ courseRootNodeId ~ '/?cid=' ~ courseId ~ '&sid=' ~ resolvedSid ~ '&gid=0' %}
{% endif %}
@@ -117,11 +118,19 @@
{% endif %}
{% elseif isQuestionResult %}
{% if courseId is not empty and quizId is not empty %}
- {% set openUrl = app.request.basePath ~ '/main/exercise/overview.php?cid=' ~ courseId ~ '&sid=' ~ (sessionId is not empty ? sessionId : 0) ~ '&gid=0&gradebook=0&origin=&exerciseId=' ~ quizId %}
+ {% if courseRootNodeId is not empty %}
+ {% set openUrl = app.request.basePath ~ '/resources/exercise/' ~ courseRootNodeId ~ '/' ~ quizId ~ '/overview?cid=' ~ courseId ~ '&sid=' ~ (sessionId is not empty ? sessionId : 0) ~ '&gid=0&gradebook=0&origin=' %}
+ {% else %}
+ {% set openUrl = app.request.basePath ~ '/main/exercise/overview.php?cid=' ~ courseId ~ '&sid=' ~ (sessionId is not empty ? sessionId : 0) ~ '&gid=0&gradebook=0&origin=&exerciseId=' ~ quizId %}
+ {% endif %}
{% endif %}
{% elseif isQuizResult %}
{% if courseId is not empty and quizId is not empty %}
- {% set openUrl = app.request.basePath ~ '/main/exercise/overview.php?cid=' ~ courseId ~ '&sid=' ~ (sessionId is not empty ? sessionId : 0) ~ '&gid=0&gradebook=0&origin=&exerciseId=' ~ quizId %}
+ {% if courseRootNodeId is not empty %}
+ {% set openUrl = app.request.basePath ~ '/resources/exercise/' ~ courseRootNodeId ~ '/' ~ quizId ~ '/overview?cid=' ~ courseId ~ '&sid=' ~ (sessionId is not empty ? sessionId : 0) ~ '&gid=0&gradebook=0&origin=' %}
+ {% else %}
+ {% set openUrl = app.request.basePath ~ '/main/exercise/overview.php?cid=' ~ courseId ~ '&sid=' ~ (sessionId is not empty ? sessionId : 0) ~ '&gid=0&gradebook=0&origin=&exerciseId=' ~ quizId %}
+ {% endif %}
{% endif %}
{% else %}
{% if courseRootNodeId is not empty and courseId is not empty %}
@@ -156,11 +165,24 @@
- {{ title }}
+
+ {% if openUrl and isAccessible %}
+ {{ title }}
+ {% else %}
+ {{ title }}
+ {% endif %}
+
- {% if isAccessible == false %}
+ {% if isStale %}
+ {{ 'Unavailable'|trans }}
+ {% elseif isAccessible == false %}
{{ 'Restricted'|trans }}
{% endif %}
@@ -193,7 +215,7 @@
{{ actionLabel }}
- {% elseif openUrl and not isAccessible %}
+ {% elseif openUrl and not isAccessible and not isStale %}
{{ actionLabel }}
diff --git a/src/CoreBundle/Service/Exercise/ExerciseAttemptScoringService.php b/src/CoreBundle/Service/Exercise/ExerciseAttemptScoringService.php
new file mode 100644
index 00000000000..c47088f730b
--- /dev/null
+++ b/src/CoreBundle/Service/Exercise/ExerciseAttemptScoringService.php
@@ -0,0 +1,1271 @@
+
+ */
+ private const SUPPORTED_TYPE_NAMES = [
+ self::UNIQUE_ANSWER => 'Unique answer',
+ self::UNIQUE_ANSWER_NO_OPTION => 'Unique answer no option',
+ self::UNIQUE_ANSWER_IMAGE => 'Unique answer with images',
+ self::MULTIPLE_ANSWER => 'Multiple answer',
+ self::GLOBAL_MULTIPLE_ANSWER => 'Global multiple answer',
+ self::MULTIPLE_ANSWER_COMBINATION => 'Multiple answer combination',
+ self::MULTIPLE_ANSWER_TRUE_FALSE => 'Multiple answer true/false',
+ self::MULTIPLE_ANSWER_COMBINATION_TRUE_FALSE => 'Multiple answer combination true/false',
+ self::MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY => 'Multiple answer true/false with degree of certainty',
+ self::FILL_IN_BLANKS => 'Fill in blanks',
+ self::FILL_IN_BLANKS_COMBINATION => 'Fill in blanks combination',
+ self::MATCHING => 'Matching',
+ self::MATCHING_DRAGGABLE => 'Matching draggable',
+ self::MATCHING_COMBINATION => 'Matching combination',
+ self::MATCHING_DRAGGABLE_COMBINATION => 'Matching draggable combination',
+ self::HOT_SPOT_COMBINATION => 'Hotspot combination',
+ self::MULTIPLE_ANSWER_DROPDOWN => 'Multiple answer dropdown',
+ self::MULTIPLE_ANSWER_DROPDOWN_COMBINATION => 'Multiple answer dropdown combination',
+ self::FREE_ANSWER => 'Free answer',
+ self::HOT_SPOT => 'Hotspot',
+ self::CALCULATED_ANSWER => 'Calculated answer',
+ self::DRAGGABLE => 'Sequence ordering',
+ self::ORAL_EXPRESSION => 'Oral expression',
+ self::UPLOAD_ANSWER => 'Upload answer',
+ self::ANNOTATION => 'Annotation',
+ self::MEDIA_QUESTION => 'Media question',
+ self::READING_COMPREHENSION => 'Reading comprehension',
+ self::PAGE_BREAK => 'Page break',
+ ];
+
+ public function __construct(
+ private EntityManagerInterface $entityManager,
+ ) {}
+
+ /**
+ * @return array{score: float, maxScore: float, questionsToCheck: array}
+ */
+ public function recalculateAttempt(TrackEExercise $attempt, CQuiz $quiz): array
+ {
+ $questionIds = $this->parseQuestionIds((string) $attempt->getDataTracking());
+ if ([] === $questionIds) {
+ throw new BadRequestHttpException('The attempt does not contain a persisted question list.');
+ }
+
+ $questions = $this->getQuestions($quiz, $questionIds);
+ $unsupportedTypes = $this->getUnsupportedQuestionTypes($questions);
+ if ([] !== $unsupportedTypes) {
+ throw new BadRequestHttpException('This attempt contains question types that are not supported by the migrated scorer yet: '.implode(', ', $unsupportedTypes).'.');
+ }
+
+ $totalScore = 0.0;
+ $totalWeight = 0.0;
+ $pendingQuestionIds = array_flip($this->parseQuestionIds((string) $attempt->getQuestionsToCheck()));
+ $questionsToCheck = [];
+
+ foreach ($questionIds as $questionId) {
+ $question = $questions[$questionId] ?? null;
+ if (!$question instanceof CQuizQuestion) {
+ continue;
+ }
+
+ $rows = $this->getAttemptRows((int) $attempt->getExeId(), $questionId);
+ $answers = $this->getQuestionAnswers($questionId);
+ $options = $this->getQuestionOptions($questionId);
+ $score = $this->scoreQuestion($quiz, $question, $answers, $options, $rows);
+ $weight = $this->getQuestionWeight($question, $answers);
+
+ if (0 === (int) $quiz->getPropagateNeg() && 0 > $score) {
+ $score = 0.0;
+ }
+
+ $this->updateQuestionAttemptRows($question, $rows, $score);
+
+ if ($this->requiresManualCorrection($question) && isset($pendingQuestionIds[$questionId])) {
+ $questionsToCheck[] = $questionId;
+ }
+
+ $totalScore += $score;
+ $totalWeight += $weight;
+ }
+
+ if (0.0 >= $totalWeight) {
+ $totalWeight = (float) $attempt->getMaxScore();
+ }
+
+ $attempt
+ ->setScore($totalScore)
+ ->setMaxScore($totalWeight)
+ ->setQuestionsToCheck(implode(',', $questionsToCheck))
+ ;
+
+ $this->syncLearningPathScore($attempt, $totalScore);
+
+ return [
+ 'score' => $totalScore,
+ 'maxScore' => $totalWeight,
+ 'questionsToCheck' => $questionsToCheck,
+ ];
+ }
+
+ private function syncLearningPathScore(TrackEExercise $attempt, float $score): void
+ {
+ if (0 >= $attempt->getOrigLpId() || 0 >= $attempt->getOrigLpItemId() || 0 >= $attempt->getOrigLpItemViewId()) {
+ return;
+ }
+
+ $lpItem = $this->entityManager->getRepository(CLpItem::class)->find($attempt->getOrigLpItemId());
+ if (!$lpItem instanceof CLpItem || 'quiz' !== $lpItem->getItemType()) {
+ return;
+ }
+
+ $lpItemView = $this->entityManager->getRepository(CLpItemView::class)->find($attempt->getOrigLpItemViewId());
+ if (!$lpItemView instanceof CLpItemView) {
+ return;
+ }
+
+ $lpItemView->setScore($score);
+ }
+
+ /**
+ * @return array
+ */
+ private function parseQuestionIds(string $value): array
+ {
+ if ('' === trim($value)) {
+ return [];
+ }
+
+ return array_values(array_filter(array_map(static fn (string $id): int => (int) trim($id), explode(',', $value))));
+ }
+
+ /**
+ * @param array $questionIds
+ *
+ * @return array
+ */
+ private function getQuestions(CQuiz $quiz, array $questionIds): array
+ {
+ $relations = $this->entityManager->createQueryBuilder()
+ ->select('relQuestion')
+ ->addSelect('question')
+ ->from(CQuizRelQuestion::class, 'relQuestion')
+ ->innerJoin('relQuestion.question', 'question')
+ ->andWhere('IDENTITY(relQuestion.quiz) = :exerciseId')
+ ->andWhere('IDENTITY(relQuestion.question) IN (:questionIds)')
+ ->setParameter('exerciseId', (int) $quiz->getIid(), Types::INTEGER)
+ ->setParameter('questionIds', $questionIds, ArrayParameterType::INTEGER)
+ ->getQuery()
+ ->getResult()
+ ;
+
+ $questions = [];
+ foreach ($relations as $relation) {
+ if (!$relation instanceof CQuizRelQuestion) {
+ continue;
+ }
+
+ $question = $relation->getQuestion();
+ if (null === $question->getIid()) {
+ continue;
+ }
+
+ $questions[(int) $question->getIid()] = $question;
+ }
+
+ return $questions;
+ }
+
+ /**
+ * @param array $questions
+ *
+ * @return array
+ */
+ private function getUnsupportedQuestionTypes(array $questions): array
+ {
+ $unsupportedTypes = [];
+ foreach ($questions as $question) {
+ $type = (int) $question->getType();
+ if (!$this->isSupportedQuestionType($type)) {
+ $unsupportedTypes[$type] = self::SUPPORTED_TYPE_NAMES[$type] ?? (string) $type;
+ }
+ }
+
+ return $unsupportedTypes;
+ }
+
+ private function isSupportedQuestionType(int $type): bool
+ {
+ return isset(self::SUPPORTED_TYPE_NAMES[$type]);
+ }
+
+ /**
+ * @return array
+ */
+ private function getAttemptRows(int $attemptId, int $questionId): array
+ {
+ $rows = $this->entityManager->createQueryBuilder()
+ ->select('saved')
+ ->from(TrackEAttempt::class, 'saved')
+ ->andWhere('IDENTITY(saved.trackExercise) = :attemptId')
+ ->andWhere('saved.questionId = :questionId')
+ ->setParameter('attemptId', $attemptId, Types::INTEGER)
+ ->setParameter('questionId', $questionId, Types::INTEGER)
+ ->orderBy('saved.position', 'ASC')
+ ->getQuery()
+ ->getResult()
+ ;
+
+ $result = [];
+ foreach ($rows as $row) {
+ if ($row instanceof TrackEAttempt) {
+ $result[] = $row;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * @return array
+ */
+ private function getQuestionAnswers(int $questionId): array
+ {
+ $rows = $this->entityManager->createQueryBuilder()
+ ->select('answer')
+ ->from(CQuizAnswer::class, 'answer')
+ ->andWhere('IDENTITY(answer.question) = :questionId')
+ ->setParameter('questionId', $questionId, Types::INTEGER)
+ ->orderBy('answer.position', 'ASC')
+ ->addOrderBy('answer.iid', 'ASC')
+ ->getQuery()
+ ->getResult()
+ ;
+
+ $answers = [];
+ foreach ($rows as $row) {
+ if (!$row instanceof CQuizAnswer || null === $row->getIid()) {
+ continue;
+ }
+
+ $answers[(int) $row->getIid()] = $row;
+ }
+
+ return $answers;
+ }
+
+ /**
+ * @return array
+ */
+ private function getQuestionOptions(int $questionId): array
+ {
+ $rows = $this->entityManager->createQueryBuilder()
+ ->select('questionOption')
+ ->from(CQuizQuestionOption::class, 'questionOption')
+ ->andWhere('IDENTITY(questionOption.question) = :questionId')
+ ->setParameter('questionId', $questionId, Types::INTEGER)
+ ->getQuery()
+ ->getResult()
+ ;
+
+ $options = [];
+ foreach ($rows as $row) {
+ if (!$row instanceof CQuizQuestionOption || null === $row->getIid()) {
+ continue;
+ }
+
+ $options[(int) $row->getIid()] = $row;
+ }
+
+ return $options;
+ }
+
+ /**
+ * @param array $answers
+ * @param array $options
+ * @param array $rows
+ */
+ private function scoreQuestion(CQuiz $quiz, CQuizQuestion $question, array $answers, array $options, array $rows): float
+ {
+ return match ((int) $question->getType()) {
+ self::UNIQUE_ANSWER,
+ self::UNIQUE_ANSWER_NO_OPTION,
+ self::UNIQUE_ANSWER_IMAGE,
+ self::READING_COMPREHENSION => $this->scoreUniqueAnswer($answers, $rows),
+ self::MULTIPLE_ANSWER,
+ self::GLOBAL_MULTIPLE_ANSWER,
+ self::MULTIPLE_ANSWER_DROPDOWN => $this->scoreMultipleAnswer($answers, $rows),
+ self::MULTIPLE_ANSWER_COMBINATION,
+ self::MULTIPLE_ANSWER_DROPDOWN_COMBINATION => $this->scoreMultipleCombination($question, $answers, $rows),
+ self::MULTIPLE_ANSWER_TRUE_FALSE => $this->scoreTrueFalseAnswer($question, $answers, $options, $rows),
+ self::MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY => $this->scoreTrueFalseDegreeCertaintyAnswer($question, $answers, $options, $rows),
+ self::MULTIPLE_ANSWER_COMBINATION_TRUE_FALSE => $this->scoreTrueFalseCombination($question, $answers, $options, $rows),
+ self::FILL_IN_BLANKS,
+ self::FILL_IN_BLANKS_COMBINATION => $this->scoreFillBlanks($quiz, $question, $answers, $rows),
+ self::MATCHING,
+ self::MATCHING_DRAGGABLE => $this->scoreMatchingAnswer($answers, $rows),
+ self::DRAGGABLE => $this->scoreDraggableAnswer($answers, $rows),
+ self::MATCHING_COMBINATION,
+ self::MATCHING_DRAGGABLE_COMBINATION => $this->scoreMatchingCombination($question, $answers, $rows),
+ self::CALCULATED_ANSWER => $this->scoreCalculatedAnswer($question, $answers, $rows),
+ self::HOT_SPOT => $this->scoreHotspotAnswer($answers, $rows, false, (float) $question->getPonderation()),
+ self::HOT_SPOT_COMBINATION => $this->scoreHotspotAnswer($answers, $rows, true, (float) $question->getPonderation()),
+ self::FREE_ANSWER,
+ self::ORAL_EXPRESSION,
+ self::UPLOAD_ANSWER,
+ self::ANNOTATION => $this->scoreManualAnswer($rows),
+ default => 0.0,
+ };
+ }
+
+ /**
+ * @param array $answers
+ * @param array $rows
+ */
+ private function scoreUniqueAnswer(array $answers, array $rows): float
+ {
+ $selectedAnswerId = $this->getFirstSavedAnswerId($rows);
+ if (0 >= $selectedAnswerId || !isset($answers[$selectedAnswerId])) {
+ return 0.0;
+ }
+
+ return $answers[$selectedAnswerId]->getPonderation();
+ }
+
+ /**
+ * @param array $answers
+ * @param array $rows
+ */
+ private function scoreMultipleAnswer(array $answers, array $rows): float
+ {
+ $score = 0.0;
+ foreach ($this->getSavedAnswerIds($rows) as $answerId) {
+ if (!isset($answers[$answerId])) {
+ continue;
+ }
+
+ $score += $answers[$answerId]->getPonderation();
+ }
+
+ return $score;
+ }
+
+ /**
+ * @param array $answers
+ * @param array $rows
+ */
+ private function scoreMultipleCombination(CQuizQuestion $question, array $answers, array $rows): float
+ {
+ $selectedAnswerIds = array_flip($this->getSavedAnswerIds($rows));
+ foreach ($answers as $answer) {
+ $answerId = (int) $answer->getIid();
+ $isCorrect = 1 === (int) $answer->getCorrect();
+ $isSelected = isset($selectedAnswerIds[$answerId]);
+ if ($isCorrect !== $isSelected) {
+ return 0.0;
+ }
+ }
+
+ $firstAnswer = reset($answers);
+ if ($firstAnswer instanceof CQuizAnswer && 0.0 !== $firstAnswer->getPonderation()) {
+ return $firstAnswer->getPonderation();
+ }
+
+ return (float) $question->getPonderation();
+ }
+
+ /**
+ * @param array $answers
+ * @param array $options
+ * @param array $rows
+ */
+ private function scoreTrueFalseAnswer(CQuizQuestion $question, array $answers, array $options, array $rows): float
+ {
+ [$trueScore, $falseScore, $doubtScore] = $this->getTrueFalseScores((string) $question->getExtra());
+ $choices = $this->getSavedTrueFalseChoices($rows);
+ $score = 0.0;
+
+ foreach ($answers as $answer) {
+ $answerId = (int) $answer->getIid();
+ $studentChoice = $choices[$answerId] ?? 0;
+ if (0 >= $studentChoice) {
+ $score += $doubtScore;
+ continue;
+ }
+
+ if ($this->isTrueFalseChoiceCorrect($studentChoice, (int) $answer->getCorrect(), $options)) {
+ $score += $trueScore;
+ continue;
+ }
+
+ $optionTitle = $this->getTrueFalseOptionTitle($studentChoice, $options);
+ $score += \in_array($optionTitle, ["Don't know", 'DoubtScore'], true) ? $doubtScore : $falseScore;
+ }
+
+ return $score;
+ }
+
+ /**
+ * @param array $answers
+ * @param array $options
+ * @param array $rows
+ */
+ private function scoreTrueFalseDegreeCertaintyAnswer(CQuizQuestion $question, array $answers, array $options, array $rows): float
+ {
+ [$trueScore, $falseScore, $doubtScore] = $this->getTrueFalseScores((string) $question->getExtra());
+ $choices = $this->getSavedTrueFalseDegreeCertaintyChoices($rows);
+ $score = 0.0;
+
+ foreach ($answers as $answer) {
+ $answerId = (int) $answer->getIid();
+ $studentChoice = (int) ($choices[$answerId]['choice'] ?? 0);
+ if (0 >= $studentChoice) {
+ continue;
+ }
+
+ $studentDegreeChoice = (int) ($choices[$answerId]['degree'] ?? 0);
+ $studentDegreeChoicePosition = $this->getTrueFalseOptionPosition($studentDegreeChoice, $options);
+ $hasCertainty = 3 <= $studentDegreeChoicePosition && 9 > $studentDegreeChoicePosition;
+
+ if ($this->isTrueFalseChoiceCorrect($studentChoice, (int) $answer->getCorrect(), $options)) {
+ $score += $hasCertainty ? $trueScore : $doubtScore;
+ continue;
+ }
+
+ $score += $hasCertainty ? $falseScore : $doubtScore;
+ }
+
+ return $score;
+ }
+
+ /**
+ * @param array $rows
+ *
+ * @return array
+ */
+ private function getSavedTrueFalseDegreeCertaintyChoices(array $rows): array
+ {
+ $choices = [];
+ foreach ($rows as $row) {
+ $parts = explode(':', (string) $row->getAnswer());
+ $answerId = isset($parts[0]) ? (int) $parts[0] : 0;
+ $optionId = isset($parts[1]) ? (int) $parts[1] : 0;
+ $degreeId = isset($parts[2]) ? (int) $parts[2] : 0;
+ if (0 < $answerId && 0 < $optionId) {
+ $choices[$answerId] = [
+ 'choice' => $optionId,
+ 'degree' => $degreeId,
+ ];
+ }
+ }
+
+ return $choices;
+ }
+
+ private function scoreTrueFalseCombination(CQuizQuestion $question, array $answers, array $options, array $rows): float
+ {
+ $choices = $this->getSavedTrueFalseChoices($rows);
+ foreach ($answers as $answer) {
+ $answerId = (int) $answer->getIid();
+ if (!$this->isTrueFalseChoiceCorrect($choices[$answerId] ?? 0, (int) $answer->getCorrect(), $options)) {
+ return 0.0;
+ }
+ }
+
+ $firstAnswer = reset($answers);
+ if ($firstAnswer instanceof CQuizAnswer && 0.0 !== $firstAnswer->getPonderation()) {
+ return $firstAnswer->getPonderation();
+ }
+
+ return (float) $question->getPonderation();
+ }
+
+ /**
+ * @return array{0: float, 1: float, 2: float}
+ */
+ private function getTrueFalseScores(string $extra): array
+ {
+ if ('' === trim($extra)) {
+ return [1.0, -0.5, 0.0];
+ }
+
+ $parts = explode(':', $extra);
+
+ return [
+ isset($parts[0]) ? (float) trim($parts[0]) : 1.0,
+ isset($parts[1]) ? (float) trim($parts[1]) : -0.5,
+ isset($parts[2]) ? (float) trim($parts[2]) : 0.0,
+ ];
+ }
+
+ /**
+ * @param array $rows
+ *
+ * @return array
+ */
+ private function getSavedTrueFalseChoices(array $rows): array
+ {
+ $choices = [];
+ foreach ($rows as $row) {
+ $parts = explode(':', (string) $row->getAnswer());
+ $answerId = isset($parts[0]) ? (int) $parts[0] : 0;
+ $optionId = isset($parts[1]) ? (int) $parts[1] : 0;
+ if (0 < $answerId && 0 < $optionId) {
+ $choices[$answerId] = $optionId;
+ }
+ }
+
+ return $choices;
+ }
+ /**
+ * @param array $options
+ */
+ private function isTrueFalseChoiceCorrect(int $studentChoice, int $correctChoice, array $options): bool
+ {
+ if (0 >= $studentChoice || 0 >= $correctChoice) {
+ return false;
+ }
+
+ if ($studentChoice === $correctChoice) {
+ return true;
+ }
+
+ $studentPosition = $this->getTrueFalseOptionPosition($studentChoice, $options);
+ $correctPosition = $this->getTrueFalseOptionPosition($correctChoice, $options);
+
+ return 0 < $studentPosition && $studentPosition === $correctPosition;
+ }
+
+ /**
+ * @param array $options
+ */
+ private function getTrueFalseOptionPosition(int $choice, array $options): int
+ {
+ $option = $options[$choice] ?? null;
+ if ($option instanceof CQuizQuestionOption) {
+ return (int) $option->getPosition();
+ }
+
+ foreach ($options as $candidate) {
+ if (!$candidate instanceof CQuizQuestionOption) {
+ continue;
+ }
+
+ if ((int) $candidate->getPosition() === $choice) {
+ return (int) $candidate->getPosition();
+ }
+ }
+
+ return $choice;
+ }
+
+ /**
+ * @param array $options
+ */
+ private function getTrueFalseOptionTitle(int $choice, array $options): string
+ {
+ $option = $options[$choice] ?? null;
+ if ($option instanceof CQuizQuestionOption) {
+ return (string) $option->getTitle();
+ }
+
+ foreach ($options as $candidate) {
+ if (!$candidate instanceof CQuizQuestionOption) {
+ continue;
+ }
+
+ if ((int) $candidate->getPosition() === $choice) {
+ return (string) $candidate->getTitle();
+ }
+ }
+
+ return '';
+ }
+
+
+ /**
+ * @param array $answers
+ * @param array $rows
+ */
+ private function scoreFillBlanks(CQuiz $quiz, CQuizQuestion $question, array $answers, array $rows): float
+ {
+ $row = $rows[0] ?? null;
+ if (!$row instanceof TrackEAttempt) {
+ return 0.0;
+ }
+
+ $answer = reset($answers);
+ if (!$answer instanceof CQuizAnswer) {
+ return 0.0;
+ }
+
+ $teacherInfo = $this->parseFillBlankAnswer($answer->getAnswer(), false);
+ $studentInfo = $this->parseFillBlankAnswer($row->getAnswer(), true);
+ $caseInsensitive = 'case:false' === (string) $question->getExtra();
+ $studentScores = [];
+ $score = 0.0;
+ $blankCount = \count($teacherInfo['words']);
+
+ for ($index = 0; $index < $blankCount; ++$index) {
+ $correctAnswer = (string) ($teacherInfo['words'][$index] ?? '');
+ $studentAnswer = (string) ($studentInfo['student_answer'][$index] ?? '');
+ $isCorrect = $this->isFillBlankStudentAnswerGood($studentAnswer, $correctAnswer, $caseInsensitive);
+ $studentScores[$index] = $isCorrect ? '1' : '0';
+ if ($isCorrect) {
+ $score += (float) ($teacherInfo['weighting'][$index] ?? 0.0);
+ }
+ }
+
+ if (self::FILL_IN_BLANKS_COMBINATION === (int) $question->getType()) {
+ $score = $blankCount > 0 && !\in_array('0', $studentScores, true) ? (float) $question->getPonderation() : 0.0;
+ }
+
+ $row->setAnswer($this->rebuildFillBlankStudentAnswer($teacherInfo, $studentInfo['student_answer'], $studentScores));
+
+ return $score;
+ }
+
+ /**
+ * @return array{
+ * text: string,
+ * system_string: string,
+ * words_count: int,
+ * words: array,
+ * words_with_bracket: array,
+ * weighting: array,
+ * common_words: array,
+ * student_answer: array,
+ * student_score: array,
+ * blank_separator_start: string,
+ * blank_separator_end: string
+ * }
+ */
+ private function parseFillBlankAnswer(string $answer, bool $isStudentAnswer): array
+ {
+ $parts = [];
+ if (1 === preg_match('/(.*)::(.*)$/s', $answer, $matches)) {
+ $parts = [(string) ($matches[1] ?? ''), (string) ($matches[2] ?? '')];
+ } else {
+ $parts = ['', ''];
+ }
+
+ $systemString = $parts[1];
+ $systemParts = explode('@', $systemString, 2);
+ $details = explode(':', (string) ($systemParts[0] ?? ''));
+ $weighting = '' !== (string) ($details[0] ?? '') ? explode(',', (string) $details[0]) : [];
+ $separatorNumber = \count($details) >= 3 ? (int) ($details[2] ?? 0) : 0;
+ [$start, $end] = $this->getFillBlankSeparators($separatorNumber);
+ $startPattern = preg_quote($start, '/');
+ $endPattern = preg_quote($end, '/');
+ $wordMatches = [];
+ preg_match_all('/'.$startPattern.'[^'.$endPattern.']*'.$endPattern.'/', $parts[0], $wordMatches);
+ $wordsWithBracket = \is_array($wordMatches[0] ?? null) ? $wordMatches[0] : [];
+ $words = [];
+ foreach ($wordsWithBracket as $word) {
+ $words[] = trim((string) $word, $start.$end);
+ }
+
+ $commonWordsString = preg_replace('/'.$startPattern.'[^'.$endPattern.']*'.$endPattern.'/', '::', $parts[0]);
+ if (!\is_string($commonWordsString)) {
+ $commonWordsString = '';
+ }
+
+ $studentAnswer = [];
+ $studentScore = [];
+ if ($isStudentAnswer) {
+ $baseWords = [];
+ $baseWordsWithBracket = [];
+ $count = \count($words);
+ for ($index = 0; $index < $count; ++$index) {
+ $baseWordsWithBracket[] = $wordsWithBracket[$index] ?? '';
+ $baseWords[] = $words[$index] ?? '';
+ ++$index;
+ $studentAnswer[] = $words[$index] ?? '';
+ ++$index;
+ $studentScore[] = $words[$index] ?? '0';
+ }
+ $words = $baseWords;
+ $wordsWithBracket = $baseWordsWithBracket;
+ $commonWordsString = preg_replace('/::::::/', '::', $commonWordsString) ?: '';
+ }
+
+ return [
+ 'text' => $parts[0],
+ 'system_string' => $systemString,
+ 'words_count' => \count($words),
+ 'words' => $words,
+ 'words_with_bracket' => $wordsWithBracket,
+ 'weighting' => $weighting,
+ 'common_words' => explode('::', $commonWordsString),
+ 'student_answer' => $studentAnswer,
+ 'student_score' => $studentScore,
+ 'blank_separator_start' => $start,
+ 'blank_separator_end' => $end,
+ ];
+ }
+
+ /**
+ * @param array{
+ * common_words: array,
+ * words_with_bracket: array,
+ * system_string: string,
+ * blank_separator_start: string,
+ * blank_separator_end: string
+ * } $teacherInfo
+ * @param array $studentAnswers
+ * @param array $studentScores
+ */
+ private function rebuildFillBlankStudentAnswer(array $teacherInfo, array $studentAnswers, array $studentScores): string
+ {
+ $start = (string) $teacherInfo['blank_separator_start'];
+ $end = (string) $teacherInfo['blank_separator_end'];
+ $commonWords = $teacherInfo['common_words'];
+ $result = '';
+ $count = \count($teacherInfo['words_with_bracket']);
+
+ for ($index = 0; $index < $count; ++$index) {
+ $result .= (string) ($commonWords[$index] ?? '');
+ $result .= (string) ($teacherInfo['words_with_bracket'][$index] ?? '');
+ $result .= $start.(string) ($studentAnswers[$index] ?? '').$end;
+ $result .= $start.(string) ($studentScores[$index] ?? '0').$end;
+ }
+
+ $result .= (string) ($commonWords[$count] ?? '');
+ $result .= '::'.(string) $teacherInfo['system_string'];
+
+ return $result;
+ }
+
+ /**
+ * @return array{0: string, 1: string}
+ */
+ private function getFillBlankSeparators(int $separator): array
+ {
+ return match ($separator) {
+ 1 => ['{', '}'],
+ 2 => ['(', ')'],
+ 3 => ['*', '*'],
+ 4 => ['#', '#'],
+ 5 => ['%', '%'],
+ 6 => ['$', '$'],
+ default => ['[', ']'],
+ };
+ }
+
+ private function isFillBlankStudentAnswerGood(string $studentAnswer, string $correctAnswer, bool $caseInsensitive): bool
+ {
+ $normalizedStudentAnswer = $caseInsensitive ? mb_strtolower($studentAnswer) : $studentAnswer;
+
+ if (false !== strpos($correctAnswer, '|') && false === strpos($correctAnswer, '||')) {
+ $menuAnswers = array_map([$this, 'trimFillBlankOption'], explode('|', $correctAnswer));
+ $firstAnswer = (string) ($menuAnswers[0] ?? '');
+ $normalizedFirstAnswer = $caseInsensitive ? mb_strtolower($firstAnswer) : $firstAnswer;
+
+ return $normalizedStudentAnswer === $normalizedFirstAnswer || $normalizedStudentAnswer === sha1($normalizedFirstAnswer);
+ }
+
+ if (false !== strpos($correctAnswer, '||')) {
+ $answers = array_map([$this, 'trimFillBlankOption'], preg_split('/\|\|/', $correctAnswer) ?: []);
+ foreach ($answers as $answer) {
+ $candidate = $caseInsensitive ? mb_strtolower($answer) : $answer;
+ if ($normalizedStudentAnswer === $candidate) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ $normalizedCorrectAnswer = $caseInsensitive ? mb_strtolower($this->trimFillBlankOption($correctAnswer)) : $this->trimFillBlankOption($correctAnswer);
+
+ return $normalizedStudentAnswer === $normalizedCorrectAnswer;
+ }
+
+ private function trimFillBlankOption(string $value): string
+ {
+ return trim(html_entity_decode($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
+ }
+
+ /**
+ * @param array $answers
+ * @param array $rows
+ */
+ private function scoreMatchingAnswer(array $answers, array $rows): float
+ {
+ $choices = $this->getSavedMatchingChoices($rows);
+ $score = 0.0;
+ foreach ($answers as $answer) {
+ if (0 === (int) $answer->getCorrect()) {
+ continue;
+ }
+
+ $answerId = (int) $answer->getIid();
+ if (($choices[$answerId] ?? 0) === (int) $answer->getCorrect()) {
+ $score += $answer->getPonderation();
+ }
+ }
+
+ return $score;
+ }
+
+ /**
+ * @param array $answers
+ * @param array $rows
+ */
+ private function scoreDraggableAnswer(array $answers, array $rows): float
+ {
+ $positions = $this->getSavedDraggablePositions($rows);
+ $score = 0.0;
+
+ foreach ($answers as $answer) {
+ $correctPosition = (int) ($answer->getCorrect() ?? 0);
+ if (0 >= $correctPosition) {
+ continue;
+ }
+
+ $answerId = (int) $answer->getIid();
+ if (($positions[$answerId] ?? 0) === $correctPosition) {
+ $score += $answer->getPonderation();
+ }
+ }
+
+ return $score;
+ }
+
+ /**
+ * @param array $answers
+ * @param array $rows
+ */
+ private function scoreMatchingCombination(CQuizQuestion $question, array $answers, array $rows): float
+ {
+ $choices = $this->getSavedMatchingChoices($rows);
+ $optionCount = 0;
+ $correctCount = 0;
+
+ foreach ($answers as $answer) {
+ if (0 === (int) $answer->getCorrect()) {
+ continue;
+ }
+
+ ++$optionCount;
+ $answerId = (int) $answer->getIid();
+ if (($choices[$answerId] ?? 0) === (int) $answer->getCorrect()) {
+ ++$correctCount;
+ }
+ }
+
+ return 0 < $optionCount && $correctCount === $optionCount ? (float) $question->getPonderation() : 0.0;
+ }
+
+ /**
+ * @param array $rows
+ *
+ * @return array
+ */
+ private function getSavedMatchingChoices(array $rows): array
+ {
+ $choices = [];
+ foreach ($rows as $row) {
+ $position = $row->getPosition();
+ if (null === $position || 0 >= $position) {
+ continue;
+ }
+
+ $choices[(int) $position] = (int) $row->getAnswer();
+ }
+
+ return $choices;
+ }
+
+ /**
+ * @param array $answers
+ * @param array $rows
+ */
+ private function scoreHotspotAnswer(array $answers, array $rows, bool $combination, float $questionWeight): float
+ {
+ $points = $this->getSavedHotspotPoints($rows);
+ if ([] === $points) {
+ return 0.0;
+ }
+
+ $matchedAnswerIds = [];
+ $score = 0.0;
+ $scoringZoneCount = 0;
+
+ foreach ($answers as $answer) {
+ $answerId = (int) $answer->getIid();
+ $hotspotType = (string) ($answer->getHotspotType() ?: 'square');
+ if (!\in_array($hotspotType, ['square', 'circle', 'poly'], true)) {
+ continue;
+ }
+
+ if (0.0 < (float) $answer->getPonderation()) {
+ ++$scoringZoneCount;
+ }
+
+ foreach ($points as $point) {
+ $pointAnswerId = (int) ($point['answerId'] ?? 0);
+ if (0 < $pointAnswerId && $pointAnswerId !== $answerId) {
+ continue;
+ }
+
+ if ($this->isPointInHotspot($point, $hotspotType, (string) $answer->getHotspotCoordinates())) {
+ $matchedAnswerIds[$answerId] = true;
+ if (!$combination) {
+ $score += (float) $answer->getPonderation();
+ }
+ break;
+ }
+ }
+ }
+
+ if (!$combination) {
+ return $score;
+ }
+
+ return 0 < $scoringZoneCount && \count($matchedAnswerIds) >= $scoringZoneCount ? $questionWeight : 0.0;
+ }
+
+ /**
+ * @param array $rows
+ *
+ * @return array
+ */
+ private function getSavedHotspotPoints(array $rows): array
+ {
+ $points = [];
+ foreach ($rows as $row) {
+ foreach (explode('|', (string) $row->getAnswer()) as $coordinate) {
+ $point = $this->decodeHotspotPoint($coordinate);
+ if (null !== $point) {
+ $points[] = $point;
+ }
+ }
+ }
+
+ return $points;
+ }
+
+ /**
+ * @return array{x: float, y: float, answerId?: int}|null
+ */
+ private function decodeHotspotPoint(string $coordinate): ?array
+ {
+ $answerId = 0;
+ $coordinateValue = trim($coordinate);
+ if (str_contains($coordinateValue, ':')) {
+ [$answerIdValue, $coordinateValue] = explode(':', $coordinateValue, 2);
+ $answerId = (int) $answerIdValue;
+ }
+
+ $parts = array_map('trim', explode(';', $coordinateValue));
+ if (2 > \count($parts) || !is_numeric($parts[0]) || !is_numeric($parts[1])) {
+ return null;
+ }
+
+ $point = ['x' => (float) $parts[0], 'y' => (float) $parts[1]];
+ if (0 < $answerId) {
+ $point['answerId'] = $answerId;
+ }
+
+ return $point;
+ }
+
+ /**
+ * @param array{x: float, y: float} $point
+ */
+ private function isPointInHotspot(array $point, string $hotspotType, string $coordinates): bool
+ {
+ return match ($hotspotType) {
+ 'square' => $this->isPointInSquare($point, $coordinates),
+ 'circle' => $this->isPointInEllipse($point, $coordinates),
+ 'poly' => $this->isPointInPolygon($point, $coordinates),
+ default => false,
+ };
+ }
+
+ /**
+ * @param array{x: float, y: float} $point
+ */
+ private function isPointInSquare(array $point, string $coordinates): bool
+ {
+ [$origin, $width, $height] = $this->parseBoxCoordinates($coordinates);
+ if (null === $origin) {
+ return false;
+ }
+
+ return $point['x'] >= $origin['x']
+ && $point['x'] <= $origin['x'] + $width
+ && $point['y'] >= $origin['y']
+ && $point['y'] <= $origin['y'] + $height;
+ }
+
+ /**
+ * @param array{x: float, y: float} $point
+ */
+ private function isPointInEllipse(array $point, string $coordinates): bool
+ {
+ [$origin, $width, $height] = $this->parseBoxCoordinates($coordinates);
+ if (null === $origin || 0.0 >= $width || 0.0 >= $height) {
+ return false;
+ }
+
+ $radiusX = $width / 2;
+ $radiusY = $height / 2;
+ $centerX = $origin['x'] + $radiusX;
+ $centerY = $origin['y'] + $radiusY;
+
+ return ((($point['x'] - $centerX) ** 2) / ($radiusX ** 2))
+ + ((($point['y'] - $centerY) ** 2) / ($radiusY ** 2)) <= 1.0;
+ }
+
+ /**
+ * @return array{0: array{x: float, y: float}|null, 1: float, 2: float}
+ */
+ private function parseBoxCoordinates(string $coordinates): array
+ {
+ $parts = explode('|', $coordinates);
+ $origin = $this->decodeHotspotPoint((string) ($parts[0] ?? ''));
+ $width = isset($parts[1]) && is_numeric($parts[1]) ? (float) $parts[1] : 0.0;
+ $height = isset($parts[2]) && is_numeric($parts[2]) ? (float) $parts[2] : 0.0;
+
+ return [$origin, $width, $height];
+ }
+
+ /**
+ * @param array{x: float, y: float} $point
+ */
+ private function isPointInPolygon(array $point, string $coordinates): bool
+ {
+ $vertices = [];
+ foreach (explode('|', $coordinates) as $coordinate) {
+ $decoded = $this->decodeHotspotPoint($coordinate);
+ if (null !== $decoded) {
+ $vertices[] = $decoded;
+ }
+ }
+
+ $count = \count($vertices);
+ if (3 > $count) {
+ return false;
+ }
+
+ $inside = false;
+ for ($i = 0, $j = $count - 1; $i < $count; $j = $i++) {
+ $xi = $vertices[$i]['x'];
+ $yi = $vertices[$i]['y'];
+ $xj = $vertices[$j]['x'];
+ $yj = $vertices[$j]['y'];
+
+ $intersects = (($yi > $point['y']) !== ($yj > $point['y']))
+ && ($point['x'] < ($xj - $xi) * ($point['y'] - $yi) / (($yj - $yi) ?: 0.000001) + $xi);
+ if ($intersects) {
+ $inside = !$inside;
+ }
+ }
+
+ return $inside;
+ }
+
+ /**
+ * @param array $answers
+ * @param array $rows
+ */
+ private function scoreCalculatedAnswer(CQuizQuestion $question, array $answers, array $rows): float
+ {
+ $row = $rows[0] ?? null;
+ if (!$row instanceof TrackEAttempt) {
+ return 0.0;
+ }
+
+ [$answerId, $studentAnswer] = $this->parseCalculatedStudentAnswer((string) $row->getAnswer());
+ $teacherAnswer = 0 < $answerId && isset($answers[$answerId]) ? $answers[$answerId] : reset($answers);
+ if (!$teacherAnswer instanceof CQuizAnswer) {
+ return 0.0;
+ }
+
+ $expectedAnswer = $this->extractCalculatedExpectedAnswer((string) $teacherAnswer->getAnswer());
+ if ('' === $expectedAnswer || '' === $studentAnswer) {
+ return 0.0;
+ }
+
+ return trim($studentAnswer) === trim($expectedAnswer) ? (float) $question->getPonderation() : 0.0;
+ }
+
+ /**
+ * @return array{0: int, 1: string}
+ */
+ private function parseCalculatedStudentAnswer(string $value): array
+ {
+ $parts = explode(':', $value, 2);
+ if (2 === \count($parts)) {
+ return [(int) $parts[0], trim((string) $parts[1])];
+ }
+
+ return [0, trim($value)];
+ }
+
+ private function extractCalculatedExpectedAnswer(string $answer): string
+ {
+ $parts = explode('@@', $answer, 2);
+ $text = (string) ($parts[0] ?? $answer);
+ if (1 === preg_match('/\[([^\[\]]*)\]\s*$/', $text, $matches)) {
+ return trim((string) ($matches[1] ?? ''));
+ }
+
+ return '';
+ }
+
+ /**
+ * @param array $rows
+ */
+ private function scoreManualAnswer(array $rows): float
+ {
+ $row = $rows[0] ?? null;
+
+ return $row instanceof TrackEAttempt ? (float) $row->getMarks() : 0.0;
+ }
+
+ /**
+ * @param array $answers
+ */
+ private function getQuestionWeight(CQuizQuestion $question, array $answers): float
+ {
+ $type = (int) $question->getType();
+ if (\in_array($type, [self::MEDIA_QUESTION, self::PAGE_BREAK], true)) {
+ return 0.0;
+ }
+
+ if (self::CALCULATED_ANSWER === $type) {
+ return (float) $question->getPonderation();
+ }
+
+ if (\in_array($type, [self::MULTIPLE_ANSWER_COMBINATION, self::MULTIPLE_ANSWER_COMBINATION_TRUE_FALSE], true)) {
+ $firstAnswer = reset($answers);
+ if ($firstAnswer instanceof CQuizAnswer && 0.0 !== $firstAnswer->getPonderation()) {
+ return $firstAnswer->getPonderation();
+ }
+ }
+
+ return (float) $question->getPonderation();
+ }
+
+ /**
+ * @param array $rows
+ */
+ private function getFirstSavedAnswerId(array $rows): int
+ {
+ $row = $rows[0] ?? null;
+
+ return $row instanceof TrackEAttempt ? (int) $row->getAnswer() : 0;
+ }
+
+ /**
+ * @param array $rows
+ *
+ * @return array
+ */
+ private function getSavedDraggablePositions(array $rows): array
+ {
+ $positions = [];
+ foreach ($rows as $row) {
+ $answerId = (int) $row->getPosition();
+ $selectedPosition = (int) $row->getAnswer();
+ if (0 < $answerId && 0 < $selectedPosition) {
+ $positions[$answerId] = $selectedPosition;
+ }
+ }
+
+ return $positions;
+ }
+
+ /**
+ * @param array $rows
+ *
+ * @return array
+ */
+ private function requiresManualCorrection(CQuizQuestion $question): bool
+ {
+ return \in_array((int) $question->getType(), [self::FREE_ANSWER, self::ORAL_EXPRESSION, self::UPLOAD_ANSWER, self::ANNOTATION], true);
+ }
+
+ private function getSavedAnswerIds(array $rows): array
+ {
+ $answerIds = [];
+ foreach ($rows as $row) {
+ $answerId = (int) $row->getAnswer();
+ if (0 < $answerId && !\in_array($answerId, $answerIds, true)) {
+ $answerIds[] = $answerId;
+ }
+ }
+
+ return $answerIds;
+ }
+
+ /**
+ * @param array $rows
+ */
+ private function updateQuestionAttemptRows(CQuizQuestion $question, array $rows, float $score): void
+ {
+ foreach ($rows as $row) {
+ $row->setMarks($score);
+ $row->setTms(new DateTime());
+ }
+
+ if ([] === $rows && !$this->requiresManualCorrection($question)) {
+ return;
+ }
+ }
+
+}
diff --git a/src/CoreBundle/Service/Exercise/ExerciseExcelTemplateService.php b/src/CoreBundle/Service/Exercise/ExerciseExcelTemplateService.php
new file mode 100644
index 00000000000..81964bd6789
--- /dev/null
+++ b/src/CoreBundle/Service/Exercise/ExerciseExcelTemplateService.php
@@ -0,0 +1,149 @@
+assertCanDownloadTemplate($request);
+
+ $spreadsheet = $this->createTemplateSpreadsheet();
+ $path = $this->createTemporaryFilePath();
+
+ $writer = new Xlsx($spreadsheet);
+ $writer->save($path);
+ $spreadsheet->disconnectWorksheets();
+
+ $response = new BinaryFileResponse(new File($path));
+ $response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
+ $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'quiz_template.xlsx');
+ $response->deleteFileAfterSend(true);
+
+ return $response;
+ }
+
+ private function assertCanDownloadTemplate(Request $request): void
+ {
+ if (!$this->security->isGranted('ROLE_CURRENT_COURSE_TEACHER')
+ && !$this->security->isGranted('ROLE_CURRENT_COURSE_SESSION_TEACHER')
+ ) {
+ throw new AccessDeniedHttpException('You are not allowed to download the exercise import template in this context.');
+ }
+
+ $course = $this->getCourse($request);
+ $this->getSession($request);
+
+ if (!$course instanceof Course) {
+ throw new BadRequestHttpException('The requested course was not found.');
+ }
+ }
+
+ private function getCourse(Request $request): Course
+ {
+ $courseId = $request->query->getInt('cid');
+ if (0 >= $courseId) {
+ throw new BadRequestHttpException('A valid course id is required.');
+ }
+
+ $course = $this->entityManager->getRepository(Course::class)->find($courseId);
+ if (!$course instanceof Course) {
+ throw new BadRequestHttpException('The requested course was not found.');
+ }
+
+ return $course;
+ }
+
+ private function getSession(Request $request): ?Session
+ {
+ $sessionId = $request->query->getInt('sid');
+ if (0 >= $sessionId) {
+ return null;
+ }
+
+ $session = $this->entityManager->getRepository(Session::class)->find($sessionId);
+ if (!$session instanceof Session) {
+ throw new BadRequestHttpException('The requested session was not found.');
+ }
+
+ return $session;
+ }
+
+ private function createTemplateSpreadsheet(): Spreadsheet
+ {
+ $spreadsheet = new Spreadsheet();
+ $sheet = $spreadsheet->getActiveSheet();
+ $sheet->setTitle('Quiz');
+
+ $rows = [
+ ['Quiz', 'Imported Excel quiz', ''],
+ ['Question', 'What is PHP?', ''],
+ ['Answer 1', 'A programming language', 'x'],
+ ['Answer 2', 'A database', ''],
+ ['Score', '', '10'],
+ ['FeedbackTrue', 'Correct.', ''],
+ ['FeedbackFalse', 'Incorrect.', ''],
+ ['Category', 'PHP basics', ''],
+ ['QuestionType', '', '1'],
+ ['', '', ''],
+ ['Question', 'Select the web technologies.', ''],
+ ['Answer 1', 'HTML', 'x'],
+ ['Answer 2', 'CSS', 'x'],
+ ['Answer 3', 'SQL', ''],
+ ['Score', '', '10'],
+ ['QuestionType', '', '2'],
+ ];
+
+ foreach ($rows as $index => $row) {
+ $rowNumber = $index + 1;
+ $sheet->setCellValue('A'.$rowNumber, $row[0]);
+ $sheet->setCellValue('B'.$rowNumber, $row[1]);
+ $sheet->setCellValue('C'.$rowNumber, $row[2]);
+ }
+
+ foreach (['A', 'B', 'C'] as $column) {
+ $sheet->getColumnDimension($column)->setAutoSize(true);
+ }
+
+ return $spreadsheet;
+ }
+
+ private function createTemporaryFilePath(): string
+ {
+ $path = tempnam(sys_get_temp_dir(), 'chamilo_exercise_excel_template_');
+ if (false === $path) {
+ throw new BadRequestHttpException('The Excel template file could not be created.');
+ }
+
+ return $path;
+ }
+}
diff --git a/src/CoreBundle/Service/Exercise/ExerciseGlobalReportExportService.php b/src/CoreBundle/Service/Exercise/ExerciseGlobalReportExportService.php
new file mode 100644
index 00000000000..c5b3ca14870
--- /dev/null
+++ b/src/CoreBundle/Service/Exercise/ExerciseGlobalReportExportService.php
@@ -0,0 +1,393 @@
+assertAccessAllowed();
+ $this->requireLegacyExerciseClasses();
+
+ $courseId = $this->resolveCourseId($request);
+ if (0 >= $courseId) {
+ throw new BadRequestHttpException('A valid course id is required.');
+ }
+
+ $course = $this->getCourse($courseId);
+ if (null === $course) {
+ throw new NotFoundHttpException('The requested course could not be found.');
+ }
+
+ $rows = $this->buildRows($courseId, (string) $course['code']);
+ $filename = $this->safeFilename('exercise_global_report_'.$course['code']).'.csv';
+
+ $response = new StreamedResponse(static function () use ($rows): void {
+ $handle = fopen('php://output', 'w');
+ if (!\is_resource($handle)) {
+ return;
+ }
+
+ foreach ($rows as $row) {
+ fputcsv($handle, $row);
+ }
+
+ fclose($handle);
+ });
+
+ $response->headers->set('Content-Type', 'text/csv; charset=UTF-8');
+ $response->headers->set(
+ 'Content-Disposition',
+ $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename)
+ );
+
+ return $response;
+ }
+
+ private function assertAccessAllowed(): void
+ {
+ if ($this->security->isGranted('ROLE_ADMIN')) {
+ return;
+ }
+
+ if (\function_exists('api_is_platform_admin') && api_is_platform_admin()) {
+ return;
+ }
+
+ throw new AccessDeniedHttpException('You are not allowed to export the exercise global report.');
+ }
+
+ private function resolveCourseId(Request $request): int
+ {
+ $courseId = $request->query->getInt('cid', $request->query->getInt('courseId'));
+
+ if (0 >= $courseId && \function_exists('api_get_course_int_id')) {
+ $courseId = (int) api_get_course_int_id();
+ }
+
+ return $courseId;
+ }
+
+ /**
+ * @return array|null
+ */
+ private function getCourse(int $courseId): ?array
+ {
+ $course = $this->connection->executeQuery(
+ 'SELECT id, code, title FROM course WHERE id = :courseId',
+ ['courseId' => $courseId],
+ ['courseId' => ParameterType::INTEGER]
+ )->fetchAssociative();
+
+ return \is_array($course) ? $course : null;
+ }
+
+ /**
+ * @return array>
+ */
+ private function buildRows(int $courseId, string $courseCode): array
+ {
+ $students = $this->getStudents($courseCode);
+ $categories = $this->getCategories($courseId);
+ $exercises = $this->getExercises($courseId);
+
+ $rows = [];
+ $header = [
+ $this->trans('Username'),
+ $this->trans('First name'),
+ $this->trans('Last name'),
+ $this->trans('E-mail'),
+ $this->trans('Official code'),
+ ];
+
+ foreach ($categories as $category) {
+ $title = (string) ($category['title'] ?? '');
+ $header[] = 'Aciertos: '.$title;
+ $header[] = 'Errores: '.$title;
+ $header[] = 'Omisiones: '.$title;
+ $header[] = 'Puntos: '.$title;
+ }
+
+ foreach ($exercises as $exercise) {
+ $header[] = (string) ($exercise['title'] ?? '');
+ }
+
+ $rows[] = $header;
+
+ foreach ($students as $student) {
+ if (!\is_array($student)) {
+ continue;
+ }
+
+ $studentId = (int) ($student['user_id'] ?? $student['id'] ?? 0);
+ if (0 >= $studentId) {
+ continue;
+ }
+
+ $row = [
+ (string) ($student['username'] ?? ''),
+ (string) ($student['firstname'] ?? ''),
+ (string) ($student['lastname'] ?? ''),
+ (string) ($student['email'] ?? ''),
+ (string) ($student['official_code'] ?? ''),
+ ];
+
+ $userExerciseData = [];
+ $categoryData = [];
+
+ foreach ($exercises as $exercise) {
+ $exerciseId = (int) ($exercise['iid'] ?? 0);
+ if (0 >= $exerciseId) {
+ continue;
+ }
+
+ $attempt = $this->getFirstCompletedAttempt($courseId, $studentId, $exerciseId);
+ if (null === $attempt) {
+ $userExerciseData[$exerciseId] = null;
+ continue;
+ }
+
+ $stats = $this->getAttemptStats($courseId, $exerciseId, (int) $attempt['exe_id']);
+ foreach ($categories as $category) {
+ $categoryId = (int) ($category['id'] ?? 0);
+ if (0 >= $categoryId) {
+ continue;
+ }
+
+ $categoryStats = $stats['category_list'][$categoryId] ?? null;
+ if (!\is_array($categoryStats)) {
+ continue;
+ }
+
+ if (!isset($categoryData[$categoryId])) {
+ $categoryData[$categoryId] = [
+ 'passed' => 0,
+ 'wrong' => 0,
+ 'no_answer' => 0,
+ 'score' => 0,
+ ];
+ }
+
+ $categoryData[$categoryId]['passed'] += (int) ($categoryStats['passed'] ?? 0);
+ $categoryData[$categoryId]['wrong'] += (int) ($categoryStats['wrong'] ?? 0);
+ $categoryData[$categoryId]['no_answer'] += (int) ($categoryStats['no_answer'] ?? 0);
+ $categoryData[$categoryId]['score'] += (float) ($categoryStats['score'] ?? 0);
+ }
+
+ $userExerciseData[$exerciseId] = $stats['total_score'] ?? null;
+ }
+
+ foreach ($categories as $category) {
+ $categoryId = (int) ($category['id'] ?? 0);
+ if (isset($categoryData[$categoryId])) {
+ $row[] = $categoryData[$categoryId]['passed'];
+ $row[] = $categoryData[$categoryId]['wrong'];
+ $row[] = $categoryData[$categoryId]['no_answer'];
+ $row[] = $this->formatNumber((float) $categoryData[$categoryId]['score']);
+ } else {
+ $row[] = null;
+ $row[] = null;
+ $row[] = null;
+ $row[] = null;
+ }
+ }
+
+ foreach ($exercises as $exercise) {
+ $exerciseId = (int) ($exercise['iid'] ?? 0);
+ $row[] = \array_key_exists($exerciseId, $userExerciseData) ? $userExerciseData[$exerciseId] : null;
+ }
+
+ $rows[] = $row;
+ }
+
+ return $rows;
+ }
+
+ /**
+ * @return array>
+ */
+ private function getStudents(string $courseCode): array
+ {
+ if (\class_exists('CourseManager')) {
+ $students = \CourseManager::get_student_list_from_course_code($courseCode);
+
+ return \is_array($students) ? $students : [];
+ }
+
+ return [];
+ }
+
+ /**
+ * @return array>
+ */
+ private function getCategories(int $courseId): array
+ {
+ return $this->connection->executeQuery(
+ 'SELECT id, title FROM c_quiz_category WHERE c_id = :courseId ORDER BY position ASC, id ASC',
+ ['courseId' => $courseId],
+ ['courseId' => ParameterType::INTEGER]
+ )->fetchAllAssociative();
+ }
+
+ /**
+ * @return array>
+ */
+ private function getExercises(int $courseId): array
+ {
+ $activeFilter = $this->tableHasColumn('c_quiz', 'active') ? 'AND q.active <> -1' : '';
+
+ $sql = <<connection->executeQuery(
+ $sql,
+ ['courseId' => $courseId],
+ ['courseId' => ParameterType::INTEGER]
+ )->fetchAllAssociative();
+ }
+
+ /**
+ * @return array|null
+ */
+ private function getFirstCompletedAttempt(int $courseId, int $studentId, int $exerciseId): ?array
+ {
+ $row = $this->connection->executeQuery(
+ "SELECT exe_id, data_tracking
+ FROM track_e_exercises
+ WHERE c_id = :courseId
+ AND exe_user_id = :studentId
+ AND exe_exo_id = :exerciseId
+ AND status = ''
+ ORDER BY exe_id ASC
+ LIMIT 1",
+ [
+ 'courseId' => $courseId,
+ 'studentId' => $studentId,
+ 'exerciseId' => $exerciseId,
+ ],
+ [
+ 'courseId' => ParameterType::INTEGER,
+ 'studentId' => ParameterType::INTEGER,
+ 'exerciseId' => ParameterType::INTEGER,
+ ]
+ )->fetchAssociative();
+
+ return \is_array($row) ? $row : null;
+ }
+
+ /**
+ * @return array
+ */
+ private function getAttemptStats(int $courseId, int $exerciseId, int $attemptId): array
+ {
+ if (!\class_exists('Exercise') || !\class_exists('ExerciseLib')) {
+ return ['category_list' => [], 'total_score' => null];
+ }
+
+ $exercise = new \Exercise($courseId);
+ if (!$exercise->read($exerciseId)) {
+ return ['category_list' => [], 'total_score' => null];
+ }
+
+ ob_start();
+ try {
+ $stats = \ExerciseLib::displayQuestionListByAttempt(
+ $exercise,
+ $attemptId,
+ false,
+ '',
+ false,
+ true,
+ true
+ );
+ } finally {
+ ob_end_clean();
+ }
+
+ return \is_array($stats) ? $stats : ['category_list' => [], 'total_score' => null];
+ }
+
+ private function requireLegacyExerciseClasses(): void
+ {
+ $legacyBase = \function_exists('api_get_path') && \defined('SYS_CODE_PATH')
+ ? rtrim((string) api_get_path(SYS_CODE_PATH), '/').'/'
+ : dirname(__DIR__, 4).'/public/main/';
+
+ $files = [
+ $legacyBase.'exercise/exercise.class.php',
+ $legacyBase.'exercise/exercise/TestCategory.php',
+ $legacyBase.'inc/lib/exercise.lib.php',
+ ];
+
+ foreach ($files as $file) {
+ if (is_file($file)) {
+ require_once $file;
+ }
+ }
+ }
+
+ private function tableHasColumn(string $table, string $column): bool
+ {
+ $rows = $this->connection
+ ->executeQuery(sprintf('SHOW COLUMNS FROM %s LIKE :column', $table), ['column' => $column])
+ ->fetchAllAssociative()
+ ;
+
+ return [] !== $rows;
+ }
+
+ private function trans(string $key): string
+ {
+ return \function_exists('get_lang') ? (string) get_lang($key) : $key;
+ }
+
+ private function formatNumber(float $value): string
+ {
+ $formatted = number_format($value, 2, '.', '');
+
+ return rtrim(rtrim($formatted, '0'), '.') ?: '0';
+ }
+
+ private function safeFilename(string $filename): string
+ {
+ $filename = preg_replace('/[^A-Za-z0-9_.-]+/', '_', $filename) ?: 'exercise_global_report';
+
+ return trim($filename, '._') ?: 'exercise_global_report';
+ }
+}
diff --git a/src/CoreBundle/Service/Exercise/ExerciseQti2ExportService.php b/src/CoreBundle/Service/Exercise/ExerciseQti2ExportService.php
new file mode 100644
index 00000000000..97754e0b4dd
--- /dev/null
+++ b/src/CoreBundle/Service/Exercise/ExerciseQti2ExportService.php
@@ -0,0 +1,449 @@
+getValidatedExercise($exerciseId, $request);
+ $xml = $this->buildExerciseXml($quiz);
+ $this->assertXmlCanBeParsed($xml);
+
+ $zipPath = $this->createZipFile();
+ $zip = new ZipArchive();
+ if (true !== $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE)) {
+ throw new BadRequestHttpException('The QTI2 export archive could not be created.');
+ }
+
+ $zip->addFromString(sprintf('qti2export_%d.xml', $exerciseId), $xml);
+ if (!$zip->close()) {
+ throw new BadRequestHttpException('The QTI2 export archive could not be finalized.');
+ }
+
+ $response = new BinaryFileResponse(new File($zipPath));
+ $response->headers->set('Content-Type', 'application/zip');
+ $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, sprintf('qti2_export_%d.zip', $exerciseId));
+ $response->deleteFileAfterSend(true);
+
+ return $response;
+ }
+
+ private function getValidatedExercise(int $exerciseId, Request $request): CQuiz
+ {
+ if (0 >= $exerciseId) {
+ throw new BadRequestHttpException('A valid exercise id is required.');
+ }
+
+ if (!$this->canExportExercises()) {
+ throw new AccessDeniedHttpException('You are not allowed to export this exercise.');
+ }
+
+ $course = $this->getCourse($request);
+ $session = $this->getSession($request);
+
+ return $this->getExerciseFromCurrentContext($exerciseId, $course, $session);
+ }
+
+ private function getCourse(Request $request): Course
+ {
+ $courseId = $request->query->getInt('cid');
+ if (0 >= $courseId) {
+ throw new BadRequestHttpException('A valid course id is required.');
+ }
+
+ $course = $this->entityManager->getRepository(Course::class)->find($courseId);
+ if (!$course instanceof Course) {
+ throw new BadRequestHttpException('The requested course was not found.');
+ }
+
+ return $course;
+ }
+
+ private function getSession(Request $request): ?Session
+ {
+ $sessionId = $request->query->getInt('sid');
+ if (0 >= $sessionId) {
+ return null;
+ }
+
+ $session = $this->entityManager->getRepository(Session::class)->find($sessionId);
+ if (!$session instanceof Session) {
+ throw new BadRequestHttpException('The requested session was not found.');
+ }
+
+ return $session;
+ }
+
+ private function getExerciseFromCurrentContext(int $exerciseId, Course $course, ?Session $session): CQuiz
+ {
+ $queryBuilder = $this->entityManager->createQueryBuilder()
+ ->select('quiz')
+ ->addSelect('links.visibility AS linkVisibility')
+ ->from(CQuiz::class, 'quiz')
+ ->innerJoin('quiz.resourceNode', 'node')
+ ->innerJoin('node.resourceLinks', 'links')
+ ->andWhere('quiz.iid = :exerciseId')
+ ->andWhere('IDENTITY(links.course) = :courseId')
+ ->andWhere('links.deletedAt IS NULL')
+ ->andWhere('links.endVisibilityAt IS NULL')
+ ->setParameter('exerciseId', $exerciseId, Types::INTEGER)
+ ->setParameter('courseId', (int) $course->getId(), Types::INTEGER)
+ ->setMaxResults(1)
+ ;
+
+ if (null !== $session) {
+ $queryBuilder
+ ->andWhere('IDENTITY(links.session) = :sessionId')
+ ->setParameter('sessionId', (int) $session->getId(), Types::INTEGER)
+ ;
+ } else {
+ $queryBuilder->andWhere('links.session IS NULL');
+ }
+
+ $row = $queryBuilder->getQuery()->getOneOrNullResult();
+ if (null === $row) {
+ throw new NotFoundHttpException('The requested exercise was not found in the current course context.');
+ }
+
+ $quiz = null;
+ $visibility = self::VISIBILITY_PUBLISHED;
+ if ($row instanceof CQuiz) {
+ $quiz = $row;
+ } elseif (\is_array($row)) {
+ $candidate = $row[0] ?? $row['quiz'] ?? null;
+ if ($candidate instanceof CQuiz) {
+ $quiz = $candidate;
+ }
+ $visibility = (int) ($row['linkVisibility'] ?? self::VISIBILITY_PUBLISHED);
+ }
+
+ if (!$quiz instanceof CQuiz) {
+ throw new NotFoundHttpException('The requested exercise was not found.');
+ }
+
+ if (0 !== $visibility && self::VISIBILITY_PUBLISHED !== $visibility && !$this->canExportExercises()) {
+ throw new AccessDeniedHttpException('The requested exercise is not visible.');
+ }
+
+ return $quiz;
+ }
+
+ private function buildExerciseXml(CQuiz $quiz): string
+ {
+ $exerciseId = (int) $quiz->getIid();
+ $xml = ''."\n";
+ $xml .= ''."\n";
+ $xml .= ''."\n";
+ $xml .= 'formatDescription($quiz->getTitle()).'"'."\n";
+ $xml .= ' >'."\n";
+ $xml .= $this->exportDuration($quiz);
+ $xml .= $this->exportPresentation($quiz);
+ $xml .= $this->exportOrdering($quiz);
+
+ foreach ($this->getExerciseQuestions($quiz) as $question) {
+ $xml .= $this->exportQuestion($question);
+ }
+
+ $xml .= ''."\n";
+ $xml .= ''."\n";
+
+ return $xml;
+ }
+
+ private function exportDuration(CQuiz $quiz): string
+ {
+ $duration = (int) ($quiz->getDuration() ?? 0);
+ if (0 >= $duration) {
+ return '';
+ }
+
+ $minutes = (int) floor($duration / 60);
+ $seconds = $duration % 60;
+
+ return 'PT'.$minutes.'M'.$seconds.'S'."\n";
+ }
+
+ private function exportPresentation(CQuiz $quiz): string
+ {
+ return "\n"
+ .' formatDescription((string) $quiz->getDescription())."]]>\n"
+ ."\n";
+ }
+
+ private function exportOrdering(CQuiz $quiz): string
+ {
+ $random = (int) $quiz->getRandom();
+ if (0 < $random) {
+ return ''
+ ." \n"
+ .' '.$random."\n"
+ ." \n"
+ .' '
+ ."\n\n";
+ }
+
+ return ''."\n"
+ ." \n"
+ ."\n";
+ }
+
+ /**
+ * @return array
+ */
+ private function getExerciseQuestions(CQuiz $quiz): array
+ {
+ $relations = $this->entityManager->createQueryBuilder()
+ ->select('relQuestion', 'question', 'answer')
+ ->from(CQuizRelQuestion::class, 'relQuestion')
+ ->innerJoin('relQuestion.question', 'question')
+ ->leftJoin('question.answers', 'answer')
+ ->andWhere('IDENTITY(relQuestion.quiz) = :exerciseId')
+ ->setParameter('exerciseId', (int) $quiz->getIid(), Types::INTEGER)
+ ->orderBy('relQuestion.questionOrder', 'ASC')
+ ->addOrderBy('question.position', 'ASC')
+ ->addOrderBy('answer.position', 'ASC')
+ ->getQuery()
+ ->getResult()
+ ;
+
+ $questions = [];
+ foreach ($relations as $relation) {
+ if (!$relation instanceof CQuizRelQuestion) {
+ continue;
+ }
+
+ $question = $relation->getQuestion();
+ if (!$this->isLegacyExportableQuestion($question)) {
+ continue;
+ }
+
+ $questionId = (int) $question->getIid();
+ if (0 < $questionId) {
+ $questions[$questionId] = $question;
+ }
+ }
+
+ return array_values($questions);
+ }
+
+ private function isLegacyExportableQuestion(CQuizQuestion $question): bool
+ {
+ return \in_array((int) $question->getType(), [self::UNIQUE_ANSWER, self::MULTIPLE_ANSWER, self::FREE_ANSWER], true);
+ }
+
+ private function exportQuestion(CQuizQuestion $question): string
+ {
+ $questionIdent = 'QST_'.(int) $question->getIid();
+ $categoryTitle = $this->getQuestionCategoryTitle($question);
+ $xml = 'escapeAttribute($this->formatText($question->getQuestion())).'"'."\n";
+ $xml .= ' category = "'.$this->escapeAttribute($this->formatText($categoryTitle)).'"'."\n";
+ $xml .= ' >'."\n";
+ $xml .= $this->exportQuestionResponseDeclaration($question, $questionIdent);
+ $xml .= " \n";
+ $xml .= $this->exportQuestionResponses($question, $questionIdent);
+ $xml .= " \n";
+ $xml .= ' '."\n";
+ $xml .= ''."\n";
+
+ return $xml;
+ }
+
+ private function exportQuestionResponseDeclaration(CQuizQuestion $question, string $questionIdent): string
+ {
+ if (self::FREE_ANSWER === (int) $question->getType()) {
+ $xml = ' ';
+ $xml .= '' .
+ ''.$this->formatScore((float) $question->getPonderation()).'';
+ $xml .= ' '."\n";
+
+ return $xml;
+ }
+
+ $answers = $this->getSortedAnswers($question);
+ $cardinality = self::MULTIPLE_ANSWER === (int) $question->getType() ? 'multiple' : 'single';
+ $xml = ' '."\n";
+ $xml .= " \n";
+ foreach ($answers as $answer) {
+ if ((int) ($answer->getCorrect() ?? 0) > 0) {
+ $xml .= ' answer_'.(int) $answer->getIid().''."\n";
+ }
+ }
+ $xml .= " \n";
+ $xml .= " \n";
+ foreach ($answers as $answer) {
+ $xml .= ' '."\n";
+ }
+ $xml .= " \n";
+ $xml .= ' '."\n";
+
+ return $xml;
+ }
+
+ private function exportQuestionResponses(CQuizQuestion $question, string $questionIdent): string
+ {
+ if (self::FREE_ANSWER === (int) $question->getType()) {
+ return '
+
+ '.$this->formatText((string) $question->getDescription()).'
+
+ ';
+ }
+
+ $xml = ' '."\n";
+ $xml .= ' escapeCdata($this->formatText($question->getQuestion())).']]>'."\n";
+ foreach ($this->getSortedAnswers($question) as $answer) {
+ $answerId = (int) $answer->getIid();
+ $xml .= '
+ escapeCdata($this->formatText($answer->getAnswer())).']]>';
+ $comment = (string) ($answer->getComment() ?? '');
+ if ('' !== $comment) {
+ $xml .= '
+ escapeCdata($this->formatText($comment)).']]>
+ ';
+ }
+ $xml .= ''."\n";
+ }
+ $xml .= ' '."\n";
+
+ return $xml;
+ }
+
+ /**
+ * @return array
+ */
+ private function getSortedAnswers(CQuizQuestion $question): array
+ {
+ $answers = [];
+ foreach ($question->getAnswers() as $answer) {
+ if ($answer instanceof CQuizAnswer) {
+ $answers[] = $answer;
+ }
+ }
+
+ usort(
+ $answers,
+ static fn (CQuizAnswer $left, CQuizAnswer $right): int => $left->getPosition() <=> $right->getPosition()
+ );
+
+ return $answers;
+ }
+
+ private function getQuestionCategoryTitle(CQuizQuestion $question): string
+ {
+ foreach ($question->getCategories() as $category) {
+ if ($category instanceof CQuizQuestionCategory) {
+ return $category->getTitle();
+ }
+ }
+
+ return '';
+ }
+
+ private function assertXmlCanBeParsed(string $xml): void
+ {
+ $document = new DOMDocument('1.0', 'UTF-8');
+ $previous = libxml_use_internal_errors(true);
+ try {
+ if (!$document->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING)) {
+ throw new BadRequestHttpException('The QTI2 export XML could not be generated.');
+ }
+ } finally {
+ libxml_clear_errors();
+ libxml_use_internal_errors($previous);
+ }
+ }
+
+ private function createZipFile(): string
+ {
+ if (!class_exists(ZipArchive::class)) {
+ throw new BadRequestHttpException('ZIP support is not available on this platform.');
+ }
+
+ $zipPath = tempnam(sys_get_temp_dir(), 'exercise-qti2-');
+ if (false === $zipPath) {
+ throw new BadRequestHttpException('The QTI2 export file could not be created.');
+ }
+
+ return $zipPath;
+ }
+
+ private function canExportExercises(): bool
+ {
+ return $this->security->isGranted('ROLE_CURRENT_COURSE_TEACHER')
+ || $this->security->isGranted('ROLE_CURRENT_COURSE_SESSION_TEACHER');
+ }
+
+ private function formatDescription(string $text): string
+ {
+ return htmlspecialchars(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+ }
+
+ private function formatText(string $text): string
+ {
+ return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+ }
+
+ private function escapeAttribute(string $text): string
+ {
+ return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+ }
+
+ private function escapeCdata(string $text): string
+ {
+ return str_replace(']]>', ']]]]>', $text);
+ }
+
+ private function formatScore(float $score): string
+ {
+ $formatted = rtrim(rtrim(sprintf('%.6F', $score), '0'), '.');
+
+ return '' === $formatted ? '0' : $formatted;
+ }
+}
diff --git a/src/CoreBundle/Service/Exercise/ExerciseQti2ImportService.php b/src/CoreBundle/Service/Exercise/ExerciseQti2ImportService.php
new file mode 100644
index 00000000000..91720794146
--- /dev/null
+++ b/src/CoreBundle/Service/Exercise/ExerciseQti2ImportService.php
@@ -0,0 +1,930 @@
+
+ */
+ private const SUPPORTED_TYPES = [
+ self::UNIQUE_ANSWER => 'Unique answer',
+ self::MULTIPLE_ANSWER => 'Multiple answer',
+ self::FREE_ANSWER => 'Free answer',
+ self::FILL_IN_BLANKS => 'Fill in blanks',
+ ];
+
+ public function __construct(
+ private EntityManagerInterface $entityManager,
+ private CQuizQuestionCategoryRepository $questionCategoryRepository,
+ ) {}
+
+ /**
+ * @return array{quiz: CQuiz, importedCount: int, skippedCount: int, errors: array}
+ */
+ public function import(UploadedFile $uploadedFile, Course $course, ?Session $session): array
+ {
+ if (!$uploadedFile->isValid()) {
+ throw new BadRequestHttpException('The uploaded file is not valid.');
+ }
+
+ $extension = strtolower((string) $uploadedFile->getClientOriginalExtension());
+ if ('zip' !== $extension) {
+ throw new BadRequestHttpException('You must upload a .zip file.');
+ }
+
+ if (!class_exists(ZipArchive::class)) {
+ throw new BadRequestHttpException('ZIP support is not available on this platform.');
+ }
+
+ $package = $this->readQti2Package($uploadedFile);
+ if ([] === $package['questions']) {
+ throw new BadRequestHttpException('No valid QTI2 questions were found in the uploaded file.');
+ }
+
+ $quiz = $this->createExercise(
+ '' !== $package['name'] ? $package['name'] : $this->buildExerciseTitle($uploadedFile),
+ $course,
+ $session,
+ (string) $package['description'],
+ 'Random' === $package['orderType'],
+ );
+
+ $result = $this->createQuestions($quiz, $package['questions'], $course, $session);
+ if (0 === $result['importedCount']) {
+ throw new BadRequestHttpException('No valid QTI2 questions were imported.');
+ }
+
+ $this->entityManager->flush();
+
+ return [
+ 'quiz' => $quiz,
+ 'importedCount' => $result['importedCount'],
+ 'skippedCount' => $result['skippedCount'],
+ 'errors' => $result['errors'],
+ ];
+ }
+
+ /**
+ * @return array{name: string, description: string, orderType: ?string, questions: array>}
+ */
+ private function readQti2Package(UploadedFile $uploadedFile): array
+ {
+ $zip = new ZipArchive();
+ if (true !== $zip->open($uploadedFile->getPathname())) {
+ throw new BadRequestHttpException('The uploaded ZIP file could not be opened.');
+ }
+
+ $package = [
+ 'name' => $this->buildExerciseTitle($uploadedFile),
+ 'description' => '',
+ 'orderType' => null,
+ 'questions' => [],
+ ];
+ $fileFound = false;
+
+ try {
+ for ($index = 0; $index < $zip->numFiles; ++$index) {
+ $name = (string) $zip->getNameIndex($index);
+ if ('' === $name || str_ends_with($name, '/')) {
+ continue;
+ }
+
+ $content = $zip->getFromIndex($index);
+ if (!is_string($content) || '' === trim($content)) {
+ continue;
+ }
+
+ if (!$this->isQtiQuestionBank($content)) {
+ continue;
+ }
+
+ $fileFound = true;
+ $this->parseQti2Xml($content, $package);
+ }
+ } finally {
+ $zip->close();
+ }
+
+ if (!$fileFound) {
+ throw new BadRequestHttpException('NoXMLFileFoundInTheZip');
+ }
+
+ return $package;
+ }
+
+ private function buildExerciseTitle(UploadedFile $uploadedFile): string
+ {
+ $baseName = $uploadedFile->getClientOriginalName();
+ $baseName = '' !== $baseName ? $baseName : 'qti2-import.zip';
+ $baseName = preg_replace('/\.zip$/i', '', $baseName) ?? $baseName;
+
+ return trim($baseName) ?: 'QTI2 import';
+ }
+
+ private function isQtiQuestionBank(string $content): bool
+ {
+ return 1 === preg_match('/ims_qtiasiv(\d)p(\d)/', $content);
+ }
+
+ /**
+ * @param array{name: string, description: string, orderType: ?string, questions: array>} $package
+ */
+ private function parseQti2Xml(string $content, array &$package): void
+ {
+ $content = $this->stripGivenTags($content, ['p', 'front']);
+ $version = [];
+ $matched = preg_match('/ims_qtiasiv(\d)p(\d)/', $content, $version);
+ $mainVersion = $matched ? (int) $version[1] : 2;
+ if (2 !== $mainVersion) {
+ throw new BadRequestHttpException('Unsupported IMS/QTI version.');
+ }
+
+ $document = new DOMDocument('1.0', 'UTF-8');
+ $previous = libxml_use_internal_errors(true);
+ try {
+ if (!$document->loadXML($content, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING)) {
+ throw new BadRequestHttpException('Error opening XML file');
+ }
+ } finally {
+ libxml_clear_errors();
+ libxml_use_internal_errors($previous);
+ }
+
+ $nodes = $document->getElementsByTagName('*');
+ $currentQuestionIdent = '';
+ $currentAnswerId = '';
+ $currentQuestionItemBody = '';
+ $cardinality = '';
+ $currentMatchSet = null;
+ $nonHtmlTagsToAvoid = [
+ 'prompt',
+ 'simpleChoice',
+ 'choiceInteraction',
+ 'inlineChoiceInteraction',
+ 'inlineChoice',
+ 'soMPLEMATCHSET',
+ 'simpleAssociableChoice',
+ 'textEntryInteraction',
+ 'feedbackInline',
+ 'matchInteraction',
+ 'extendedTextInteraction',
+ 'itemBody',
+ 'br',
+ 'img',
+ ];
+
+ foreach ($nodes as $node) {
+ if (!$node instanceof DOMElement) {
+ continue;
+ }
+
+ $nodeName = $this->localName($node);
+ switch ($nodeName) {
+ case 'assessmentItem':
+ $currentQuestionIdent = $node->getAttribute('identifier');
+ if ('' === $currentQuestionIdent) {
+ $currentQuestionIdent = 'question_'.(count($package['questions']) + 1);
+ }
+
+ $package['questions'][$currentQuestionIdent] = [
+ 'answer' => [],
+ 'correct_answers' => [],
+ 'title' => $node->getAttribute('title'),
+ 'category' => $node->getAttribute('category'),
+ 'type' => null,
+ 'subtype' => null,
+ 'description' => null,
+ 'response_text' => null,
+ 'fib_options' => [],
+ 'statement' => '',
+ ];
+ $currentMatchSet = null;
+ break;
+
+ case 'section':
+ $title = trim($node->getAttribute('title'));
+ if ('' !== $title) {
+ $package['name'] = $this->formatPlainText($title);
+ }
+ break;
+
+ case 'responseDeclaration':
+ if (!$this->hasCurrentQuestion($package, $currentQuestionIdent)) {
+ break;
+ }
+
+ $currentAnswerId = $node->getAttribute('identifier');
+ $cardinality = $node->getAttribute('cardinality');
+
+ if (str_starts_with($currentAnswerId, 'fill_')) {
+ $package['questions'][$currentQuestionIdent]['type'] = self::FILL_IN_BLANKS;
+ $package['questions'][$currentQuestionIdent]['subtype'] = 'TEXTFIELD_FILL';
+ break;
+ }
+
+ if ('multiple' === $cardinality) {
+ $package['questions'][$currentQuestionIdent]['type'] = self::MULTIPLE_ANSWER;
+ } elseif ('single' === $cardinality) {
+ $package['questions'][$currentQuestionIdent]['type'] = self::UNIQUE_ANSWER;
+ }
+ break;
+
+ case 'inlineChoiceInteraction':
+ if (!$this->hasCurrentQuestion($package, $currentQuestionIdent)) {
+ break;
+ }
+ $package['questions'][$currentQuestionIdent]['type'] = self::FILL_IN_BLANKS;
+ $package['questions'][$currentQuestionIdent]['subtype'] = 'LISTBOX_FILL';
+ $currentAnswerId = $node->getAttribute('responseIdentifier');
+ break;
+
+ case 'inlineChoice':
+ if (!$this->hasCurrentQuestion($package, $currentQuestionIdent)) {
+ break;
+ }
+
+ $parent = $node->parentNode;
+ if (!$parent instanceof DOMElement || 'inlineChoiceInteraction' !== $this->localName($parent)) {
+ break;
+ }
+
+ $responseId = $parent->getAttribute('responseIdentifier');
+ if ('' === $responseId) {
+ break;
+ }
+
+ $correctChoiceId = $package['questions'][$currentQuestionIdent]['correct_answers'][$responseId] ?? null;
+ $choiceId = $node->getAttribute('identifier');
+ $choiceText = trim($node->textContent);
+
+ if (!isset($package['questions'][$currentQuestionIdent]['fib_options'][$responseId])) {
+ $package['questions'][$currentQuestionIdent]['fib_options'][$responseId] = [
+ 'correct' => null,
+ 'wrongs' => [],
+ ];
+ }
+
+ if (null !== $correctChoiceId && $choiceId === $correctChoiceId) {
+ $package['questions'][$currentQuestionIdent]['fib_options'][$responseId]['correct'] = $choiceText;
+ } else {
+ $package['questions'][$currentQuestionIdent]['fib_options'][$responseId]['wrongs'][] = $choiceText;
+ }
+ break;
+
+ case 'textEntryInteraction':
+ if (!$this->hasCurrentQuestion($package, $currentQuestionIdent)) {
+ break;
+ }
+ $package['questions'][$currentQuestionIdent]['type'] = self::FILL_IN_BLANKS;
+ $package['questions'][$currentQuestionIdent]['subtype'] = 'TEXTFIELD_FILL';
+ $package['questions'][$currentQuestionIdent]['response_text'] = $currentQuestionItemBody;
+ break;
+
+ case 'matchInteraction':
+ if ($this->hasCurrentQuestion($package, $currentQuestionIdent)) {
+ // The legacy importer detected matching but did not save it in the supported list.
+ $package['questions'][$currentQuestionIdent]['type'] = 4;
+ }
+ break;
+
+ case 'extendedTextInteraction':
+ if (!$this->hasCurrentQuestion($package, $currentQuestionIdent)) {
+ break;
+ }
+ $package['questions'][$currentQuestionIdent]['type'] = self::FREE_ANSWER;
+ $package['questions'][$currentQuestionIdent]['description'] = trim($node->textContent);
+ break;
+
+ case 'simpleMatchSet':
+ if (!$this->hasCurrentQuestion($package, $currentQuestionIdent)) {
+ break;
+ }
+ $currentMatchSet = null === $currentMatchSet ? 1 : $currentMatchSet + 1;
+ $package['questions'][$currentQuestionIdent]['answer'][$currentMatchSet] = [];
+ break;
+
+ case 'simpleAssociableChoice':
+ if (!$this->hasCurrentQuestion($package, $currentQuestionIdent) || null === $currentMatchSet) {
+ break;
+ }
+ $choiceId = $node->getAttribute('identifier');
+ $package['questions'][$currentQuestionIdent]['answer'][$currentMatchSet][$choiceId] = trim($node->textContent);
+ break;
+
+ case 'simpleChoice':
+ if (!$this->hasCurrentQuestion($package, $currentQuestionIdent)) {
+ break;
+ }
+ $currentAnswerId = $node->getAttribute('identifier');
+ if ('' === $currentAnswerId) {
+ break;
+ }
+ $simpleChoiceValue = $this->extractSimpleChoiceText($node);
+ $package['questions'][$currentQuestionIdent]['answer'][$currentAnswerId]['value'] = ($package['questions'][$currentQuestionIdent]['answer'][$currentAnswerId]['value'] ?? '').$simpleChoiceValue;
+ break;
+
+ case 'mapEntry':
+ if (!$this->hasCurrentQuestion($package, $currentQuestionIdent)) {
+ break;
+ }
+ $parentName = $this->localName($node->parentNode);
+ if ('mapping' === $parentName || 'mapEntry' === $parentName) {
+ $answerId = $node->getAttribute('mapKey');
+ if ('' !== $answerId) {
+ $package['questions'][$currentQuestionIdent]['weighting'][$answerId] = $node->getAttribute('mappedValue');
+ }
+ }
+ break;
+
+ case 'mapping':
+ if (!$this->hasCurrentQuestion($package, $currentQuestionIdent)) {
+ break;
+ }
+ $defaultValue = $node->getAttribute('defaultValue');
+ if ('' !== $defaultValue) {
+ $package['questions'][$currentQuestionIdent]['default_weighting'] = $defaultValue;
+ }
+ break;
+
+ case 'itemBody':
+ if (!$this->hasCurrentQuestion($package, $currentQuestionIdent)) {
+ break;
+ }
+ $currentQuestionItemBody = $this->buildItemBodyText($node, $nonHtmlTagsToAvoid);
+ $questionType = (int) ($package['questions'][$currentQuestionIdent]['type'] ?? 0);
+
+ if (self::FILL_IN_BLANKS === $questionType) {
+ $candidate = (string) $currentQuestionItemBody;
+ $hasPlaceholders = false !== strpos($candidate, '**claroline_start**');
+ $hasRealContent = '' !== trim(strip_tags($candidate));
+ if ($hasPlaceholders || $hasRealContent) {
+ $package['questions'][$currentQuestionIdent]['response_text'] = $candidate;
+ }
+ } elseif (self::FREE_ANSWER === $questionType) {
+ $candidate = trim($currentQuestionItemBody);
+ if ('' !== $candidate) {
+ $package['questions'][$currentQuestionIdent]['description'] = $candidate;
+ }
+ } else {
+ $package['questions'][$currentQuestionIdent]['statement'] = $currentQuestionItemBody;
+ }
+ break;
+
+ case 'order':
+ $orderType = $node->getAttribute('order_type');
+ if ('' !== $orderType) {
+ $package['orderType'] = $orderType;
+ }
+ break;
+
+ case 'feedbackInline':
+ if (!$this->hasCurrentQuestion($package, $currentQuestionIdent) || '' === $currentAnswerId) {
+ break;
+ }
+ $package['questions'][$currentQuestionIdent]['answer'][$currentAnswerId]['feedback'] = ($package['questions'][$currentQuestionIdent]['answer'][$currentAnswerId]['feedback'] ?? '').trim($node->textContent);
+ break;
+
+ case 'value':
+ if (!$this->hasCurrentQuestion($package, $currentQuestionIdent)) {
+ break;
+ }
+ $parentName = $this->localName($node->parentNode);
+ if ('correctResponse' === $parentName) {
+ $nodeValue = trim($node->textContent);
+ if ('' !== $currentAnswerId && str_starts_with($currentAnswerId, 'fill_')) {
+ $package['questions'][$currentQuestionIdent]['type'] = self::FILL_IN_BLANKS;
+ $package['questions'][$currentQuestionIdent]['subtype'] = $package['questions'][$currentQuestionIdent]['subtype'] ?? 'TEXTFIELD_FILL';
+ $package['questions'][$currentQuestionIdent]['correct_answers'][$currentAnswerId] = $nodeValue;
+ if (empty($package['questions'][$currentQuestionIdent]['response_text'])) {
+ $package['questions'][$currentQuestionIdent]['response_text'] = $nodeValue;
+ }
+ } elseif ('single' === $cardinality) {
+ $package['questions'][$currentQuestionIdent]['correct_answers'][$nodeValue] = $nodeValue;
+ } else {
+ $package['questions'][$currentQuestionIdent]['correct_answers'][] = $nodeValue;
+ }
+ }
+
+ $grandParentName = $this->localName($node->parentNode?->parentNode);
+ if ('outcomeDeclaration' === $grandParentName) {
+ $nodeValue = trim($node->textContent);
+ if ('' !== $nodeValue) {
+ $package['questions'][$currentQuestionIdent]['weighting'][0] = $nodeValue;
+ }
+ }
+ break;
+
+ case 'mattext':
+ $nodeValue = trim($node->textContent);
+ if ('' !== $nodeValue) {
+ $package['description'] = $nodeValue;
+ }
+ break;
+
+ case 'prompt':
+ if ($this->hasCurrentQuestion($package, $currentQuestionIdent)) {
+ $description = $this->sanitizePrompt(trim($node->textContent));
+ if ('' !== $description) {
+ $package['questions'][$currentQuestionIdent]['description'] = $description;
+ }
+ }
+ break;
+ }
+ }
+
+ $this->postProcessFibQuestions($package['questions']);
+ }
+
+ /**
+ * @param array{name: string, description: string, orderType: ?string, questions: array>} $package
+ */
+ private function hasCurrentQuestion(array $package, string $currentQuestionIdent): bool
+ {
+ return '' !== $currentQuestionIdent && isset($package['questions'][$currentQuestionIdent]);
+ }
+
+ /**
+ * @param array $tags
+ */
+ private function stripGivenTags(string $content, array $tags): string
+ {
+ foreach ($tags as $tag) {
+ $content = preg_replace(sprintf('#?%s(?:\s[^>]*)?>#i', preg_quote($tag, '#')), '', $content) ?? $content;
+ }
+
+ return $content;
+ }
+
+ private function localName(?DOMNode $node): string
+ {
+ if (!$node instanceof DOMNode) {
+ return '';
+ }
+
+ return $node->localName ?: $node->nodeName;
+ }
+
+ private function extractSimpleChoiceText(DOMElement $node): string
+ {
+ $value = '';
+ foreach ($node->childNodes as $childNode) {
+ if ($childNode instanceof DOMElement && 'feedbackInline' === $this->localName($childNode)) {
+ continue;
+ }
+ $value .= $childNode->textContent;
+ }
+
+ return trim($value);
+ }
+
+ /**
+ * @param array $nonHtmlTagsToAvoid
+ */
+ private function buildItemBodyText(DOMElement $node, array $nonHtmlTagsToAvoid): string
+ {
+ $content = '';
+ foreach ($node->childNodes as $childNode) {
+ if ('#text' === $childNode->nodeName) {
+ continue;
+ }
+
+ $childName = $this->localName($childNode);
+ if (!$childNode instanceof DOMElement) {
+ continue;
+ }
+
+ if (!in_array($childName, $nonHtmlTagsToAvoid, true)) {
+ $content .= '<'.$childName;
+ foreach ($childNode->attributes ?? [] as $attribute) {
+ $content .= ' '.$attribute->nodeName.'="'.htmlspecialchars($attribute->nodeValue, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'"';
+ }
+ $content .= '>'.$childNode->textContent.''.$childName.'>';
+ continue;
+ }
+
+ if ('inlineChoiceInteraction' === $childName || 'textEntryInteraction' === $childName) {
+ $responseId = $childNode->getAttribute('responseIdentifier');
+ $content .= '**claroline_start**'.$responseId.'**claroline_end**';
+ continue;
+ }
+
+ if ('br' === $childName) {
+ $content .= ' ';
+ }
+ }
+
+ $firstChild = $node->firstChild;
+ if ($firstChild instanceof DOMNode && '#text' === $firstChild->nodeName) {
+ $firstText = trim($firstChild->nodeValue ?? '');
+ if ('' !== $firstText) {
+ $content .= $firstText;
+ }
+ }
+
+ return $content;
+ }
+
+ private function sanitizePrompt(string $text): string
+ {
+ $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ $text = preg_replace('#<\s*(script|style)[^>]*>.*?<\s*/\s*\1\s*>#is', '', $text) ?? $text;
+ $text = preg_replace('/\son[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $text) ?? $text;
+
+ return trim($text);
+ }
+
+ /**
+ * @param array> $questions
+ */
+ private function postProcessFibQuestions(array &$questions): void
+ {
+ foreach ($questions as &$question) {
+ if ((int) ($question['type'] ?? 0) !== self::FILL_IN_BLANKS) {
+ continue;
+ }
+
+ $text = (string) ($question['response_text'] ?? '');
+ if ('' === $text) {
+ continue;
+ }
+
+ $subtype = $question['subtype'] ?? null;
+ if ('LISTBOX_FILL' === $subtype) {
+ $options = is_array($question['fib_options'] ?? null) ? $question['fib_options'] : [];
+ foreach ($options as $responseId => $data) {
+ $correct = (string) ($data['correct'] ?? '');
+ $wrongs = is_array($data['wrongs'] ?? null) ? $data['wrongs'] : [];
+ $final = [];
+ if ('' !== $correct) {
+ $final[] = $correct;
+ }
+ foreach ($wrongs as $wrong) {
+ $wrong = trim((string) $wrong);
+ if ('' === $wrong || $wrong === $correct) {
+ continue;
+ }
+ $final[] = $wrong;
+ }
+ $text = str_replace('**claroline_start**'.$responseId.'**claroline_end**', '['.implode('|', $final).']', $text);
+ }
+ } else {
+ $corrects = is_array($question['correct_answers'] ?? null) ? $question['correct_answers'] : [];
+ foreach ($corrects as $responseId => $answerValue) {
+ if (!str_starts_with((string) $responseId, 'fill_')) {
+ continue;
+ }
+ $text = str_replace('**claroline_start**'.$responseId.'**claroline_end**', '['.trim((string) $answerValue).']', $text);
+ }
+ }
+
+ $question['response_text'] = preg_replace('/\*\*claroline_start\*\*fill_[^*]+\*\*claroline_end\*\*/', '[]', $text) ?? $text;
+ }
+ unset($question);
+ }
+
+ private function createExercise(string $title, Course $course, ?Session $session, string $description, bool $randomQuestions): CQuiz
+ {
+ $quiz = new CQuiz();
+ $quiz
+ ->setTitle($this->formatPlainText($title))
+ ->setDescription('' !== trim($description) ? $this->formatPlainText(strip_tags($description)) : '')
+ ->setType(CQuiz::ONE_PER_PAGE)
+ ->setRandom($randomQuestions ? -1 : 0)
+ ->setRandomAnswers(false)
+ ->setResultsDisabled(0)
+ ->setMaxAttempt(1)
+ ->setFeedbackType(0)
+ ->setExpiredTime(0)
+ ->setPropagateNeg(0)
+ ->setSaveCorrectAnswers(0)
+ ->setReviewAnswers(0)
+ ->setRandomByCategory(0)
+ ->setDisplayCategoryName(0)
+ ->setPassPercentage(0)
+ ->setPreventBackwards(0)
+ ->setHideQuestionTitle(false)
+ ->setHideQuestionNumber(0)
+ ->setShowPreviousButton(true)
+ ->setNotifications('')
+ ->setAutoLaunch(false)
+ ->setHideAttemptsTable(false)
+ ->setPageResultConfiguration([])
+ ->setParent($course)
+ ->addCourseLink($course, $session)
+ ;
+
+ if ($randomQuestions) {
+ $quiz->setQuestionSelectionType(2);
+ }
+
+ $this->entityManager->persist($quiz);
+
+ return $quiz;
+ }
+
+ /**
+ * @param array> $questions
+ *
+ * @return array{importedCount: int, skippedCount: int, errors: array}
+ */
+ private function createQuestions(CQuiz $quiz, array $questions, Course $course, ?Session $session): array
+ {
+ $importedCount = 0;
+ $skippedCount = 0;
+ $errors = [];
+ $questionOrder = 1;
+
+ foreach ($questions as $identifier => $questionData) {
+ $questionType = (int) ($questionData['type'] ?? 0);
+ if (!isset(self::SUPPORTED_TYPES[$questionType])) {
+ ++$skippedCount;
+ continue;
+ }
+
+ $title = $this->formatPlainText(strip_tags((string) ($questionData['title'] ?? '')));
+ if ('' === $title) {
+ ++$skippedCount;
+ $errors[] = sprintf('QTI question %s skipped because it has no title.', (string) $identifier);
+ continue;
+ }
+
+ $description = (string) ($questionData['description'] ?? '');
+ $question = $this->createQuestion($quiz, $course, $session, $title, $description, $questionType, 0.0, $questionOrder);
+ $category = $this->findOrCreateQuestionCategory((string) ($questionData['category'] ?? ''), $course, $session);
+ if ($category instanceof CQuizQuestionCategory) {
+ $question->updateCategory($category);
+ }
+
+ if (self::FILL_IN_BLANKS === $questionType) {
+ [$answerString, $totalWeight] = $this->buildFibAnswerString($questionData);
+ $question->setPonderation($totalWeight);
+ $this->createSingleAnswer($question, $answerString, 0, '', $totalWeight, 1);
+ } elseif (self::FREE_ANSWER === $questionType) {
+ $question->setPonderation($this->scoreFromValue($questionData['weighting'][0] ?? 0));
+ } else {
+ $this->createChoiceAnswers($question, $questionData);
+ }
+
+ ++$importedCount;
+ ++$questionOrder;
+ }
+
+ return [
+ 'importedCount' => $importedCount,
+ 'skippedCount' => $skippedCount,
+ 'errors' => $errors,
+ ];
+ }
+
+ private function createQuestion(
+ CQuiz $quiz,
+ Course $course,
+ ?Session $session,
+ string $title,
+ string $description,
+ int $type,
+ float $ponderation,
+ int $questionOrder,
+ ): CQuizQuestion {
+ $question = new CQuizQuestion();
+ $question
+ ->setQuestion($title)
+ ->setDescription($description)
+ ->setFeedback('')
+ ->setExtra(null)
+ ->setType($type)
+ ->setLevel(0)
+ ->setPosition($questionOrder)
+ ->setPonderation($ponderation)
+ ->setMandatory(0)
+ ->setDuration(null)
+ ->setParentMediaId(null)
+ ->setParent($course)
+ ->addCourseLink($course, $session)
+ ;
+ $this->entityManager->persist($question);
+
+ $relation = new CQuizRelQuestion();
+ $relation
+ ->setQuiz($quiz)
+ ->setQuestion($question)
+ ->setQuestionOrder($questionOrder)
+ ;
+ $this->entityManager->persist($relation);
+
+ return $question;
+ }
+
+ /**
+ * @param array $questionData
+ */
+ private function createChoiceAnswers(CQuizQuestion $question, array $questionData): void
+ {
+ $answerList = is_array($questionData['answer'] ?? null) ? $questionData['answer'] : [];
+ $correctAnswersRaw = is_array($questionData['correct_answers'] ?? null) ? $questionData['correct_answers'] : [];
+ $correctAnswerIds = array_values($correctAnswersRaw);
+ $defaultWeight = $this->scoreFromValue($questionData['default_weighting'] ?? 0);
+ $totalCorrectWeight = 0.0;
+ $position = 1;
+
+ foreach ($answerList as $key => $answerData) {
+ $answerData = is_array($answerData) ? $answerData : [];
+ $answerValue = $this->formatHtml((string) ($answerData['value'] ?? ''));
+ $answerFeedback = $this->formatHtml((string) ($answerData['feedback'] ?? ''));
+ $isCorrect = in_array((string) $key, array_map('strval', $correctAnswerIds), true);
+ $weight = $defaultWeight;
+ if (isset($questionData['weighting']) && is_array($questionData['weighting']) && array_key_exists((string) $key, $questionData['weighting'])) {
+ $weight = $this->scoreFromValue($questionData['weighting'][(string) $key]);
+ }
+
+ $this->createSingleAnswer($question, $answerValue, $isCorrect ? 1 : 0, $answerFeedback, $weight, $position);
+ if ($isCorrect) {
+ $totalCorrectWeight += $weight;
+ }
+ ++$position;
+ }
+
+ $question->setPonderation($totalCorrectWeight);
+ }
+
+ private function createSingleAnswer(CQuizQuestion $question, string $answerText, int $correct, string $comment, float $weight, int $position): void
+ {
+ $answer = new CQuizAnswer();
+ $answer
+ ->setQuestion($question)
+ ->setAnswer($answerText)
+ ->setCorrect($correct)
+ ->setComment($comment)
+ ->setPonderation($weight)
+ ->setPosition($position)
+ ;
+ $this->entityManager->persist($answer);
+ }
+
+ /**
+ * @param array $questionData
+ *
+ * @return array{0: string, 1: float}
+ */
+ private function buildFibAnswerString(array $questionData): array
+ {
+ $text = trim((string) ($questionData['response_text'] ?? ''));
+ $looksEmptyOrWrong = '' === $text
+ || (false === strpos($text, '[') && false === strpos($text, '::') && false === strpos($text, '**claroline_start**'));
+
+ if ($looksEmptyOrWrong && isset($questionData['correct_answers']) && is_array($questionData['correct_answers'])) {
+ foreach ($questionData['correct_answers'] as $key => $value) {
+ if (str_starts_with((string) $key, 'fill_')) {
+ $text = trim((string) $value);
+ break;
+ }
+ }
+ }
+
+ $text = str_replace("\xc2\xa0", ' ', $this->formatHtml($text));
+ $text = trim($text);
+
+ $existing = $this->parseExistingFibMeta($text);
+ if (null !== $existing) {
+ return $existing;
+ }
+
+ $text = str_replace('::', '', $text);
+ $text = str_replace("\xc2\xa0", ' ', $text);
+ $weightsMap = is_array($questionData['weighting'] ?? null) ? $questionData['weighting'] : [];
+ preg_match_all('/\[[^\]]*\]/', $text, $matches);
+ $weights = [];
+ $sizes = [];
+ $total = 0.0;
+
+ foreach ($matches[0] ?? [] as $blankRaw) {
+ $inside = trim((string) $blankRaw, '[]');
+ $correct = $inside;
+ if (false !== strpos($correct, '||')) {
+ $parts = explode('||', $correct);
+ $correct = $parts[0] ?? $correct;
+ } elseif (false !== strpos($correct, '|')) {
+ $parts = explode('|', $correct);
+ $correct = $parts[0] ?? $correct;
+ }
+ $correct = trim((string) $correct);
+ $weight = 1.0;
+ if ('' !== $correct && array_key_exists($correct, $weightsMap)) {
+ $weight = $this->scoreFromValue($weightsMap[$correct]);
+ }
+ $weights[] = $weight;
+ $sizes[] = 200;
+ $total += $weight;
+ }
+
+ if ([] !== $weights) {
+ $text .= '::'.implode(',', $weights).':'.implode(',', $sizes);
+ }
+
+ $text .= ':0@0';
+
+ return [$text, $total];
+ }
+
+ /**
+ * @return array{0: string, 1: float}|null
+ */
+ private function parseExistingFibMeta(string $text): ?array
+ {
+ $position = strrpos(trim($text), '::');
+ if (false === $position) {
+ return null;
+ }
+
+ $tail = substr($text, $position + 2);
+ if (1 === preg_match('/^([0-9]+(?:\.[0-9]+)?(?:,[0-9]+(?:\.[0-9]+)?)*)\:([0-9]+(?:,[0-9]+)*)\:(\d+)@(\d+)$/', $tail, $matches)) {
+ $weights = array_map('floatval', explode(',', $matches[1]));
+
+ return [$text, array_sum($weights)];
+ }
+
+ if (1 === preg_match('/^([0-9]+(?:\.[0-9]+)?(?:,[0-9]+(?:\.[0-9]+)?)*)\:([0-9]+(?:,[0-9]+)*)\:(\d+)@$/', $tail, $matches)) {
+ $weights = array_map('floatval', explode(',', $matches[1]));
+
+ return [$text.'0', array_sum($weights)];
+ }
+
+ return null;
+ }
+
+ private function scoreFromValue(mixed $value): float
+ {
+ $value = str_replace(',', '.', trim((string) $value));
+ if ('' === $value || !is_numeric($value)) {
+ return 0.0;
+ }
+
+ return (float) $value;
+ }
+
+ private function formatPlainText(string $text): string
+ {
+ return trim(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
+ }
+
+ private function formatHtml(string $text): string
+ {
+ return html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ }
+
+ private function findOrCreateQuestionCategory(string $categoryTitle, Course $course, ?Session $session): ?CQuizQuestionCategory
+ {
+ $categoryTitle = $this->formatPlainText(strip_tags($categoryTitle));
+ if ('' === $categoryTitle) {
+ return null;
+ }
+
+ $existingCategory = $this->questionCategoryRepository->findCourseResourceByTitle($categoryTitle, $course->getResourceNode(), $course);
+ if ($existingCategory instanceof CQuizQuestionCategory) {
+ return $existingCategory;
+ }
+
+ $category = new CQuizQuestionCategory();
+ $category
+ ->setTitle($categoryTitle)
+ ->setDescription('')
+ ->setParent($course)
+ ->addCourseLink($course, $session)
+ ;
+ $this->questionCategoryRepository->create($category);
+
+ return $category;
+ }
+}
diff --git a/src/CoreBundle/Service/Exercise/ExerciseQuestionReportPdfService.php b/src/CoreBundle/Service/Exercise/ExerciseQuestionReportPdfService.php
new file mode 100644
index 00000000000..91d7c4143b1
--- /dev/null
+++ b/src/CoreBundle/Service/Exercise/ExerciseQuestionReportPdfService.php
@@ -0,0 +1,227 @@
+= $exerciseId) {
+ throw new BadRequestHttpException('A valid exercise id is required.');
+ }
+
+ $report = $this->reportByQuestionProvider->provide(new Get(), ['exerciseId' => $exerciseId]);
+ if (!$report instanceof ExerciseReportByQuestion) {
+ throw new NotFoundHttpException('The requested report by question was not found.');
+ }
+
+ return $this->createPdfResponse(
+ $this->renderReportByQuestionHtml($report),
+ sprintf('exercise_%d_report_by_question.pdf', $exerciseId),
+ $this->text($report->title) ?: 'Report by question'
+ );
+ }
+
+ public function exportQuestionStatsPdf(int $exerciseId): Response
+ {
+ if (0 >= $exerciseId) {
+ throw new BadRequestHttpException('A valid exercise id is required.');
+ }
+
+ $stats = $this->questionStatsProvider->provide(new Get(), ['exerciseId' => $exerciseId]);
+ if (!$stats instanceof ExerciseQuestionStats) {
+ throw new NotFoundHttpException('The requested question statistics were not found.');
+ }
+
+ return $this->createPdfResponse(
+ $this->renderQuestionStatsHtml($stats),
+ sprintf('exercise_%d_question_statistics.pdf', $exerciseId),
+ $this->text($stats->title) ?: 'Question statistics'
+ );
+ }
+
+ private function createPdfResponse(string $html, string $fileName, string $title): Response
+ {
+ $pdf = new Mpdf([
+ 'format' => 'A4',
+ 'mode' => 'utf-8',
+ 'tempDir' => sys_get_temp_dir(),
+ ]);
+ $pdf->SetTitle($title);
+ $pdf->WriteHTML($html);
+
+ $response = new Response($pdf->Output('', 'S'));
+ $response->headers->set('Content-Type', 'application/pdf');
+ $response->headers->set(
+ 'Content-Disposition',
+ $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $fileName)
+ );
+
+ return $response;
+ }
+
+ private function renderReportByQuestionHtml(ExerciseReportByQuestion $report): string
+ {
+ $summary = $report->summary;
+ $html = '';
+ $html .= ''.$this->escape($report->title ?: 'Report by question').'';
+ if ('' !== $this->text($report->description)) {
+ $html .= ''.$this->escape($report->description).' ';
+ }
+
+ $html .= '';
+ $html .= $this->summaryRow('Questions', (string) ($summary['totalQuestions'] ?? 0));
+ $html .= $this->summaryRow('Configured answers', (string) ($summary['totalAnswers'] ?? 0));
+ $html .= $this->summaryRow('Selections', (string) ($summary['totalSelections'] ?? 0));
+ $html .= ' ';
+
+ foreach ($report->questions as $question) {
+ $html .= '';
+ $html .= ' '.$this->escape($this->text($question['title'] ?? 'Question')).'';
+ $html .= ' #'.(int) ($question['questionId'] ?? 0).' · '.$this->escape($this->text($question['typeLabel'] ?? 'Question')).' · Score: '.$this->formatNumber($question['maxScore'] ?? 0).' ';
+
+ if (!empty($question['usesSpecialCounting']) && empty($question['countingAvailable'])) {
+ $html .= ' Detailed answer counting for this question type is not available in the migrated report yet. ';
+ }
+
+ $html .= ' ';
+ $html .= '| Answer | Correct | Selected | Selection % | ';
+ $html .= ' ';
+
+ foreach (($question['answers'] ?? []) as $answer) {
+ $selectedCount = $answer['selectedCount'] ?? null;
+ $selectedPercentage = $answer['selectedPercentage'] ?? null;
+ $html .= '';
+ $html .= ''.$this->escape($this->text($answer['answer'] ?? '-')).' #'.$this->escape($answer['answerId'] ?? 0).' · Score: '.$this->formatNumber($answer['score'] ?? 0).' | ';
+ $html .= ''.(!empty($question['usesSpecialCounting']) ? '—' : (!empty($answer['correct']) ? 'Yes' : 'No')).' | ';
+ $html .= ''.(null === $selectedCount ? '—' : (string) (int) $selectedCount).' | ';
+ $html .= ''.(null === $selectedCount ? '—' : $this->formatNumber($selectedPercentage).' %').' | ';
+ $html .= ' ';
+ }
+
+ if ([] === ($question['answers'] ?? [])) {
+ $html .= '| No answer distribution found | ';
+ }
+
+ $html .= '
';
+ $html .= ' ';
+ }
+
+ if ([] === $report->questions) {
+ $html .= 'No report by question data found. ';
+ }
+
+ $html .= '';
+
+ return $html;
+ }
+
+ private function renderQuestionStatsHtml(ExerciseQuestionStats $stats): string
+ {
+ $summary = $stats->summary;
+ $html = '';
+ $html .= ''.$this->escape($stats->title ?: 'Question statistics').'';
+ if ('' !== $this->text($stats->description)) {
+ $html .= ''.$this->escape($stats->description).' ';
+ }
+
+ $html .= '';
+ $html .= $this->summaryRow('Questions', (string) ($summary['totalQuestions'] ?? 0));
+ $html .= $this->summaryRow('Answered', (string) ($summary['totalAnswered'] ?? 0));
+ $html .= $this->summaryRow('Wrong answers', (string) ($summary['totalWrong'] ?? 0));
+ $html .= $this->summaryRow('Wrong %', $this->formatNumber($summary['wrongPercentage'] ?? 0).' %');
+ $html .= ' ';
+
+ $html .= '';
+ $html .= '| Question | Question type | Answered | Lowest | Average | Highest | Score | Wrong / Total | % | ';
+ $html .= ' ';
+
+ foreach ($stats->questions as $question) {
+ $html .= '';
+ $html .= ''.$this->escape($this->text($question['title'] ?? '-')).' #'.(int) ($question['questionId'] ?? 0).' | ';
+ $html .= ''.$this->escape($this->text($question['typeLabel'] ?? 'Question')).' | ';
+ $html .= ''.(int) ($question['answeredAttempts'] ?? 0).' | ';
+ $html .= ''.$this->formatNumber($question['lowestScore'] ?? 0).' | ';
+ $html .= ''.$this->formatNumber($question['averageScore'] ?? 0).' | ';
+ $html .= ''.$this->formatNumber($question['highestScore'] ?? 0).' | ';
+ $html .= ''.$this->formatNumber($question['maxScore'] ?? 0).' | ';
+ $html .= ''.(int) ($question['wrongAttempts'] ?? 0).' / '.(int) ($question['answeredAttempts'] ?? 0).' | ';
+ $html .= ''.$this->formatNumber($question['wrongPercentage'] ?? 0).' % | ';
+ $html .= ' ';
+ }
+
+ if ([] === $stats->questions) {
+ $html .= '| No question statistics found. | ';
+ }
+
+ $html .= ' ';
+ $html .= '';
+
+ return $html;
+ }
+
+ private function summaryRow(string $label, string $value): string
+ {
+ return '| '.$this->escape($label).' | '.$this->escape($value).' | ';
+ }
+
+ private function formatNumber(mixed $value): string
+ {
+ if (null === $value || '' === $value) {
+ return '—';
+ }
+
+ $number = (float) $value;
+ $formatted = number_format($number, 2, '.', '');
+
+ return rtrim(rtrim($formatted, '0'), '.') ?: '0';
+ }
+
+ private function text(mixed $value): string
+ {
+ return trim(strip_tags(html_entity_decode((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8')));
+ }
+
+ private function escape(mixed $value): string
+ {
+ return htmlspecialchars($this->text($value), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+ }
+
+ private function getCss(): string
+ {
+ return <<<'CSS'
+body { font-family: DejaVu Sans, sans-serif; color: #222; font-size: 11px; line-height: 1.4; }
+h1 { color: #1f3763; font-size: 20px; margin: 0 0 12px; }
+h2 { color: #1f3763; font-size: 14px; margin: 0 0 4px; }
+.description { border-left: 3px solid #2b7bb9; padding: 8px 10px; background: #f5f8fb; margin-bottom: 12px; }
+.summary { width: 100%; margin-bottom: 16px; border-collapse: collapse; }
+.summary th { width: 30%; text-align: left; background: #eef3f8; }
+table { width: 100%; border-collapse: collapse; margin: 8px 0 16px; }
+th, td { border: 1px solid #d7dee8; padding: 6px; vertical-align: top; }
+th { background: #eef3f8; color: #1f3763; font-weight: bold; }
+.question { page-break-inside: avoid; margin-bottom: 14px; padding-bottom: 4px; }
+.muted { color: #6b7280; font-size: 10px; }
+.notice { border: 1px solid #b7d7f0; background: #eef8ff; color: #1f5f93; padding: 8px; margin: 8px 0; }
+CSS;
+ }
+}
diff --git a/src/CoreBundle/Service/Exercise/ExerciseRuntimeAllAttemptsExportService.php b/src/CoreBundle/Service/Exercise/ExerciseRuntimeAllAttemptsExportService.php
new file mode 100644
index 00000000000..3359e732707
--- /dev/null
+++ b/src/CoreBundle/Service/Exercise/ExerciseRuntimeAllAttemptsExportService.php
@@ -0,0 +1,307 @@
+getValidatedExercise($exerciseId, $request);
+ $course = $this->getCourse($request);
+ $session = $this->getSession($request);
+ $attempts = $this->getAttempts($quiz, $course, $session, $request);
+ $zipPath = $this->createZipFile();
+
+ $zip = new ZipArchive();
+ if (true !== $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE)) {
+ throw new BadRequestHttpException('The attempts export archive could not be created.');
+ }
+
+ if ([] === $attempts) {
+ $zip->addFromString('README.txt', 'No attempts found for the current filters.');
+ }
+
+ $usedNames = [];
+ foreach ($attempts as $attempt) {
+ $pdfFile = $this->attemptPdfService->buildAttemptPdfFile($exerciseId, (int) $attempt->getExeId(), $request);
+ $fileName = $this->buildAttemptFileName($pdfFile['fileName'], $attempt, $usedNames);
+ $zip->addFromString($fileName, $pdfFile['content']);
+ }
+
+ if (!$zip->close()) {
+ throw new BadRequestHttpException('The attempts export archive could not be finalized.');
+ }
+
+ $response = new BinaryFileResponse(new File($zipPath));
+ $response->headers->set('Content-Type', 'application/zip');
+ $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $this->buildArchiveFileName($quiz));
+ $response->deleteFileAfterSend(true);
+
+ return $response;
+ }
+
+ private function getValidatedExercise(int $exerciseId, Request $request): CQuiz
+ {
+ if (0 >= $exerciseId) {
+ throw new BadRequestHttpException('A valid exercise id is required.');
+ }
+
+ if (!$this->canExportAttempts()) {
+ throw new AccessDeniedHttpException('You are not allowed to export attempts for this exercise.');
+ }
+
+ $course = $this->getCourse($request);
+ $session = $this->getSession($request);
+
+ return $this->getExerciseFromCurrentContext($exerciseId, $course, $session);
+ }
+
+ private function getCourse(Request $request): Course
+ {
+ $courseId = $request->query->getInt('cid');
+ if (0 >= $courseId) {
+ throw new BadRequestHttpException('A valid course id is required.');
+ }
+
+ $course = $this->entityManager->getRepository(Course::class)->find($courseId);
+ if (!$course instanceof Course) {
+ throw new BadRequestHttpException('The requested course was not found.');
+ }
+
+ return $course;
+ }
+
+ private function getSession(Request $request): ?Session
+ {
+ $sessionId = $request->query->getInt('sid');
+ if (0 >= $sessionId) {
+ return null;
+ }
+
+ $session = $this->entityManager->getRepository(Session::class)->find($sessionId);
+ if (!$session instanceof Session) {
+ throw new BadRequestHttpException('The requested session was not found.');
+ }
+
+ return $session;
+ }
+
+ private function getExerciseFromCurrentContext(int $exerciseId, Course $course, ?Session $session): CQuiz
+ {
+ $quiz = $this->quizRepository->find($exerciseId);
+ if (!$quiz instanceof CQuiz) {
+ throw new NotFoundHttpException('The requested exercise was not found.');
+ }
+
+ $queryBuilder = $this->entityManager->createQueryBuilder()
+ ->select('quiz.iid')
+ ->addSelect('links.visibility AS linkVisibility')
+ ->from(CQuiz::class, 'quiz')
+ ->innerJoin('quiz.resourceNode', 'node')
+ ->innerJoin('node.resourceLinks', 'links')
+ ->andWhere('quiz.iid = :exerciseId')
+ ->andWhere('IDENTITY(links.course) = :courseId')
+ ->andWhere('links.deletedAt IS NULL')
+ ->andWhere('links.endVisibilityAt IS NULL')
+ ->setParameter('exerciseId', $exerciseId, Types::INTEGER)
+ ->setParameter('courseId', (int) $course->getId(), Types::INTEGER)
+ ->setMaxResults(1)
+ ;
+
+ if (null !== $session) {
+ $queryBuilder
+ ->andWhere('IDENTITY(links.session) = :sessionId')
+ ->setParameter('sessionId', (int) $session->getId(), Types::INTEGER)
+ ;
+ } else {
+ $queryBuilder->andWhere('links.session IS NULL');
+ }
+
+ $row = $queryBuilder->getQuery()->getOneOrNullResult();
+ if (null === $row) {
+ throw new AccessDeniedHttpException('The requested exercise does not belong to the current course context.');
+ }
+
+ $visibility = \is_array($row) ? (int) ($row['linkVisibility'] ?? 0) : 0;
+ if (0 !== $visibility && self::VISIBILITY_PUBLISHED !== $visibility && !$this->canExportAttempts()) {
+ throw new AccessDeniedHttpException('The requested exercise is not visible.');
+ }
+
+ return $quiz;
+ }
+
+ /**
+ * @return array
+ */
+ private function getAttempts(CQuiz $quiz, Course $course, ?Session $session, Request $request): array
+ {
+ $queryBuilder = $this->entityManager->createQueryBuilder()
+ ->select('attempt', 'user')
+ ->from(TrackEExercise::class, 'attempt')
+ ->innerJoin('attempt.user', 'user')
+ ->andWhere('IDENTITY(attempt.quiz) = :exerciseId')
+ ->andWhere('IDENTITY(attempt.course) = :courseId')
+ ->setParameter('exerciseId', (int) $quiz->getIid(), Types::INTEGER)
+ ->setParameter('courseId', (int) $course->getId(), Types::INTEGER)
+ ->orderBy('attempt.exeDate', 'DESC')
+ ->addOrderBy('attempt.exeId', 'DESC')
+ ;
+
+ if (null !== $session) {
+ $queryBuilder
+ ->andWhere('IDENTITY(attempt.session) = :sessionId')
+ ->setParameter('sessionId', (int) $session->getId(), Types::INTEGER)
+ ;
+ } else {
+ $queryBuilder->andWhere('attempt.session IS NULL');
+ }
+
+ $firstName = trim((string) $request->query->get('firstName', ''));
+ if ('' !== $firstName) {
+ $queryBuilder
+ ->andWhere('LOWER(user.firstname) LIKE :firstName')
+ ->setParameter('firstName', '%'.mb_strtolower($firstName).'%', Types::STRING)
+ ;
+ }
+
+ $lastName = trim((string) $request->query->get('lastName', ''));
+ if ('' !== $lastName) {
+ $queryBuilder
+ ->andWhere('LOWER(user.lastname) LIKE :lastName')
+ ->setParameter('lastName', '%'.mb_strtolower($lastName).'%', Types::STRING)
+ ;
+ }
+
+ $status = trim((string) $request->query->get('status', ''));
+ if (self::STATUS_PENDING_CORRECTION === $status) {
+ $queryBuilder->andWhere("attempt.questionsToCheck <> ''");
+ } elseif (self::STATUS_INCOMPLETE === $status) {
+ $queryBuilder
+ ->andWhere('attempt.status = :status')
+ ->setParameter('status', self::STATUS_INCOMPLETE, Types::STRING)
+ ;
+ } elseif (self::STATUS_COMPLETED === $status) {
+ $queryBuilder
+ ->andWhere('attempt.status = :status')
+ ->andWhere("attempt.questionsToCheck = ''")
+ ->setParameter('status', self::STATUS_COMPLETED, Types::STRING)
+ ;
+ }
+
+ $attempts = [];
+ foreach ($queryBuilder->getQuery()->getResult() as $attempt) {
+ if ($attempt instanceof TrackEExercise) {
+ $attempts[] = $attempt;
+ }
+ }
+
+ return $attempts;
+ }
+
+ private function canExportAttempts(): bool
+ {
+ return $this->security->isGranted('ROLE_CURRENT_COURSE_TEACHER')
+ || $this->security->isGranted('ROLE_CURRENT_COURSE_SESSION_TEACHER');
+ }
+
+ private function createZipFile(): string
+ {
+ $filePath = tempnam(sys_get_temp_dir(), 'exercise-attempts-');
+ if (false === $filePath) {
+ throw new BadRequestHttpException('The attempts export archive could not be created.');
+ }
+
+ $zipPath = $filePath.'.zip';
+ if (!rename($filePath, $zipPath)) {
+ throw new BadRequestHttpException('The attempts export archive could not be prepared.');
+ }
+
+ return $zipPath;
+ }
+
+ /**
+ * @param array $usedNames
+ */
+ private function buildAttemptFileName(string $baseFileName, TrackEExercise $attempt, array &$usedNames): string
+ {
+ $user = $attempt->getUser();
+ $learnerName = $this->safeFileSegment(trim($user->getLastname().'-'.$user->getFirstname()));
+ if ('' === $learnerName) {
+ $learnerName = $this->safeFileSegment($user->getUsername());
+ }
+
+ $baseName = $this->safeFileSegment(pathinfo($baseFileName, PATHINFO_FILENAME));
+ if ('' === $baseName) {
+ $baseName = 'attempt-'.(int) $attempt->getExeId();
+ }
+
+ $fileName = $learnerName.'-'.$baseName.'.pdf';
+ $fileName = strtolower(trim($fileName, '-'));
+ if (!isset($usedNames[$fileName])) {
+ $usedNames[$fileName] = true;
+
+ return $fileName;
+ }
+
+ $counter = 2;
+ do {
+ $candidate = preg_replace('/\.pdf$/', '-'.$counter.'.pdf', $fileName) ?: $fileName;
+ ++$counter;
+ } while (isset($usedNames[$candidate]));
+
+ $usedNames[$candidate] = true;
+
+ return $candidate;
+ }
+
+ private function buildArchiveFileName(CQuiz $quiz): string
+ {
+ $safeTitle = $this->safeFileSegment($quiz->getTitle());
+ if ('' === $safeTitle) {
+ $safeTitle = 'exercise';
+ }
+
+ return strtolower($safeTitle).'-attempts.zip';
+ }
+
+ private function safeFileSegment(string $value): string
+ {
+ $segment = preg_replace('/[^A-Za-z0-9_-]+/', '-', $value) ?: '';
+ $segment = trim($segment, '-');
+
+ return substr($segment, 0, 80);
+ }
+}
diff --git a/src/CoreBundle/Service/Exercise/ExerciseRuntimeAttemptPdfService.php b/src/CoreBundle/Service/Exercise/ExerciseRuntimeAttemptPdfService.php
new file mode 100644
index 00000000000..7eda314c7ea
--- /dev/null
+++ b/src/CoreBundle/Service/Exercise/ExerciseRuntimeAttemptPdfService.php
@@ -0,0 +1,672 @@
+buildAttemptPdfFile($exerciseId, $attemptId, $request);
+
+ $response = new Response($pdfFile['content']);
+ $response->headers->set('Content-Type', 'application/pdf');
+ $response->headers->set(
+ 'Content-Disposition',
+ $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $pdfFile['fileName'])
+ );
+
+ return $response;
+ }
+
+ /**
+ * @return array{fileName: string, content: string}
+ */
+ public function buildAttemptPdfFile(int $exerciseId, int $attemptId, Request $request): array
+ {
+ if (0 >= $exerciseId || 0 >= $attemptId) {
+ throw new BadRequestHttpException('A valid exercise and attempt are required.');
+ }
+
+ $result = $this->resultProvider->provide(new Get(), [
+ 'exerciseId' => $exerciseId,
+ 'attemptId' => $attemptId,
+ ]);
+ if (!$result instanceof ExerciseRuntimeResult) {
+ throw new NotFoundHttpException('The requested attempt result was not found.');
+ }
+
+ $attempt = $this->getAttempt($exerciseId, $attemptId, $request);
+ $html = $this->renderHtml($result, $attempt);
+
+ $pdf = new Mpdf([
+ 'format' => 'A4',
+ 'mode' => 'utf-8',
+ 'tempDir' => sys_get_temp_dir(),
+ ]);
+ $pdf->SetTitle($this->text($result->title));
+ $pdf->WriteHTML($html);
+
+ return [
+ 'fileName' => $this->buildFileName($result, $attempt),
+ 'content' => $pdf->Output('', 'S'),
+ ];
+ }
+
+ private function getAttempt(int $exerciseId, int $attemptId, Request $request): TrackEExercise
+ {
+ $course = $this->getCourse($request);
+ $session = $this->getSession($request);
+
+ $queryBuilder = $this->entityManager->createQueryBuilder()
+ ->select('attempt', 'user')
+ ->from(TrackEExercise::class, 'attempt')
+ ->innerJoin('attempt.user', 'user')
+ ->andWhere('attempt.exeId = :attemptId')
+ ->andWhere('IDENTITY(attempt.quiz) = :exerciseId')
+ ->andWhere('IDENTITY(attempt.course) = :courseId')
+ ->setParameter('attemptId', $attemptId, Types::INTEGER)
+ ->setParameter('exerciseId', $exerciseId, Types::INTEGER)
+ ->setParameter('courseId', (int) $course->getId(), Types::INTEGER)
+ ->setMaxResults(1)
+ ;
+
+ if (null !== $session) {
+ $queryBuilder
+ ->andWhere('IDENTITY(attempt.session) = :sessionId')
+ ->setParameter('sessionId', (int) $session->getId(), Types::INTEGER)
+ ;
+ } else {
+ $queryBuilder->andWhere('attempt.session IS NULL');
+ }
+
+ $attempt = $queryBuilder->getQuery()->getOneOrNullResult();
+ if (!$attempt instanceof TrackEExercise) {
+ throw new NotFoundHttpException('The requested attempt was not found.');
+ }
+
+ return $attempt;
+ }
+
+ private function getCourse(Request $request): Course
+ {
+ $courseId = $request->query->getInt('cid');
+ if (0 >= $courseId) {
+ throw new BadRequestHttpException('A valid course id is required.');
+ }
+
+ $course = $this->entityManager->getRepository(Course::class)->find($courseId);
+ if (!$course instanceof Course) {
+ throw new BadRequestHttpException('The requested course was not found.');
+ }
+
+ return $course;
+ }
+
+ private function getSession(Request $request): ?Session
+ {
+ $sessionId = $request->query->getInt('sid');
+ if (0 >= $sessionId) {
+ return null;
+ }
+
+ $session = $this->entityManager->getRepository(Session::class)->find($sessionId);
+ if (!$session instanceof Session) {
+ throw new BadRequestHttpException('The requested session was not found.');
+ }
+
+ return $session;
+ }
+
+ private function renderHtml(ExerciseRuntimeResult $result, TrackEExercise $attempt): string
+ {
+ $attemptData = $result->attempt;
+ $visibility = $result->visibility;
+ $user = $attempt->getUser();
+ $course = $attempt->getCourse();
+ $session = $attempt->getSession();
+
+ $score = $this->formatScore($attemptData);
+ $percentage = isset($attemptData['percentage']) && null !== $attemptData['percentage']
+ ? $this->formatNumber((float) $attemptData['percentage']).'%'
+ : 'Hidden';
+
+ $html = '';
+ $html .= ''.$this->escape($result->title).'';
+ if ('' !== $this->text($result->description)) {
+ $html .= ''.$this->escape($result->description).' ';
+ }
+
+ $html .= '';
+ $html .= $this->summaryRow('Learner', trim($user->getFirstname().' '.$user->getLastname()));
+ $html .= $this->summaryRow('Username', $user->getUsername());
+ $html .= $this->summaryRow('Course', $course->getTitle());
+ if (null !== $session) {
+ $html .= $this->summaryRow('Session', '#'.(int) $session->getId());
+ }
+ $html .= $this->summaryRow('Attempt', '#'.(int) $attempt->getExeId());
+ $html .= $this->summaryRow('Status', (string) ($attemptData['status'] ?? $attempt->getStatus()));
+ $html .= $this->summaryRow('Started at', $this->formatDateValue($attemptData['startedAt'] ?? null));
+ $html .= $this->summaryRow('Completed at', $this->formatDateValue($attemptData['completedAt'] ?? null));
+ $html .= $this->summaryRow('Duration', $this->formatDuration((int) ($attemptData['duration'] ?? $attempt->getExeDuration())));
+ $html .= $this->summaryRow('Score', $score);
+ $html .= $this->summaryRow('Percentage', $percentage);
+ $html .= ' ';
+
+ if ('' !== $this->text($attemptData['textWhenFinished'] ?? '')) {
+ $html .= ''.$this->escape((string) $attemptData['textWhenFinished']).' ';
+ }
+
+ if (true !== ($visibility['showQuestionDetails'] ?? false)) {
+ $html .= 'Question details are hidden according to the exercise result settings. ';
+ } elseif ([] === $result->questions) {
+ $html .= 'No question details available. ';
+ } else {
+ foreach ($result->questions as $question) {
+ $html .= $this->renderQuestion($question);
+ }
+ }
+
+ $html .= '';
+
+ return $html;
+ }
+
+ /**
+ * @param array $attemptData
+ */
+ private function formatScore(array $attemptData): string
+ {
+ if (!isset($attemptData['score'], $attemptData['maxScore']) || null === $attemptData['score'] || null === $attemptData['maxScore']) {
+ return 'Hidden';
+ }
+
+ return $this->formatNumber((float) $attemptData['score']).' / '.$this->formatNumber((float) $attemptData['maxScore']);
+ }
+
+ /**
+ * @param array $question
+ */
+ private function renderQuestion(array $question): string
+ {
+ $title = $this->text($question['title'] ?? 'Question');
+ $typeLabel = $this->text($question['typeLabel'] ?? '');
+ $position = (int) ($question['position'] ?? 0);
+ $score = $this->questionScore($question);
+
+ $html = '';
+ $html .= ' '.(0 < $position ? $position.'. ' : '').$this->escape($title).'';
+ if ('' !== $typeLabel) {
+ $html .= ' '.$this->escape($typeLabel).' ';
+ }
+ if ('' !== $this->text($question['description'] ?? '')) {
+ $html .= ' '.$this->escape((string) $question['description']).' ';
+ }
+ if ('' !== $score) {
+ $html .= ' '.$this->escape($score).' ';
+ }
+ if (true === ($question['pendingCorrection'] ?? false)) {
+ $html .= ' Pending correction ';
+ }
+ if (null !== ($question['feedback'] ?? null) && '' !== $this->text($question['feedback'])) {
+ $html .= ' Feedback: '.$this->escape((string) $question['feedback']).' ';
+ }
+
+ $answer = $question['answer'] ?? [];
+ if (\is_array($answer)) {
+ $html .= $this->renderAnswer($answer);
+ }
+
+ $html .= ' ';
+
+ return $html;
+ }
+
+ /**
+ * @param array $question
+ */
+ private function questionScore(array $question): string
+ {
+ if (!isset($question['score'], $question['maxScore']) || null === $question['score'] || null === $question['maxScore']) {
+ return '';
+ }
+
+ return 'Score: '.$this->formatNumber((float) $question['score']).' / '.$this->formatNumber((float) $question['maxScore']);
+ }
+
+ /**
+ * @param array $answer
+ */
+ private function renderAnswer(array $answer): string
+ {
+ $kind = (string) ($answer['kind'] ?? '');
+
+ return match ($kind) {
+ 'choice', 'dropdown' => $this->renderChoiceAnswer($answer),
+ 'true_false' => $this->renderTrueFalseAnswer($answer),
+ 'fill_blanks' => $this->renderFillBlankAnswer($answer),
+ 'matching' => $this->renderMatchingAnswer($answer),
+ 'draggable' => $this->renderDraggableAnswer($answer),
+ 'calculated' => $this->renderCalculatedAnswer($answer),
+ 'hotspot' => $this->renderHotspotAnswer($answer),
+ 'free_answer', 'annotation', 'upload_answer', 'oral_expression' => $this->renderManualAnswer($answer),
+ default => $this->renderGenericAnswer($answer),
+ };
+ }
+
+ /**
+ * @param array $answer
+ */
+ private function renderChoiceAnswer(array $answer): string
+ {
+ $items = $answer['choices'] ?? $answer['options'] ?? [];
+ if (!\is_array($items) || [] === $items) {
+ return 'No answer data available. ';
+ }
+
+ $html = '';
+ foreach ($items as $item) {
+ if (!\is_array($item)) {
+ continue;
+ }
+
+ $badges = [];
+ if (true === ($item['selected'] ?? false)) {
+ $badges[] = 'Your answer';
+ }
+ if (true === ($item['correct'] ?? false)) {
+ $badges[] = 'Correct answer';
+ }
+
+ $html .= '- '.$this->escape($item['answer'] ?? '').$this->renderBadges($badges);
+ if ('' !== $this->text($item['comment'] ?? '')) {
+ $html .= '';
+ }
+ $html .= '
';
+ }
+ $html .= ' ';
+
+ return $html;
+ }
+
+ /**
+ * @param array $answer
+ */
+ private function renderTrueFalseAnswer(array $answer): string
+ {
+ $items = $answer['choices'] ?? [];
+ if (!\is_array($items) || [] === $items) {
+ return 'No answer data available. ';
+ }
+
+ $html = '';
+ foreach ($items as $item) {
+ if (!\is_array($item)) {
+ continue;
+ }
+
+ $html .= '- '.$this->escape($item['answer'] ?? '');
+ if ('' !== $this->text($item['selectedOptionLabel'] ?? '')) {
+ $html .= '
Your answer: '.$this->escape((string) $item['selectedOptionLabel']).' ';
+ }
+ if ('' !== $this->text($item['correctOptionLabel'] ?? '')) {
+ $html .= 'Correct answer: '.$this->escape((string) $item['correctOptionLabel']).' ';
+ }
+ if ('' !== $this->text($item['selectedDegreeLabel'] ?? '')) {
+ $html .= 'Degree of certainty: '.$this->escape((string) $item['selectedDegreeLabel']).' ';
+ }
+ $html .= ' ';
+ }
+ $html .= ' ';
+
+ return $html;
+ }
+
+ /**
+ * @param array $answer
+ */
+ private function renderFillBlankAnswer(array $answer): string
+ {
+ $blanks = $answer['blanks'] ?? [];
+ if (!\is_array($blanks) || [] === $blanks) {
+ return 'No blank answer data available. ';
+ }
+
+ $html = '| Blank | Your answer | Correct answer | ';
+ foreach ($blanks as $blank) {
+ if (!\is_array($blank)) {
+ continue;
+ }
+
+ $html .= '';
+ $html .= '| '.$this->escape($blank['position'] ?? '').' | ';
+ $html .= ''.$this->escape($blank['studentAnswer'] ?? 'No answer').' | ';
+ $html .= ''.$this->escape($blank['correctAnswer'] ?? '').' | ';
+ $html .= ' ';
+ }
+ $html .= ' ';
+
+ return $html;
+ }
+
+ /**
+ * @param array $answer
+ */
+ private function renderMatchingAnswer(array $answer): string
+ {
+ $prompts = $answer['prompts'] ?? [];
+ if (!\is_array($prompts) || [] === $prompts) {
+ return 'No matching answer data available. ';
+ }
+
+ $html = '| Prompt | Your answer | Correct answer | ';
+ foreach ($prompts as $prompt) {
+ if (!\is_array($prompt)) {
+ continue;
+ }
+
+ $html .= '';
+ $html .= '| '.$this->escape($prompt['answer'] ?? '').' | ';
+ $html .= ''.$this->escape($prompt['selectedOptionAnswer'] ?? 'No answer').' | ';
+ $html .= ''.$this->escape($prompt['correctOptionAnswer'] ?? '').' | ';
+ $html .= ' ';
+ }
+ $html .= ' ';
+
+ return $html;
+ }
+
+ /**
+ * @param array $answer
+ */
+ private function renderDraggableAnswer(array $answer): string
+ {
+ $studentItems = $answer['studentItems'] ?? [];
+ $correctItems = $answer['correctItems'] ?? [];
+ $html = '';
+ $html .= ' Your order'.$this->renderOrderedItems($studentItems).'';
+ if (\is_array($correctItems) && [] !== $correctItems) {
+ $html .= ' Correct order'.$this->renderOrderedItems($correctItems).'';
+ }
+ $html .= ' ';
+
+ return $html;
+ }
+
+ /**
+ * @param mixed $items
+ */
+ private function renderOrderedItems(mixed $items): string
+ {
+ if (!\is_array($items) || [] === $items) {
+ return 'No answer ';
+ }
+
+ $html = '';
+ foreach ($items as $item) {
+ if (\is_array($item)) {
+ $html .= '- '.$this->escape($item['answer'] ?? $item['text'] ?? '').'
';
+ }
+ }
+ $html .= ' ';
+
+ return $html;
+ }
+
+ /**
+ * @param array $answer
+ */
+ private function renderCalculatedAnswer(array $answer): string
+ {
+ $html = '';
+ if ('' !== $this->text($answer['text'] ?? '')) {
+ $html .= ' '.$this->escape($answer['text']).' ';
+ }
+ $html .= ' Your answer: '.$this->escape($answer['studentAnswer'] ?? 'No answer').' ';
+ if ('' !== $this->text($answer['expectedAnswer'] ?? '')) {
+ $html .= ' Expected answer: '.$this->escape($answer['expectedAnswer']).' ';
+ }
+ $html .= ' ';
+
+ return $html;
+ }
+
+ /**
+ * @param array $answer
+ */
+ private function renderHotspotAnswer(array $answer): string
+ {
+ $html = '';
+ if ('' !== $this->text($answer['imageName'] ?? '')) {
+ $html .= ' Image: '.$this->escape($answer['imageName']).' ';
+ }
+
+ $points = $answer['studentPoints'] ?? [];
+ if (\is_array($points) && [] !== $points) {
+ $html .= ' Your points: '.$this->escape($this->compactList($points)).' ';
+ }
+
+ $zones = $answer['zones'] ?? [];
+ if (\is_array($zones) && [] !== $zones) {
+ $html .= ' Expected zones: '.$this->escape($this->compactList($zones)).' ';
+ }
+ $html .= ' ';
+
+ return $html;
+ }
+
+ /**
+ * @param array $answer
+ */
+ private function renderManualAnswer(array $answer): string
+ {
+ $html = '';
+ if ('' !== $this->text($answer['studentAnswer'] ?? '')) {
+ $html .= ' Your answer: '.$this->nl2br($answer['studentAnswer']).' ';
+ }
+
+ $files = $answer['files'] ?? [];
+ if (\is_array($files) && [] !== $files) {
+ $html .= ' Files:';
+ foreach ($files as $file) {
+ if (\is_array($file)) {
+ $html .= '- '.$this->escape($file['name'] ?? 'File').'
';
+ }
+ }
+ $html .= ' ';
+ }
+
+ if ('' !== $this->text($answer['teacherComment'] ?? '')) {
+ $html .= ' Teacher comment: '.$this->nl2br($answer['teacherComment']).' ';
+ }
+ if (isset($answer['marks'])) {
+ $html .= ' Marks: '.$this->escape($this->formatNumber((float) $answer['marks'])).' ';
+ }
+ $html .= ' ';
+
+ return $html;
+ }
+
+ /**
+ * @param array $answer
+ */
+ private function renderGenericAnswer(array $answer): string
+ {
+ return ''.$this->escape(json_encode($answer, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ?: '').' ';
+ }
+
+ /**
+ * @param array $badges
+ */
+ private function renderBadges(array $badges): string
+ {
+ if ([] === $badges) {
+ return '';
+ }
+
+ $html = ' ';
+ foreach ($badges as $badge) {
+ $html .= ''.$this->escape($badge).' ';
+ }
+
+ return $html;
+ }
+
+ /**
+ * @param mixed $items
+ */
+ private function compactList(mixed $items): string
+ {
+ if (!\is_array($items)) {
+ return '';
+ }
+
+ $values = [];
+ foreach ($items as $item) {
+ if (!\is_array($item)) {
+ continue;
+ }
+
+ $parts = [];
+ foreach ($item as $key => $value) {
+ if (\is_scalar($value)) {
+ $parts[] = $key.': '.$value;
+ }
+ }
+ if ([] !== $parts) {
+ $values[] = implode(', ', $parts);
+ }
+ }
+
+ return implode(' | ', $values);
+ }
+
+ private function summaryRow(string $label, string $value): string
+ {
+ return '| '.$this->escape($label).' | '.$this->escape('' !== $value ? $value : '-').' | ';
+ }
+
+ private function formatDateValue(mixed $value): string
+ {
+ if ($value instanceof DateTimeInterface) {
+ return $value->format('Y-m-d H:i:s');
+ }
+
+ if (\is_string($value) && '' !== $value) {
+ try {
+ return (new \DateTimeImmutable($value))->format('Y-m-d H:i:s');
+ } catch (\Throwable) {
+ return $value;
+ }
+ }
+
+ return '';
+ }
+
+ private function formatDuration(int $duration): string
+ {
+ $seconds = max(0, $duration);
+ $hours = intdiv($seconds, 3600);
+ $minutes = intdiv($seconds % 3600, 60);
+ $remainingSeconds = $seconds % 60;
+
+ if (0 < $hours) {
+ return sprintf('%02d:%02d:%02d', $hours, $minutes, $remainingSeconds);
+ }
+
+ return sprintf('%02d:%02d', $minutes, $remainingSeconds);
+ }
+
+ private function formatNumber(float $value): string
+ {
+ return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
+ }
+
+ private function buildFileName(ExerciseRuntimeResult $result, TrackEExercise $attempt): string
+ {
+ $safeTitle = preg_replace('/[^A-Za-z0-9_-]+/', '-', $this->text($result->title)) ?: 'exercise-attempt';
+ $safeTitle = trim($safeTitle, '-');
+ if ('' === $safeTitle) {
+ $safeTitle = 'exercise-attempt';
+ }
+
+ return strtolower($safeTitle).'-attempt-'.(int) $attempt->getExeId().'.pdf';
+ }
+
+ private function nl2br(mixed $value): string
+ {
+ return nl2br($this->escape((string) $value));
+ }
+
+ private function escape(mixed $value): string
+ {
+ return htmlspecialchars($this->text($value), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+ }
+
+ private function text(mixed $value): string
+ {
+ if (null === $value) {
+ return '';
+ }
+
+ $text = \is_scalar($value) ? (string) $value : json_encode($value, JSON_UNESCAPED_UNICODE);
+ if (false === $text) {
+ return '';
+ }
+
+ return trim(html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
+ }
+
+ private function getCss(): string
+ {
+ return <<<'CSS'
+body { font-family: DejaVu Sans, sans-serif; color: #222; font-size: 11pt; line-height: 1.45; }
+h1 { color: #1f4e79; font-size: 20pt; margin: 0 0 10px; }
+h2 { color: #1f4e79; font-size: 13pt; margin: 0 0 4px; }
+h3 { color: #333; font-size: 11pt; margin: 8px 0 4px; }
+.summary, .details { border-collapse: collapse; width: 100%; margin: 12px 0; }
+.summary th { width: 30%; background: #f2f5f8; text-align: left; }
+.summary th, .summary td, .details th, .details td { border: 1px solid #d9e2ec; padding: 6px 8px; vertical-align: top; }
+.details th { background: #f2f5f8; text-align: left; }
+.question { page-break-inside: avoid; border: 1px solid #d9e2ec; border-left: 4px solid #1f4e79; padding: 12px; margin: 14px 0; }
+.description { color: #555; margin: 8px 0; }
+.small { font-size: 10pt; }
+.muted { color: #666; font-size: 9.5pt; }
+.score { background: #eef7ff; border: 1px solid #cce5ff; color: #1f4e79; display: inline-block; margin: 8px 0; padding: 4px 8px; }
+.warning { background: #fff3cd; border: 1px solid #ffeeba; color: #856404; display: inline-block; margin: 8px 0; padding: 4px 8px; }
+.notice { background: #eef7ff; border: 1px solid #cce5ff; margin: 12px 0; padding: 8px; }
+.feedback, .comment { background: #f8f9fa; border: 1px solid #e9ecef; margin-top: 8px; padding: 6px; }
+.answers { margin: 8px 0; padding-left: 18px; }
+.answers li { margin-bottom: 8px; }
+.badge { background: #e8f5e9; border: 1px solid #c8e6c9; border-radius: 10px; color: #2e7d32; font-size: 8.5pt; padding: 2px 6px; }
+.answer-block { border: 1px solid #e9ecef; background: #fbfbfb; padding: 8px; margin: 8px 0; }
+.columns { width: 100%; }
+.columns div { width: 48%; display: inline-block; vertical-align: top; }
+.generic { background: #f8f9fa; border: 1px solid #e9ecef; padding: 8px; white-space: pre-wrap; }
+CSS;
+ }
+}
diff --git a/src/CoreBundle/Service/Exercise/ExerciseRuntimeReportExportService.php b/src/CoreBundle/Service/Exercise/ExerciseRuntimeReportExportService.php
new file mode 100644
index 00000000000..30b0fd14f1a
--- /dev/null
+++ b/src/CoreBundle/Service/Exercise/ExerciseRuntimeReportExportService.php
@@ -0,0 +1,846 @@
+getValidatedExercise($exerciseId, $request);
+ $exportData = $this->buildExportData($quiz, $request);
+ $fileName = $this->buildFileName($quiz, 'csv');
+
+ $response = new StreamedResponse(static function () use ($exportData): void {
+ $handle = fopen('php://output', 'w');
+ if (!\is_resource($handle)) {
+ return;
+ }
+
+ fputcsv($handle, $exportData['headers']);
+ foreach ($exportData['rows'] as $row) {
+ fputcsv($handle, $row);
+ }
+ fclose($handle);
+ });
+
+ $response->headers->set('Content-Type', 'text/csv; charset=UTF-8');
+ $response->headers->set(
+ 'Content-Disposition',
+ $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $fileName)
+ );
+
+ return $response;
+ }
+
+ public function exportXlsx(int $exerciseId, Request $request): BinaryFileResponse
+ {
+ $quiz = $this->getValidatedExercise($exerciseId, $request);
+ $exportData = $this->buildExportData($quiz, $request);
+ $fileName = $this->buildFileName($quiz, 'xlsx');
+
+ $spreadsheet = new Spreadsheet();
+ $sheet = $spreadsheet->getActiveSheet();
+ $sheet->setTitle('Learner score');
+ $sheet->fromArray($exportData['headers'], null, 'A1');
+
+ $rowNumber = 2;
+ foreach ($exportData['rows'] as $row) {
+ $sheet->fromArray($row, null, 'A'.$rowNumber);
+ ++$rowNumber;
+ }
+
+ $columnCount = \count($exportData['headers']);
+ for ($columnIndex = 1; $columnIndex <= $columnCount; ++$columnIndex) {
+ $sheet->getColumnDimensionByColumn($columnIndex)->setAutoSize(true);
+ }
+
+ $filePath = tempnam(sys_get_temp_dir(), 'exercise-report-');
+ if (false === $filePath) {
+ throw new BadRequestHttpException('The export file could not be created.');
+ }
+
+ $xlsxPath = $filePath.'.xlsx';
+ rename($filePath, $xlsxPath);
+
+ $writer = new Xlsx($spreadsheet);
+ $writer->save($xlsxPath);
+ $spreadsheet->disconnectWorksheets();
+
+ $response = new BinaryFileResponse(new File($xlsxPath));
+ $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $fileName);
+ $response->deleteFileAfterSend(true);
+
+ return $response;
+ }
+
+ private function getValidatedExercise(int $exerciseId, Request $request): CQuiz
+ {
+ if (0 >= $exerciseId) {
+ throw new BadRequestHttpException('A valid exercise id is required.');
+ }
+
+ if (!$this->canExportReport()) {
+ throw new AccessDeniedHttpException('You are not allowed to export this exercise report.');
+ }
+
+ $course = $this->getCourse($request);
+ $session = $this->getSession($request);
+
+ return $this->getExerciseFromCurrentContext($exerciseId, $course, $session);
+ }
+
+ private function getCourse(Request $request): Course
+ {
+ $courseId = $request->query->getInt('cid');
+ if (0 >= $courseId) {
+ throw new BadRequestHttpException('A valid course id is required.');
+ }
+
+ $course = $this->entityManager->getRepository(Course::class)->find($courseId);
+ if (!$course instanceof Course) {
+ throw new BadRequestHttpException('The requested course was not found.');
+ }
+
+ return $course;
+ }
+
+ private function getSession(Request $request): ?Session
+ {
+ $sessionId = $request->query->getInt('sid');
+ if (0 >= $sessionId) {
+ return null;
+ }
+
+ $session = $this->entityManager->getRepository(Session::class)->find($sessionId);
+ if (!$session instanceof Session) {
+ throw new BadRequestHttpException('The requested session was not found.');
+ }
+
+ return $session;
+ }
+
+ private function getExerciseFromCurrentContext(int $exerciseId, Course $course, ?Session $session): CQuiz
+ {
+ $quiz = $this->quizRepository->find($exerciseId);
+ if (!$quiz instanceof CQuiz) {
+ throw new NotFoundHttpException('The requested exercise was not found.');
+ }
+
+ $queryBuilder = $this->entityManager->createQueryBuilder()
+ ->select('quiz.iid')
+ ->addSelect('links.visibility AS linkVisibility')
+ ->from(CQuiz::class, 'quiz')
+ ->innerJoin('quiz.resourceNode', 'node')
+ ->innerJoin('node.resourceLinks', 'links')
+ ->andWhere('quiz.iid = :exerciseId')
+ ->andWhere('IDENTITY(links.course) = :courseId')
+ ->andWhere('links.deletedAt IS NULL')
+ ->andWhere('links.endVisibilityAt IS NULL')
+ ->setParameter('exerciseId', $exerciseId, Types::INTEGER)
+ ->setParameter('courseId', (int) $course->getId(), Types::INTEGER)
+ ->setMaxResults(1)
+ ;
+
+ if (null !== $session) {
+ $queryBuilder
+ ->andWhere('(IDENTITY(links.session) = :sessionId OR links.session IS NULL)')
+ ->setParameter('sessionId', (int) $session->getId(), Types::INTEGER)
+ ;
+ } else {
+ $queryBuilder->andWhere('links.session IS NULL');
+ }
+
+ $row = $queryBuilder->getQuery()->getOneOrNullResult();
+ if (null === $row) {
+ throw new AccessDeniedHttpException('The requested exercise does not belong to the current course context.');
+ }
+
+ $visibility = \is_array($row) ? (int) ($row['linkVisibility'] ?? 0) : 0;
+ if (0 !== $visibility && self::VISIBILITY_PUBLISHED !== $visibility && !$this->canExportReport()) {
+ throw new AccessDeniedHttpException('The requested exercise is not visible.');
+ }
+
+ return $quiz;
+ }
+
+ /**
+ * @return array{headers: array, rows: array>}
+ */
+ private function buildExportData(CQuiz $quiz, Request $request): array
+ {
+ $course = $this->getCourse($request);
+ $session = $this->getSession($request);
+ $showOfficialCode = $this->shouldShowOfficialCode();
+ $loadExtraData = $this->getBooleanQuery($request, 'extraData', 'extra_data');
+ $includeAllUsers = $this->getBooleanQuery($request, 'includeAllUsers', 'include_all_users');
+ $extraFields = $loadExtraData ? $this->getFilterableUserExtraFields() : [];
+ $attempts = $this->getAttempts($quiz, $course, $session, $request);
+
+ if ($this->getBooleanQuery($request, 'onlyBestAttempts', 'only_best_attempts')) {
+ $attempts = $this->filterBestAttempts($attempts);
+ }
+
+ $users = $includeAllUsers ? $this->getSubscribedUsers($course, $session, $request) : [];
+ $userIds = $this->collectUserIds($attempts, $users);
+ $groupNamesByUser = $this->getGroupNamesByUserIds($userIds, $course);
+ $extraFieldValues = [] !== $extraFields ? $this->getExtraFieldValues($extraFields, $userIds) : [];
+
+ $rows = [];
+ $attemptedUserIds = [];
+ foreach ($attempts as $attempt) {
+ $userId = (int) $attempt->getUser()->getId();
+ $attemptedUserIds[$userId] = true;
+ $rows[] = $this->buildAttemptRow(
+ $attempt,
+ $showOfficialCode,
+ $extraFields,
+ $extraFieldValues,
+ $groupNamesByUser[$userId] ?? '-'
+ );
+ }
+
+ if ($includeAllUsers && $this->shouldAppendNotAttemptedUsers($request)) {
+ foreach ($users as $user) {
+ $userId = (int) $user->getId();
+ if (isset($attemptedUserIds[$userId])) {
+ continue;
+ }
+
+ $rows[] = $this->buildNotAttemptedUserRow(
+ $user,
+ $showOfficialCode,
+ $extraFields,
+ $extraFieldValues,
+ $groupNamesByUser[$userId] ?? '-'
+ );
+ }
+ }
+
+ return [
+ 'headers' => $this->getHeaders($showOfficialCode, $extraFields),
+ 'rows' => $rows,
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ private function getHeaders(bool $showOfficialCode, array $extraFields): array
+ {
+ $headers = [
+ 'First name',
+ 'Last name',
+ 'Username',
+ 'Group',
+ 'Duration',
+ 'Started at',
+ 'Completed at',
+ 'Score',
+ 'Max score',
+ 'Percentage',
+ 'IP',
+ 'Status',
+ 'Learning path',
+ ];
+
+ if ($showOfficialCode) {
+ array_unshift($headers, 'Official code');
+ }
+
+ foreach ($extraFields as $field) {
+ $headers[] = (string) ($field['label'] ?? $field['variable'] ?? '');
+ }
+
+ return $headers;
+ }
+
+ /**
+ * @param array> $extraFields
+ * @param array> $extraFieldValues
+ *
+ * @return array
+ */
+ private function buildAttemptRow(
+ TrackEExercise $attempt,
+ bool $showOfficialCode,
+ array $extraFields,
+ array $extraFieldValues,
+ string $groupName,
+ ): array {
+ $user = $attempt->getUser();
+ $score = $attempt->getScore();
+ $maxScore = $attempt->getMaxScore();
+ $percentage = 0.0 < $maxScore ? round(($score * 100) / $maxScore, 2) : 0.0;
+
+ $row = [
+ (string) $user->getFirstname(),
+ (string) $user->getLastname(),
+ (string) $user->getUsername(),
+ $groupName,
+ $this->formatDuration($attempt->getExeDuration()),
+ $this->formatDate($attempt->getStartDate()),
+ $this->formatDate($attempt->getExeDate()),
+ round($score, 2),
+ round($maxScore, 2),
+ $percentage,
+ $attempt->getUserIp(),
+ $this->getAttemptStatusLabel($attempt),
+ $this->formatLearningPath($attempt),
+ ];
+
+ if ($showOfficialCode) {
+ array_unshift($row, (string) ($user->getOfficialCode() ?? ''));
+ }
+
+ $this->appendExtraFieldValues($row, $user, $extraFields, $extraFieldValues);
+
+ return $row;
+ }
+
+ /**
+ * @param array> $extraFields
+ * @param array> $extraFieldValues
+ *
+ * @return array
+ */
+ private function buildNotAttemptedUserRow(
+ User $user,
+ bool $showOfficialCode,
+ array $extraFields,
+ array $extraFieldValues,
+ string $groupName,
+ ): array {
+ $row = [
+ (string) $user->getFirstname(),
+ (string) $user->getLastname(),
+ (string) $user->getUsername(),
+ $groupName,
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ 'Not attempted',
+ '-',
+ ];
+
+ if ($showOfficialCode) {
+ array_unshift($row, (string) ($user->getOfficialCode() ?? ''));
+ }
+
+ $this->appendExtraFieldValues($row, $user, $extraFields, $extraFieldValues);
+
+ return $row;
+ }
+
+ /**
+ * @param array $row
+ * @param array> $extraFields
+ * @param array> $extraFieldValues
+ */
+ private function appendExtraFieldValues(array &$row, User $user, array $extraFields, array $extraFieldValues): void
+ {
+ $userValues = $extraFieldValues[(int) $user->getId()] ?? [];
+ foreach ($extraFields as $field) {
+ $fieldId = (int) ($field['id'] ?? 0);
+ $row[] = $userValues[$fieldId] ?? '';
+ }
+ }
+
+ /**
+ * @return array
+ */
+ private function getAttempts(CQuiz $quiz, Course $course, ?Session $session, Request $request): array
+ {
+ $queryBuilder = $this->entityManager->createQueryBuilder()
+ ->select('attempt', 'user')
+ ->from(TrackEExercise::class, 'attempt')
+ ->innerJoin('attempt.user', 'user')
+ ->andWhere('IDENTITY(attempt.quiz) = :exerciseId')
+ ->andWhere('IDENTITY(attempt.course) = :courseId')
+ ->setParameter('exerciseId', (int) $quiz->getIid(), Types::INTEGER)
+ ->setParameter('courseId', (int) $course->getId(), Types::INTEGER)
+ ->orderBy('attempt.exeDate', 'DESC')
+ ->addOrderBy('attempt.exeId', 'DESC')
+ ;
+
+ if (null !== $session) {
+ $queryBuilder
+ ->andWhere('IDENTITY(attempt.session) = :sessionId')
+ ->setParameter('sessionId', (int) $session->getId(), Types::INTEGER)
+ ;
+ } else {
+ $queryBuilder->andWhere('attempt.session IS NULL');
+ }
+
+ $this->applyUserFilters($queryBuilder, $request);
+ $this->applyGroupFilter($queryBuilder, $course, $request);
+ $this->applyStatusFilter($queryBuilder, $request);
+
+ $attempts = [];
+ foreach ($queryBuilder->getQuery()->getResult() as $attempt) {
+ if ($attempt instanceof TrackEExercise) {
+ $attempts[] = $attempt;
+ }
+ }
+
+ return $attempts;
+ }
+
+ private function applyUserFilters(QueryBuilder $queryBuilder, Request $request): void
+ {
+ $firstName = trim((string) $request->query->get('firstName', ''));
+ if ('' !== $firstName) {
+ $queryBuilder
+ ->andWhere('LOWER(user.firstname) LIKE :firstName')
+ ->setParameter('firstName', '%'.mb_strtolower($firstName).'%', Types::STRING)
+ ;
+ }
+
+ $lastName = trim((string) $request->query->get('lastName', ''));
+ if ('' !== $lastName) {
+ $queryBuilder
+ ->andWhere('LOWER(user.lastname) LIKE :lastName')
+ ->setParameter('lastName', '%'.mb_strtolower($lastName).'%', Types::STRING)
+ ;
+ }
+ }
+
+ private function applyGroupFilter(QueryBuilder $queryBuilder, Course $course, Request $request): void
+ {
+ $groupFilter = $this->getGroupFilterValue($request);
+ if ('' === $groupFilter || 'group_all' === $groupFilter) {
+ return;
+ }
+
+ $joinType = 'group_none' === $groupFilter ? 'leftJoin' : 'innerJoin';
+ $queryBuilder
+ ->{$joinType}(
+ CGroupRelUser::class,
+ 'groupRelFilter',
+ 'WITH',
+ 'groupRelFilter.user = user AND groupRelFilter.cId = :groupCourseId'
+ )
+ ->setParameter('groupCourseId', (int) $course->getId(), Types::INTEGER)
+ ;
+
+ if ('group_none' === $groupFilter) {
+ $queryBuilder->andWhere('groupRelFilter.iid IS NULL');
+
+ return;
+ }
+
+ $groupId = (int) $groupFilter;
+ if (0 < $groupId) {
+ $queryBuilder
+ ->andWhere('IDENTITY(groupRelFilter.group) = :groupId')
+ ->setParameter('groupId', $groupId, Types::INTEGER)
+ ;
+ }
+ }
+
+ private function getGroupFilterValue(Request $request): string
+ {
+ return trim((string) $request->query->get(
+ 'groupId',
+ $request->query->get('group_id', $request->query->get('group_id_in_toolbar', ''))
+ ));
+ }
+
+ private function applyStatusFilter(QueryBuilder $queryBuilder, Request $request): void
+ {
+ $status = trim((string) $request->query->get('status', ''));
+ if (self::STATUS_PENDING_CORRECTION === $status) {
+ $queryBuilder->andWhere("attempt.questionsToCheck <> ''");
+
+ return;
+ }
+
+ if (self::STATUS_INCOMPLETE === $status) {
+ $queryBuilder
+ ->andWhere('attempt.status = :status')
+ ->setParameter('status', self::STATUS_INCOMPLETE, Types::STRING)
+ ;
+
+ return;
+ }
+
+ if (self::STATUS_COMPLETED === $status) {
+ $queryBuilder
+ ->andWhere('attempt.status = :status')
+ ->andWhere("attempt.questionsToCheck = ''")
+ ->setParameter('status', self::STATUS_COMPLETED, Types::STRING)
+ ;
+ }
+ }
+
+ /**
+ * @param array $attempts
+ *
+ * @return array
+ */
+ private function filterBestAttempts(array $attempts): array
+ {
+ $bestAttempts = [];
+ foreach ($attempts as $attempt) {
+ $userId = (int) $attempt->getUser()->getId();
+ $current = $bestAttempts[$userId] ?? null;
+ if (!$current instanceof TrackEExercise || $this->isBetterAttempt($attempt, $current)) {
+ $bestAttempts[$userId] = $attempt;
+ }
+ }
+
+ return array_values($bestAttempts);
+ }
+
+ private function isBetterAttempt(TrackEExercise $candidate, TrackEExercise $current): bool
+ {
+ $candidateMaxScore = $candidate->getMaxScore();
+ $currentMaxScore = $current->getMaxScore();
+ $candidatePercentage = 0.0 < $candidateMaxScore ? ($candidate->getScore() * 100) / $candidateMaxScore : 0.0;
+ $currentPercentage = 0.0 < $currentMaxScore ? ($current->getScore() * 100) / $currentMaxScore : 0.0;
+
+ if ($candidatePercentage > $currentPercentage) {
+ return true;
+ }
+
+ if ($candidatePercentage < $currentPercentage) {
+ return false;
+ }
+
+ return $candidate->getExeDate() > $current->getExeDate();
+ }
+
+ /**
+ * @return array
+ */
+ private function getSubscribedUsers(Course $course, ?Session $session, Request $request): array
+ {
+ if (null !== $session) {
+ $queryBuilder = $this->entityManager->createQueryBuilder()
+ ->select('rel', 'user')
+ ->from(SessionRelCourseRelUser::class, 'rel')
+ ->innerJoin('rel.user', 'user')
+ ->andWhere('IDENTITY(rel.course) = :courseId')
+ ->andWhere('IDENTITY(rel.session) = :sessionId')
+ ->andWhere('rel.status = :status')
+ ->andWhere('user.status <> :softDeleted')
+ ->setParameter('courseId', (int) $course->getId(), Types::INTEGER)
+ ->setParameter('sessionId', (int) $session->getId(), Types::INTEGER)
+ ->setParameter('status', Session::STUDENT, Types::INTEGER)
+ ->setParameter('softDeleted', User::SOFT_DELETED, Types::INTEGER)
+ ;
+ } else {
+ $queryBuilder = $this->entityManager->createQueryBuilder()
+ ->select('rel', 'user')
+ ->from(CourseRelUser::class, 'rel')
+ ->innerJoin('rel.user', 'user')
+ ->andWhere('IDENTITY(rel.course) = :courseId')
+ ->andWhere('rel.status = :status')
+ ->andWhere('user.status <> :softDeleted')
+ ->setParameter('courseId', (int) $course->getId(), Types::INTEGER)
+ ->setParameter('status', CourseRelUser::STUDENT, Types::INTEGER)
+ ->setParameter('softDeleted', User::SOFT_DELETED, Types::INTEGER)
+ ;
+ }
+
+ $this->applyUserFilters($queryBuilder, $request);
+ $this->applyGroupFilter($queryBuilder, $course, $request);
+ $queryBuilder
+ ->orderBy('user.lastname', 'ASC')
+ ->addOrderBy('user.firstname', 'ASC')
+ ->addOrderBy('user.username', 'ASC')
+ ;
+
+ $users = [];
+ foreach ($queryBuilder->getQuery()->getResult() as $relation) {
+ if (!$relation instanceof CourseRelUser && !$relation instanceof SessionRelCourseRelUser) {
+ continue;
+ }
+
+ $user = $relation->getUser();
+ $userId = (int) $user->getId();
+ if (0 >= $userId) {
+ continue;
+ }
+
+ $users[$userId] = $user;
+ }
+
+ return array_values($users);
+ }
+
+ /**
+ * @param array $attempts
+ * @param array $users
+ *
+ * @return array
+ */
+ private function collectUserIds(array $attempts, array $users): array
+ {
+ $userIds = [];
+ foreach ($attempts as $attempt) {
+ $userIds[] = (int) $attempt->getUser()->getId();
+ }
+ foreach ($users as $user) {
+ $userIds[] = (int) $user->getId();
+ }
+
+ return array_values(array_unique(array_filter($userIds, static fn (int $userId): bool => 0 < $userId)));
+ }
+
+ /**
+ * @param array $userIds
+ *
+ * @return array
+ */
+ private function getGroupNamesByUserIds(array $userIds, Course $course): array
+ {
+ if ([] === $userIds) {
+ return [];
+ }
+
+ $rows = $this->entityManager->createQueryBuilder()
+ ->select('rel', 'groupInfo')
+ ->from(CGroupRelUser::class, 'rel')
+ ->innerJoin('rel.group', 'groupInfo')
+ ->andWhere('rel.cId = :courseId')
+ ->andWhere('IDENTITY(rel.user) IN (:userIds)')
+ ->setParameter('courseId', (int) $course->getId(), Types::INTEGER)
+ ->setParameter('userIds', $userIds, ArrayParameterType::INTEGER)
+ ->orderBy('groupInfo.title', 'ASC')
+ ->getQuery()
+ ->getResult()
+ ;
+
+ $names = [];
+ foreach ($rows as $row) {
+ if (!$row instanceof CGroupRelUser) {
+ continue;
+ }
+
+ $userId = (int) $row->getUser()->getId();
+ $title = trim($row->getGroup()->getTitle());
+ if ('' === $title) {
+ continue;
+ }
+
+ if (!isset($names[$userId])) {
+ $names[$userId] = [];
+ }
+ $names[$userId][] = $title;
+ }
+
+ $formatted = [];
+ foreach ($names as $userId => $groupNames) {
+ $formatted[$userId] = implode(', ', array_values(array_unique($groupNames)));
+ }
+
+ return $formatted;
+ }
+
+ /**
+ * @return array>
+ */
+ private function getFilterableUserExtraFields(): array
+ {
+ $rows = $this->entityManager->createQueryBuilder()
+ ->select('field')
+ ->from(ExtraField::class, 'field')
+ ->andWhere('field.itemType = :itemType')
+ ->andWhere('field.filter = :filter')
+ ->setParameter('itemType', ExtraField::USER_FIELD_TYPE, Types::INTEGER)
+ ->setParameter('filter', true, Types::BOOLEAN)
+ ->orderBy('field.fieldOrder', 'ASC')
+ ->addOrderBy('field.id', 'ASC')
+ ->getQuery()
+ ->getResult()
+ ;
+
+ $fields = [];
+ foreach ($rows as $row) {
+ if (!$row instanceof ExtraField) {
+ continue;
+ }
+
+ $fieldId = (int) $row->getId();
+ if (0 >= $fieldId) {
+ continue;
+ }
+
+ $fields[] = [
+ 'id' => $fieldId,
+ 'variable' => $row->getVariable(),
+ 'label' => (string) ($row->getDisplayText() ?: $row->getVariable()),
+ ];
+ }
+
+ return $fields;
+ }
+
+ /**
+ * @param array> $extraFields
+ * @param array $userIds
+ *
+ * @return array>
+ */
+ private function getExtraFieldValues(array $extraFields, array $userIds): array
+ {
+ $fieldIds = array_values(array_filter(
+ array_map(static fn (array $field): int => (int) ($field['id'] ?? 0), $extraFields),
+ static fn (int $fieldId): bool => 0 < $fieldId
+ ));
+
+ if ([] === $fieldIds || [] === $userIds) {
+ return [];
+ }
+
+ $rows = $this->entityManager->createQueryBuilder()
+ ->select('value', 'field')
+ ->from(ExtraFieldValues::class, 'value')
+ ->innerJoin('value.field', 'field')
+ ->andWhere('field.id IN (:fieldIds)')
+ ->andWhere('value.itemId IN (:userIds)')
+ ->setParameter('fieldIds', $fieldIds, ArrayParameterType::INTEGER)
+ ->setParameter('userIds', $userIds, ArrayParameterType::INTEGER)
+ ->getQuery()
+ ->getResult()
+ ;
+
+ $values = [];
+ foreach ($rows as $row) {
+ if (!$row instanceof ExtraFieldValues) {
+ continue;
+ }
+
+ $values[$row->getItemId()][(int) $row->getField()->getId()] = (string) ($row->getFieldValue() ?? '');
+ }
+
+ return $values;
+ }
+
+ private function canExportReport(): bool
+ {
+ return $this->security->isGranted('ROLE_CURRENT_COURSE_TEACHER')
+ || $this->security->isGranted('ROLE_CURRENT_COURSE_SESSION_TEACHER');
+ }
+
+ private function shouldShowOfficialCode(): bool
+ {
+ return 'true' === $this->settingsManager->getSetting('exercise.show_official_code_exercise_result_list', true);
+ }
+
+ private function shouldAppendNotAttemptedUsers(Request $request): bool
+ {
+ $status = trim((string) $request->query->get('status', ''));
+
+ return '' === $status || self::STATUS_NOT_ATTEMPTED === $status;
+ }
+
+ private function getBooleanQuery(Request $request, string $modernName, string $legacyName): bool
+ {
+ $value = $request->query->get($modernName, $request->query->get($legacyName, '0'));
+
+ return \in_array(strtolower((string) $value), ['1', 'true', 'yes', 'on'], true);
+ }
+
+ private function getAttemptStatusLabel(TrackEExercise $attempt): string
+ {
+ if ('' !== trim($attempt->getQuestionsToCheck())) {
+ return 'Pending correction';
+ }
+
+ if (self::STATUS_INCOMPLETE === (string) $attempt->getStatus()) {
+ return 'Ongoing';
+ }
+
+ return 'Completed';
+ }
+
+ private function formatLearningPath(TrackEExercise $attempt): string
+ {
+ if (0 < $attempt->getOrigLpId()) {
+ return '#'.$attempt->getOrigLpId();
+ }
+
+ return '-';
+ }
+
+ private function formatDuration(int $duration): string
+ {
+ $seconds = max(0, $duration);
+ $hours = intdiv($seconds, 3600);
+ $minutes = intdiv($seconds % 3600, 60);
+ $remainingSeconds = $seconds % 60;
+
+ if (0 < $hours) {
+ return sprintf('%02d:%02d:%02d', $hours, $minutes, $remainingSeconds);
+ }
+
+ return sprintf('%02d:%02d', $minutes, $remainingSeconds);
+ }
+
+ private function formatDate(DateTimeInterface $date): string
+ {
+ return $date->format('Y-m-d H:i:s');
+ }
+
+ private function buildFileName(CQuiz $quiz, string $extension): string
+ {
+ $safeTitle = preg_replace('/[^A-Za-z0-9_-]+/', '-', $quiz->getTitle()) ?: 'exercise-report';
+ $safeTitle = trim($safeTitle, '-');
+ if ('' === $safeTitle) {
+ $safeTitle = 'exercise-report';
+ }
+
+ return strtolower($safeTitle).'-learner-score.'.$extension;
+ }
+}
diff --git a/src/CoreBundle/Service/Exercise/ExerciseRuntimeResultEmailService.php b/src/CoreBundle/Service/Exercise/ExerciseRuntimeResultEmailService.php
new file mode 100644
index 00000000000..3050df6fab5
--- /dev/null
+++ b/src/CoreBundle/Service/Exercise/ExerciseRuntimeResultEmailService.php
@@ -0,0 +1,485 @@
+>}
+ */
+ public function sendReviewedAttempts(int $exerciseId, Request $request, string $node = ''): array
+ {
+ if (0 >= $exerciseId) {
+ throw new BadRequestHttpException('A valid exercise id is required.');
+ }
+
+ if (!$this->canManageExercises()) {
+ throw new AccessDeniedHttpException('You are not allowed to email exercise results.');
+ }
+
+ $sender = $this->getCurrentUser();
+ $course = $this->getCourse($request);
+ $session = $this->getSession($request);
+ $quiz = $this->getExerciseFromCurrentContext($exerciseId, $course, $session);
+ $attempts = $this->getReviewedAttempts($quiz, $course, $session, $request);
+
+ return $this->sendAttempts($attempts, $quiz, $course, $session, $sender, $request, $node);
+ }
+
+ /**
+ * @return array{success: bool, message: string, totalCount: int, sentCount: int, skippedCount: int, failedCount: int, failures: array>, recipientId: int|null, recipientName: string, recipientEmail: string}
+ */
+ public function sendAttempt(int $exerciseId, int $attemptId, Request $request, string $node = ''): array
+ {
+ if (0 >= $exerciseId || 0 >= $attemptId) {
+ throw new BadRequestHttpException('A valid exercise and attempt are required.');
+ }
+
+ if (!$this->canManageExercises()) {
+ throw new AccessDeniedHttpException('You are not allowed to email this exercise attempt.');
+ }
+
+ $sender = $this->getCurrentUser();
+ $course = $this->getCourse($request);
+ $session = $this->getSession($request);
+ $quiz = $this->getExerciseFromCurrentContext($exerciseId, $course, $session);
+ $attempt = $this->getAttempt($attemptId, $quiz, $course, $session);
+ $result = $this->sendAttempts([$attempt], $quiz, $course, $session, $sender, $request, $node);
+ $recipient = $attempt->getUser();
+
+ return $result + [
+ 'recipientId' => (int) $recipient->getId(),
+ 'recipientName' => $recipient->getFullName(),
+ 'recipientEmail' => $recipient->getEmail(),
+ ];
+ }
+
+ /**
+ * @param array $attempts
+ *
+ * @return array{success: bool, message: string, totalCount: int, sentCount: int, skippedCount: int, failedCount: int, failures: array>}
+ */
+ private function sendAttempts(
+ array $attempts,
+ CQuiz $quiz,
+ Course $course,
+ ?Session $session,
+ User $sender,
+ Request $request,
+ string $node,
+ ): array {
+ $sentCount = 0;
+ $skippedCount = 0;
+ $failedCount = 0;
+ $failures = [];
+
+ foreach ($attempts as $attempt) {
+ $recipient = $attempt->getUser();
+ $recipientEmail = trim($recipient->getEmail());
+ if ('' === $recipientEmail || !filter_var($recipientEmail, FILTER_VALIDATE_EMAIL)) {
+ ++$skippedCount;
+ $failures[] = [
+ 'attemptId' => (int) $attempt->getExeId(),
+ 'recipientId' => (int) $recipient->getId(),
+ 'reason' => 'Invalid recipient email',
+ ];
+ continue;
+ }
+
+ try {
+ $this->sendAttemptEmail($attempt, $quiz, $course, $session, $sender, $request, $node);
+ ++$sentCount;
+ } catch (\Throwable $exception) {
+ ++$failedCount;
+ $failures[] = [
+ 'attemptId' => (int) $attempt->getExeId(),
+ 'recipientId' => (int) $recipient->getId(),
+ 'reason' => $exception->getMessage(),
+ ];
+ }
+ }
+
+ $totalCount = \count($attempts);
+ $success = 0 === $totalCount || 0 < $sentCount;
+ $message = match (true) {
+ 0 === $totalCount => 'No reviewed attempts found',
+ 0 < $sentCount && 0 === $failedCount && 0 === $skippedCount => 'Exercise result emails sent',
+ 0 < $sentCount => 'Exercise result emails sent with warnings',
+ default => 'Could not send emails',
+ };
+
+ return [
+ 'success' => $success,
+ 'message' => $message,
+ 'totalCount' => $totalCount,
+ 'sentCount' => $sentCount,
+ 'skippedCount' => $skippedCount,
+ 'failedCount' => $failedCount,
+ 'failures' => $failures,
+ ];
+ }
+
+ private function sendAttemptEmail(
+ TrackEExercise $attempt,
+ CQuiz $quiz,
+ Course $course,
+ ?Session $session,
+ User $sender,
+ Request $request,
+ string $node,
+ ): void {
+ $recipient = $attempt->getUser();
+ $resultUrl = $this->buildResultUrl($request, $node, $course, $session, (int) $quiz->getIid(), (int) $attempt->getExeId());
+ $body = $this->twig->render('@ChamiloCore/Mailer/Exercise/result_alert_body.html.twig', [
+ 'course_title' => $this->getCourseTitle($course),
+ 'test_title' => $quiz->getTitle(),
+ 'url' => $resultUrl,
+ 'teacher_name' => $sender->getFullName(),
+ ]);
+
+ $email = (new Email())
+ ->from($this->getFromAddress($request))
+ ->to(new Address($recipient->getEmail(), $this->getUserDisplayName($recipient)))
+ ->subject($this->translator->trans('Corrected test result'))
+ ->html($body)
+ ->text($this->htmlToText($body))
+ ;
+
+ $senderEmail = trim($sender->getEmail());
+ if (filter_var($senderEmail, FILTER_VALIDATE_EMAIL)) {
+ $email->replyTo(new Address($senderEmail, $this->getUserDisplayName($sender)));
+ }
+
+ $this->mailer->send($email);
+ }
+
+ private function canManageExercises(): bool
+ {
+ return $this->security->isGranted('ROLE_CURRENT_COURSE_TEACHER')
+ || $this->security->isGranted('ROLE_CURRENT_COURSE_SESSION_TEACHER');
+ }
+
+ private function getCurrentUser(): User
+ {
+ $user = $this->security->getUser();
+ if (!$user instanceof User) {
+ throw new AccessDeniedHttpException('A valid user is required.');
+ }
+
+ return $user;
+ }
+
+ private function getCourse(Request $request): Course
+ {
+ $courseId = $request->query->getInt('cid');
+ if (0 >= $courseId) {
+ throw new BadRequestHttpException('A valid course id is required.');
+ }
+
+ $course = $this->entityManager->getRepository(Course::class)->find($courseId);
+ if (!$course instanceof Course) {
+ throw new BadRequestHttpException('The requested course was not found.');
+ }
+
+ return $course;
+ }
+
+ private function getSession(Request $request): ?Session
+ {
+ $sessionId = $request->query->getInt('sid');
+ if (0 >= $sessionId) {
+ return null;
+ }
+
+ $session = $this->entityManager->getRepository(Session::class)->find($sessionId);
+ if (!$session instanceof Session) {
+ throw new BadRequestHttpException('The requested session was not found.');
+ }
+
+ return $session;
+ }
+
+ private function getExerciseFromCurrentContext(int $exerciseId, Course $course, ?Session $session): CQuiz
+ {
+ $quiz = $this->quizRepository->find($exerciseId);
+ if (!$quiz instanceof CQuiz) {
+ throw new NotFoundHttpException('The requested exercise was not found.');
+ }
+
+ $queryBuilder = $this->entityManager->createQueryBuilder()
+ ->select('quiz.iid')
+ ->addSelect('links.visibility AS linkVisibility')
+ ->from(CQuiz::class, 'quiz')
+ ->innerJoin('quiz.resourceNode', 'node')
+ ->innerJoin('node.resourceLinks', 'links')
+ ->andWhere('quiz.iid = :exerciseId')
+ ->andWhere('IDENTITY(links.course) = :courseId')
+ ->andWhere('links.deletedAt IS NULL')
+ ->andWhere('links.endVisibilityAt IS NULL')
+ ->setParameter('exerciseId', $exerciseId, Types::INTEGER)
+ ->setParameter('courseId', (int) $course->getId(), Types::INTEGER)
+ ->setMaxResults(1)
+ ;
+
+ if (null !== $session) {
+ $queryBuilder
+ ->andWhere('IDENTITY(links.session) = :sessionId')
+ ->setParameter('sessionId', (int) $session->getId(), Types::INTEGER)
+ ;
+ } else {
+ $queryBuilder->andWhere('links.session IS NULL');
+ }
+
+ $row = $queryBuilder->getQuery()->getOneOrNullResult();
+ if (null === $row) {
+ throw new AccessDeniedHttpException('The requested exercise does not belong to the current course context.');
+ }
+
+ $visibility = \is_array($row) ? (int) ($row['linkVisibility'] ?? 0) : 0;
+ if (0 !== $visibility && self::VISIBILITY_PUBLISHED !== $visibility && !$this->canManageExercises()) {
+ throw new AccessDeniedHttpException('The requested exercise is not visible.');
+ }
+
+ return $quiz;
+ }
+
+ private function getAttempt(int $attemptId, CQuiz $quiz, Course $course, ?Session $session): TrackEExercise
+ {
+ $queryBuilder = $this->entityManager->createQueryBuilder()
+ ->select('attempt', 'user')
+ ->from(TrackEExercise::class, 'attempt')
+ ->innerJoin('attempt.user', 'user')
+ ->andWhere('attempt.exeId = :attemptId')
+ ->andWhere('IDENTITY(attempt.quiz) = :exerciseId')
+ ->andWhere('IDENTITY(attempt.course) = :courseId')
+ ->setParameter('attemptId', $attemptId, Types::INTEGER)
+ ->setParameter('exerciseId', (int) $quiz->getIid(), Types::INTEGER)
+ ->setParameter('courseId', (int) $course->getId(), Types::INTEGER)
+ ->setMaxResults(1)
+ ;
+
+ if (null !== $session) {
+ $queryBuilder
+ ->andWhere('IDENTITY(attempt.session) = :sessionId')
+ ->setParameter('sessionId', (int) $session->getId(), Types::INTEGER)
+ ;
+ } else {
+ $queryBuilder->andWhere('attempt.session IS NULL');
+ }
+
+ $attempt = $queryBuilder->getQuery()->getOneOrNullResult();
+ if (!$attempt instanceof TrackEExercise) {
+ throw new NotFoundHttpException('The requested attempt was not found.');
+ }
+
+ return $attempt;
+ }
+
+ /**
+ * @return array
+ */
+ private function getReviewedAttempts(CQuiz $quiz, Course $course, ?Session $session, Request $request): array
+ {
+ $queryBuilder = $this->entityManager->createQueryBuilder()
+ ->select('DISTINCT attempt', 'user')
+ ->from(TrackEExercise::class, 'attempt')
+ ->innerJoin('attempt.user', 'user')
+ ->innerJoin('attempt.revisedAttempts', 'qualify', 'WITH', 'qualify.author > 0')
+ ->andWhere('IDENTITY(attempt.quiz) = :exerciseId')
+ ->andWhere('IDENTITY(attempt.course) = :courseId')
+ ->setParameter('exerciseId', (int) $quiz->getIid(), Types::INTEGER)
+ ->setParameter('courseId', (int) $course->getId(), Types::INTEGER)
+ ->orderBy('attempt.exeDate', 'DESC')
+ ->addOrderBy('attempt.exeId', 'DESC')
+ ;
+
+ if (null !== $session) {
+ $queryBuilder
+ ->andWhere('IDENTITY(attempt.session) = :sessionId')
+ ->setParameter('sessionId', (int) $session->getId(), Types::INTEGER)
+ ;
+ } else {
+ $queryBuilder->andWhere('attempt.session IS NULL');
+ }
+
+ $firstName = trim((string) $request->query->get('firstName', ''));
+ if ('' !== $firstName) {
+ $queryBuilder
+ ->andWhere('LOWER(user.firstname) LIKE :firstName')
+ ->setParameter('firstName', '%'.mb_strtolower($firstName).'%', Types::STRING)
+ ;
+ }
+
+ $lastName = trim((string) $request->query->get('lastName', ''));
+ if ('' !== $lastName) {
+ $queryBuilder
+ ->andWhere('LOWER(user.lastname) LIKE :lastName')
+ ->setParameter('lastName', '%'.mb_strtolower($lastName).'%', Types::STRING)
+ ;
+ }
+
+ $status = trim((string) $request->query->get('status', ''));
+ if (self::STATUS_PENDING_CORRECTION === $status) {
+ $queryBuilder->andWhere("attempt.questionsToCheck <> ''");
+ } elseif (self::STATUS_INCOMPLETE === $status) {
+ $queryBuilder
+ ->andWhere('attempt.status = :status')
+ ->setParameter('status', self::STATUS_INCOMPLETE, Types::STRING)
+ ;
+ } elseif (self::STATUS_COMPLETED === $status) {
+ $queryBuilder
+ ->andWhere('attempt.status = :status')
+ ->andWhere("attempt.questionsToCheck = ''")
+ ->setParameter('status', self::STATUS_COMPLETED, Types::STRING)
+ ;
+ }
+
+ $attempts = [];
+ foreach ($queryBuilder->getQuery()->getResult() as $attempt) {
+ if ($attempt instanceof TrackEExercise) {
+ $attempts[] = $attempt;
+ }
+ }
+
+ return $attempts;
+ }
+
+ private function buildResultUrl(
+ Request $request,
+ string $node,
+ Course $course,
+ ?Session $session,
+ int $exerciseId,
+ int $attemptId,
+ ): string {
+ $safeNode = trim($node);
+ if ('' === $safeNode) {
+ $referer = (string) $request->headers->get('referer', '');
+ if (preg_match('#/resources/exercise/([^/]+)/#', $referer, $matches)) {
+ $safeNode = rawurldecode((string) $matches[1]);
+ }
+ }
+
+ if ('' === $safeNode) {
+ $safeNode = (string) $exerciseId;
+ }
+
+ $query = [
+ 'cid' => (int) $course->getId(),
+ 'gid' => $request->query->getInt('gid'),
+ ];
+
+ if (null !== $session) {
+ $query['sid'] = (int) $session->getId();
+ }
+
+ $baseUrl = rtrim($request->getSchemeAndHttpHost().$request->getBaseUrl(), '/');
+
+ return sprintf(
+ '%s/resources/exercise/%s/%d/result/%d?%s',
+ $baseUrl,
+ rawurlencode($safeNode),
+ $exerciseId,
+ $attemptId,
+ http_build_query($query),
+ );
+ }
+
+ private function getFromAddress(Request $request): Address
+ {
+ $fromEmail = trim((string) $this->settingsManager->getSetting('mail.mailer_from_email', true));
+ $fromName = trim((string) $this->settingsManager->getSetting('mail.mailer_from_name', true));
+
+ if ('' === $fromName) {
+ $fromName = trim((string) $this->settingsManager->getSetting('platform.site_name', true));
+ }
+
+ if ('' === $fromName) {
+ $fromName = 'Chamilo';
+ }
+
+ if ('' === $fromEmail || !filter_var($fromEmail, FILTER_VALIDATE_EMAIL)) {
+ $fromEmail = trim((string) $this->settingsManager->getSetting('admin.administrator_email', true));
+ }
+
+ if ('' === $fromEmail || !filter_var($fromEmail, FILTER_VALIDATE_EMAIL)) {
+ $host = preg_replace('/[^A-Za-z0-9.-]+/', '', $request->getHost()) ?: 'example.org';
+ $fromEmail = 'noreply@'.$host;
+ }
+
+ return new Address($fromEmail, $fromName);
+ }
+
+ private function getUserDisplayName(User $user): string
+ {
+ $name = trim($user->getFullName());
+ if ('' !== $name) {
+ return $name;
+ }
+
+ return $user->getUsername();
+ }
+
+ private function getCourseTitle(Course $course): string
+ {
+ if (method_exists($course, 'getTitle')) {
+ return (string) $course->getTitle();
+ }
+
+ if (method_exists($course, 'getName')) {
+ return (string) $course->getName();
+ }
+
+ if (method_exists($course, 'getCode')) {
+ return (string) $course->getCode();
+ }
+
+ return 'Course';
+ }
+
+ private function htmlToText(string $html): string
+ {
+ return trim(html_entity_decode(strip_tags(str_replace([' ', ' ', ' '], "\n", $html)), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
+ }
+}
diff --git a/src/CoreBundle/Service/Exercise/ExerciseSpecialQuestionReportCounterService.php b/src/CoreBundle/Service/Exercise/ExerciseSpecialQuestionReportCounterService.php
new file mode 100644
index 00000000000..20cd483656b
--- /dev/null
+++ b/src/CoreBundle/Service/Exercise/ExerciseSpecialQuestionReportCounterService.php
@@ -0,0 +1,593 @@
+
+ */
+ private const FILL_BLANK_TYPES = [
+ self::FILL_IN_BLANKS => true,
+ self::FILL_IN_BLANKS_COMBINATION => true,
+ ];
+
+ /**
+ * @var array
+ */
+ private const MATCHING_TYPES = [
+ self::MATCHING => true,
+ self::MATCHING_DRAGGABLE => true,
+ self::MATCHING_COMBINATION => true,
+ self::MATCHING_DRAGGABLE_COMBINATION => true,
+ ];
+
+ /**
+ * @var array
+ */
+ private const HOTSPOT_TYPES = [
+ self::HOT_SPOT => true,
+ self::HOT_SPOT_COMBINATION => true,
+ ];
+
+ public function __construct(
+ private EntityManagerInterface $entityManager,
+ ) {}
+
+ /**
+ * @param array> $questions
+ *
+ * @return array>, totalSelections: int, countingAvailable: bool}>
+ */
+ public function buildDistributions(CQuiz $quiz, Course $course, ?Session $session, array $questions): array
+ {
+ $distributions = [];
+
+ foreach ($questions as $questionId => $question) {
+ $type = (int) ($question['type'] ?? 0);
+ $answers = $question['answers'] ?? [];
+ if (!\is_array($answers)) {
+ $answers = [];
+ }
+
+ if (isset(self::FILL_BLANK_TYPES[$type])) {
+ $distribution = $this->buildFillBlankDistribution($quiz, $course, $session, (int) $questionId, $question, $answers);
+ } elseif (isset(self::MATCHING_TYPES[$type])) {
+ $distribution = $this->buildMatchingDistribution($quiz, $course, $session, (int) $questionId, $answers);
+ } elseif (isset(self::HOTSPOT_TYPES[$type])) {
+ $distribution = $this->buildHotspotDistribution($quiz, $course, $session, (int) $questionId, $answers);
+ } else {
+ $distribution = null;
+ }
+
+ if (null !== $distribution) {
+ $distributions[(int) $questionId] = $distribution;
+ }
+ }
+
+ return $distributions;
+ }
+
+ /**
+ * @param array $question
+ * @param array> $answers
+ *
+ * @return array{answers: array>, totalSelections: int, countingAvailable: bool}|null
+ */
+ private function buildFillBlankDistribution(CQuiz $quiz, Course $course, ?Session $session, int $questionId, array $question, array $answers): ?array
+ {
+ $sourceAnswer = $answers[0] ?? null;
+ if (!\is_array($sourceAnswer)) {
+ return null;
+ }
+
+ $teacherInfo = $this->parseFillBlankAnswer((string) ($sourceAnswer['answer'] ?? ''), false);
+ $blankLabels = $teacherInfo['words_with_bracket'];
+ if ([] === $blankLabels) {
+ return [
+ 'answers' => [],
+ 'totalSelections' => 0,
+ 'countingAvailable' => true,
+ ];
+ }
+
+ $caseInsensitive = 'case:false' === (string) ($question['extra'] ?? '');
+ $userResults = [];
+ foreach ($this->getCompletedAttemptAnswerRows($quiz, $course, $session, $questionId) as $attemptRow) {
+ $userId = (int) ($attemptRow['userId'] ?? 0);
+ if (0 >= $userId) {
+ continue;
+ }
+
+ $studentInfo = $this->parseFillBlankAnswer((string) ($attemptRow['answer'] ?? ''), true);
+ foreach ($teacherInfo['words'] as $index => $correctAnswer) {
+ $studentAnswer = (string) ($studentInfo['student_answer'][$index] ?? '');
+ $studentScore = (string) ($studentInfo['student_score'][$index] ?? '');
+
+ if ('' !== $studentScore) {
+ $userResults[$userId][$index] = '1' === $studentScore ? 0 : (-2 === $this->normalizeFillBlankState($studentAnswer) ? -2 : -1);
+ continue;
+ }
+
+ if ('' === trim($studentAnswer)) {
+ if (!isset($userResults[$userId][$index])) {
+ $userResults[$userId][$index] = -2;
+ }
+ continue;
+ }
+
+ $userResults[$userId][$index] = $this->isFillBlankStudentAnswerGood($studentAnswer, (string) $correctAnswer, $caseInsensitive) ? 0 : -1;
+ }
+ }
+
+ $rows = [];
+ $totalSelections = 0;
+ $baseAnswerId = (string) ($sourceAnswer['answerId'] ?? $sourceAnswer['id'] ?? $questionId);
+ foreach ($blankLabels as $index => $label) {
+ $selectedCount = 0;
+ foreach ($userResults as $result) {
+ if (0 === (int) ($result[$index] ?? -2)) {
+ ++$selectedCount;
+ }
+ }
+
+ $totalSelections += $selectedCount;
+ $rows[] = [
+ 'id' => sprintf('%d-blank-%d', $questionId, $index + 1),
+ 'answerId' => sprintf('%s:%d', $baseAnswerId, $index + 1),
+ 'answer' => $this->stripFillBlankBrackets((string) $label, (string) $teacherInfo['blank_separator_start'], (string) $teacherInfo['blank_separator_end']),
+ 'correct' => false,
+ 'correctValue' => 0,
+ 'score' => round((float) ($teacherInfo['weighting'][$index] ?? 0.0), 2),
+ 'position' => $index + 1,
+ 'selectedCount' => $selectedCount,
+ 'selectedPercentage' => 0.0,
+ ];
+ }
+
+ return $this->withPercentages($rows, $totalSelections);
+ }
+
+ private function normalizeFillBlankState(string $studentAnswer): int
+ {
+ return '' === trim($studentAnswer) ? -2 : -1;
+ }
+
+ /**
+ * @param array> $answers
+ *
+ * @return array{answers: array>, totalSelections: int, countingAvailable: bool}
+ */
+ private function buildMatchingDistribution(CQuiz $quiz, Course $course, ?Session $session, int $questionId, array $answers): array
+ {
+ $selectedCounts = $this->getAnswerValueCounts($quiz, $course, $session, $questionId);
+ $answersByPosition = [];
+ foreach ($answers as $answer) {
+ $position = (int) ($answer['position'] ?? 0);
+ if (0 < $position) {
+ $answersByPosition[$position] = $answer;
+ }
+ }
+
+ $rows = [];
+ $totalSelections = 0;
+ foreach ($answers as $answer) {
+ $correctValue = (int) ($answer['correctValue'] ?? 0);
+ if (0 !== $correctValue) {
+ continue;
+ }
+
+ $answerId = (int) ($answer['answerId'] ?? 0);
+ if (0 >= $answerId) {
+ continue;
+ }
+
+ $sourceAnswer = $this->findMatchingSourceAnswer($answer, $answersByPosition);
+ $selectedCount = (int) ($selectedCounts[(string) $answerId] ?? 0);
+ $totalSelections += $selectedCount;
+ $rows[] = [
+ 'id' => $answerId,
+ 'answerId' => $answerId,
+ 'answer' => $this->formatMatchingAnswerLabel($sourceAnswer, (string) ($answer['answer'] ?? '')),
+ 'correct' => false,
+ 'correctValue' => 0,
+ 'score' => (float) ($sourceAnswer['score'] ?? $answer['score'] ?? 0.0),
+ 'position' => (int) ($answer['position'] ?? 0),
+ 'selectedCount' => $selectedCount,
+ 'selectedPercentage' => 0.0,
+ ];
+ }
+
+ return $this->withPercentages($rows, $totalSelections);
+ }
+
+ /**
+ * @param array $targetAnswer
+ * @param array> $answersByPosition
+ *
+ * @return array
+ */
+ private function findMatchingSourceAnswer(array $targetAnswer, array $answersByPosition): array
+ {
+ $targetPosition = (int) ($targetAnswer['position'] ?? 0);
+ foreach ($answersByPosition as $answer) {
+ if ((int) ($answer['correctValue'] ?? 0) === $targetPosition) {
+ return $answer;
+ }
+ }
+
+ return [];
+ }
+
+ private function formatMatchingAnswerLabel(array $sourceAnswer, string $targetAnswer): string
+ {
+ $source = trim(strip_tags(html_entity_decode((string) ($sourceAnswer['answer'] ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
+ $target = trim(strip_tags(html_entity_decode($targetAnswer, ENT_QUOTES | ENT_HTML5, 'UTF-8')));
+
+ if ('' === $source) {
+ return $target;
+ }
+ if ('' === $target) {
+ return $source;
+ }
+
+ return sprintf('%s → %s', $source, $target);
+ }
+
+ /**
+ * @param array> $answers
+ *
+ * @return array{answers: array>, totalSelections: int, countingAvailable: bool}|null
+ */
+ private function buildHotspotDistribution(CQuiz $quiz, Course $course, ?Session $session, int $questionId, array $answers): ?array
+ {
+ $answerIds = [];
+ foreach ($answers as $answer) {
+ $answerId = (int) ($answer['answerId'] ?? 0);
+ if (0 < $answerId) {
+ $answerIds[] = $answerId;
+ }
+ }
+ if ([] === $answerIds) {
+ return [
+ 'answers' => [],
+ 'totalSelections' => 0,
+ 'countingAvailable' => true,
+ ];
+ }
+
+ $selectedCounts = $this->getHotspotCorrectAnswerCounts($quiz, $course, $session, $questionId, $answerIds);
+ if (null === $selectedCounts) {
+ return null;
+ }
+
+ $rows = [];
+ $totalSelections = 0;
+ foreach ($answers as $answer) {
+ $answerId = (int) ($answer['answerId'] ?? 0);
+ if (0 >= $answerId) {
+ continue;
+ }
+
+ $selectedCount = (int) ($selectedCounts[$answerId] ?? 0);
+ $totalSelections += $selectedCount;
+ $rows[] = [
+ 'id' => $answerId,
+ 'answerId' => $answerId,
+ 'answer' => (string) ($answer['answer'] ?? ''),
+ 'correct' => false,
+ 'correctValue' => (int) ($answer['correctValue'] ?? 0),
+ 'score' => (float) ($answer['score'] ?? 0.0),
+ 'position' => (int) ($answer['position'] ?? 0),
+ 'selectedCount' => $selectedCount,
+ 'selectedPercentage' => 0.0,
+ ];
+ }
+
+ return $this->withPercentages($rows, $totalSelections);
+ }
+
+ /**
+ * @return array |