diff --git a/public/main/exercise/UploadAnswer.php b/public/main/exercise/UploadAnswer.php
index a8a7e053b69..df80edcb60a 100644
--- a/public/main/exercise/UploadAnswer.php
+++ b/public/main/exercise/UploadAnswer.php
@@ -2,9 +2,9 @@
/* For licensing terms, see /license.txt */
use Chamilo\CoreBundle\Entity\AttemptFile;
+use Chamilo\CoreBundle\Entity\ResourceNode;
use Chamilo\CoreBundle\Entity\TrackEAttempt;
use Chamilo\CoreBundle\Framework\Container;
-use Symfony\Component\Uid\Uuid;
/**
* Question with file upload, where the file is the answer.
@@ -63,9 +63,9 @@ public function return_header(Exercise $exercise, $counter = null, $score = [])
}
/**
- * Attach uploaded Asset(s) to the question attempt as AttemptFile.
+ * Attach uploaded ResourceNode(s) to the question attempt as AttemptFile.
*/
- public static function saveAssetInQuestionAttempt(int $attemptId, array $postedAssetIds = []): void
+ public static function saveAssetInQuestionAttempt(int $attemptId, array $postedNodeIds = []): void
{
$em = Container::getEntityManager();
@@ -78,30 +78,36 @@ public static function saveAssetInQuestionAttempt(int $attemptId, array $postedA
$questionId = (int) $attempt->getQuestionId();
$sessionKey = 'upload_answer_assets_'.$questionId;
- $assetIds = array_values(array_filter(array_map('strval', $postedAssetIds)));
- if (empty($assetIds)) {
+ $nodeIds = array_values(array_filter(array_map('intval', $postedNodeIds)));
+
+ if (empty($nodeIds)) {
$sessionVal = ChamiloSession::read($sessionKey);
- $assetIds = is_array($sessionVal) ? $sessionVal : (empty($sessionVal) ? [] : [$sessionVal]);
+ $nodeIds = is_array($sessionVal) ? $sessionVal : (empty($sessionVal) ? [] : [(int) $sessionVal]);
}
- if (empty($assetIds)) {
+
+ if (empty($nodeIds)) {
return;
}
ChamiloSession::erase($sessionKey);
- $repo = Container::getAssetRepository();
- foreach ($assetIds as $id) {
- try {
- $asset = $repo->find(Uuid::fromRfc4122($id));
- } catch (\Throwable $e) {
+ $resourceNodeRepo = Container::getResourceNodeRepository();
+
+ foreach ($nodeIds as $id) {
+ if (!$id) {
continue;
}
- if (!$asset) {
+
+ /** @var ResourceNode|null $node */
+ $node = $resourceNodeRepo->find($id);
+ if (null === $node) {
continue;
}
- $attemptFile = (new AttemptFile())->setAsset($asset);
+ $attemptFile = new AttemptFile();
+ $attemptFile->setResourceNode($node);
$attempt->addAttemptFile($attemptFile);
+
$em->persist($attemptFile);
}
diff --git a/public/main/exercise/exercise_show.php b/public/main/exercise/exercise_show.php
index 9d41e2934f8..8f316189464 100644
--- a/public/main/exercise/exercise_show.php
+++ b/public/main/exercise/exercise_show.php
@@ -657,7 +657,8 @@ function getFCK(vals, marksid) {
if (!empty($comnt)) {
echo ExerciseLib::getFeedbackText($comnt);
}
- echo ExerciseLib::getOralFeedbackAudio($id, $questionId);
+ echo ExerciseLib::getOralFeedbackAudio($id, $questionId, false);
+
echo '';
echo '
';
@@ -707,7 +708,7 @@ function getFCK(vals, marksid) {
if (!empty($comnt)) {
echo '
'.get_lang('Feedback').'';
echo ExerciseLib::getFeedbackText($comnt);
- echo ExerciseLib::getOralFeedbackAudio($id, $questionId);
+ echo ExerciseLib::getOralFeedbackAudio($id, $questionId, false);
}
}
diff --git a/public/main/exercise/oral_expression.class.php b/public/main/exercise/oral_expression.class.php
index 03943b80f11..0e8de4e3c45 100644
--- a/public/main/exercise/oral_expression.class.php
+++ b/public/main/exercise/oral_expression.class.php
@@ -4,8 +4,10 @@
use Chamilo\CoreBundle\Entity\Asset;
use Chamilo\CoreBundle\Entity\AttemptFile;
+use Chamilo\CoreBundle\Entity\ResourceNode;
use Chamilo\CoreBundle\Entity\TrackEAttempt;
use Chamilo\CoreBundle\Framework\Container;
+use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
use Symfony\Component\Uid\Uuid;
/**
@@ -17,6 +19,9 @@
*/
class OralExpression extends Question
{
+ public const RECORDING_TYPE_ATTEMPT = 1;
+ public const RECORDING_TYPE_FEEDBACK = 2;
+
public $typePicture = 'audio_question.png';
public $explanationLangVar = 'Oral expression';
public $available_extensions = ['wav', 'ogg'];
@@ -80,7 +85,8 @@ public function returnRecorder(int $trackExerciseId): string
false
);
- $recordAudioView->assign('type', Asset::EXERCISE_ATTEMPT);
+ // Student recording
+ $recordAudioView->assign('type', self::RECORDING_TYPE_ATTEMPT);
$recordAudioView->assign('t_exercise_id', $trackExerciseId);
$recordAudioView->assign('question_id', $this->id);
@@ -89,30 +95,37 @@ public function returnRecorder(int $trackExerciseId): string
return $recordAudioView->fetch($template);
}
- public static function saveAssetInQuestionAttempt($attemptId)
+ public static function saveAssetInQuestionAttempt($attemptId): void
{
$em = Container::getEntityManager();
+ /** @var TrackEAttempt|null $attempt */
$attempt = $em->find(TrackEAttempt::class, $attemptId);
- $variable = 'oral_expression_asset_'.$attempt->getQuestionId();
-
- $asset = null;
- $assetId = ChamiloSession::read($variable);
- if (!empty($assetId)) {
- $asset = Container::getAssetRepository()->find(Uuid::fromRfc4122($assetId));
+ if (null === $attempt) {
+ return;
}
- if (null === $asset) {
+ $variable = 'oral_expression_asset_'.$attempt->getQuestionId();
+ $resourceNodeId = ChamiloSession::read($variable);
+ ChamiloSession::erase($variable);
+
+ if (empty($resourceNodeId)) {
return;
}
- ChamiloSession::erase($variable);
+ /** @var ResourceNodeRepository $resourceNodeRepo */
+ $resourceNodeRepo = Container::getResourceNodeRepository();
+
+ /** @var ResourceNode|null $node */
+ $node = $resourceNodeRepo->find($resourceNodeId);
- $attemptFile = (new AttemptFile())
- ->setAsset($asset)
- ;
+ if (null === $node) {
+ return;
+ }
+ $attemptFile = new AttemptFile();
+ $attemptFile->setResourceNode($node);
$attempt->addAttemptFile($attemptFile);
$em->persist($attemptFile);
diff --git a/public/main/inc/ajax/exercise.ajax.php b/public/main/inc/ajax/exercise.ajax.php
index 39cde851ae0..91f394aa8d7 100644
--- a/public/main/inc/ajax/exercise.ajax.php
+++ b/public/main/inc/ajax/exercise.ajax.php
@@ -2,13 +2,17 @@
/* For licensing terms, see /license.txt */
-use Chamilo\CoreBundle\Entity\Asset;
+use Chamilo\CoreBundle\Entity\ResourceFile;
+use Chamilo\CoreBundle\Entity\ResourceNode;
+use Chamilo\CoreBundle\Entity\ResourceType;
use Chamilo\CoreBundle\Entity\TrackEExerciseConfirmation;
use Chamilo\CoreBundle\Entity\TrackEExercise;
use Chamilo\CoreBundle\Event\Events;
use Chamilo\CoreBundle\Framework\Container;
use Chamilo\CoreBundle\Event\ExerciseQuestionAnsweredEvent;
use ChamiloSession as Session;
+use Doctrine\Persistence\ObjectRepository;
+use Symfony\Component\HttpFoundation\File\UploadedFile;
require_once __DIR__.'/../global.inc.php';
$current_course_tool = TOOL_QUIZ;
@@ -1073,6 +1077,7 @@ function (array $exercise) {
exit;
}
+ // Chunk upload "send" phase
if (isset($_REQUEST['chunkAction']) && 'send' === $_REQUEST['chunkAction']) {
if (!empty($_FILES)) {
$tempDirectory = api_get_path(SYS_ARCHIVE_PATH);
@@ -1085,7 +1090,11 @@ function (array $exercise) {
}
foreach ($fileList as $file) {
$tmpFile = disable_dangerous_file(api_replace_dangerous_char($file['name']));
- file_put_contents($tempDirectory.$tmpFile, fopen($file['tmp_name'], 'r'), FILE_APPEND);
+ file_put_contents(
+ $tempDirectory.$tmpFile,
+ fopen($file['tmp_name'], 'r'),
+ FILE_APPEND
+ );
}
}
echo json_encode(['files' => $_FILES, 'errorStatus' => 0]);
@@ -1102,36 +1111,74 @@ function (array $exercise) {
}
$resultList = [];
- $assetRepo = Container::getAssetRepository();
+
$em = Container::getEntityManager();
+
+ /** @var ObjectRepository
$resourceTypeRepo */
+ $resourceTypeRepo = $em->getRepository(ResourceType::class);
+
+ /** @var ResourceType|null $resourceType */
+ $resourceType = $resourceTypeRepo->findOneBy(['title' => 'attempt_file']);
+ if (null === $resourceType) {
+ echo json_encode(['files' => [], 'error' => 'Missing ResourceType \"attempt_file\"']);
+ exit;
+ }
+
+ $resourceNodeRepo = Container::getResourceNodeRepository();
$basePath = rtrim(api_get_path(WEB_PATH), '/');
foreach ($fileList as $file) {
- $originalName = api_replace_dangerous_char(disable_dangerous_file($file['name'] ?? 'file.bin'));
+ $originalName = api_replace_dangerous_char(
+ disable_dangerous_file($file['name'] ?? 'file.bin')
+ );
$tmpPath = $file['tmp_name'];
+
if (isset($_REQUEST['chunkAction']) && 'done' === $_REQUEST['chunkAction']) {
$tmpPath = api_get_path(SYS_ARCHIVE_PATH).($file['name'] ?? $originalName);
}
- $asset = (new Asset())
- ->setCategory(Asset::EXERCISE_ATTEMPT)
- ->setTitle($originalName);
+ $uploadedFile = new UploadedFile(
+ $tmpPath,
+ $originalName,
+ $file['type'] ?? 'application/octet-stream',
+ $file['error'] ?? UPLOAD_ERR_OK,
+ true
+ );
+
+ $node = new ResourceNode();
+ $node->setTitle($originalName);
+ $node->setResourceType($resourceType);
+ $em->persist($node);
+
+ $resourceFile = new ResourceFile();
+ $resourceFile->setResourceNode($node);
+ $resourceFile->setFile($uploadedFile);
+ $em->persist($resourceFile);
- $assetRepo->createFromRequest($asset, ['tmp_name' => $tmpPath]);
+ $em->flush();
if (isset($_REQUEST['chunkAction']) && 'done' === $_REQUEST['chunkAction']) {
@unlink($tmpPath);
}
- $key = 'upload_answer_assets_'.$questionId;
+ $key = 'upload_answer_assets_'.$questionId;
$current = (array) ChamiloSession::read($key);
- $current[] = (string) $asset->getId();
+ $current[] = (int) $node->getId();
ChamiloSession::write($key, array_values(array_unique($current)));
+ $relativeUrl = '';
+ try {
+ $relativeUrl = $resourceNodeRepo->getResourceFileUrl($node);
+ } catch (\Throwable $e) {
+ $relativeUrl = '';
+ }
+
+ $url = $relativeUrl ? $basePath.$relativeUrl : '';
+
$resultList[] = [
'name' => api_htmlentities($originalName),
- 'asset_id' => (string) $asset->getId(),
- 'url' => $basePath.$assetRepo->getAssetUrl($asset),
+ 'asset_id' => (string) $node->getId(),
+ 'url' => $url,
'size' => isset($file['size']) ? format_file_size((int) $file['size']) : '',
'type' => api_htmlentities($file['type'] ?? ''),
'result' => Display::return_icon('accept.png', get_lang('Uploaded')),
diff --git a/public/main/inc/ajax/record_audio_rtc.ajax.php b/public/main/inc/ajax/record_audio_rtc.ajax.php
index c46f7b17449..b1d59ffca15 100644
--- a/public/main/inc/ajax/record_audio_rtc.ajax.php
+++ b/public/main/inc/ajax/record_audio_rtc.ajax.php
@@ -2,21 +2,26 @@
/* For licensing terms, see /license.txt */
-use Chamilo\CoreBundle\Entity\Asset;
use Chamilo\CoreBundle\Entity\AttemptFeedback;
use Chamilo\CoreBundle\Entity\TrackEExercise;
+use Chamilo\CoreBundle\Entity\ResourceFile;
+use Chamilo\CoreBundle\Entity\ResourceNode;
+use Chamilo\CoreBundle\Entity\ResourceType;
+use Chamilo\CoreBundle\Entity\User;
use Chamilo\CoreBundle\Framework\Container;
+use Doctrine\Persistence\ObjectRepository;
+use Symfony\Component\HttpFoundation\File\UploadedFile;
require_once __DIR__.'/../global.inc.php';
+require_once api_get_path(SYS_CODE_PATH).'exercise/oral_expression.class.php';
api_block_anonymous_users();
-$httpRequest = Container::getRequest();
-
-$type = $httpRequest->get('type');
+$httpRequest = Container::getRequest();
+$type = (int) $httpRequest->get('type');
$trackExerciseId = (int) $httpRequest->get('t_exercise');
-$questionId = (int) $httpRequest->get('question');
-$userId = api_get_user_id();
+$questionId = (int) $httpRequest->get('question');
+$userId = api_get_user_id();
if (empty($_FILES) || empty($_FILES['audio_blob'])) {
Display::addFlash(
@@ -28,52 +33,127 @@
exit;
}
+$fileInfo = $_FILES['audio_blob'];
+$originalName = $fileInfo['name'] ?? 'audio.webm';
+$mimeType = $fileInfo['type'] ?? 'audio/webm';
+$errorCode = $fileInfo['error'] ?? UPLOAD_ERR_OK;
+
+if (UPLOAD_ERR_OK !== $errorCode) {
+ Display::addFlash(
+ Display::return_message(
+ get_lang('Upload failed, please check maximum file size limits and folder rights.'),
+ 'error'
+ )
+ );
+ exit;
+}
+
$em = Container::getEntityManager();
-$assetRepo = Container::getAssetRepository();
-switch ($type) {
- case Asset::EXERCISE_ATTEMPT:
- $asset = (new Asset())
- ->setCategory(Asset::EXERCISE_ATTEMPT)
- ->setTitle($_FILES['audio_blob']['name'])
- ;
+/** @var ObjectRepository $resourceTypeRepo */
+$resourceTypeRepo = $em->getRepository(ResourceType::class);
+
+/**
+ * Create a ResourceNode + ResourceFile from an uploaded file and return the node.
+ *
+ * @param string $title
+ * @param ResourceType $resourceType
+ * @param array $fileInfo
+ * @param int|null $userId
+ */
+$createResourceNodeFromUploadedFile = static function (
+ string $title,
+ ResourceType $resourceType,
+ array $fileInfo,
+ ?int $userId = null
+) use ($em): ResourceNode {
+ $tmpName = $fileInfo['tmp_name'];
+ $originalName = $fileInfo['name'] ?? 'file.bin';
+ $mimeType = $fileInfo['type'] ?? 'application/octet-stream';
+ $errorCode = $fileInfo['error'] ?? UPLOAD_ERR_OK;
+
+ $node = new ResourceNode();
+ $node->setTitle($title !== '' ? $title : $originalName);
+ $node->setResourceType($resourceType);
+
+ if (null !== $userId && method_exists($node, 'setCreator')) {
+ $user = $em->getRepository(User::class)->find($userId);
+ if (null !== $user) {
+ $node->setCreator($user);
+ }
+ }
- $asset = $assetRepo->createFromRequest($asset, $_FILES['audio_blob']);
+ $em->persist($node);
- ChamiloSession::write("oral_expression_asset_$questionId", $asset->getId()->toRfc4122());
- break;
+ $uploadedFile = new UploadedFile(
+ $tmpName,
+ $originalName,
+ $mimeType,
+ $errorCode,
+ true
+ );
+
+ $resourceFile = new ResourceFile();
+ $resourceFile->setResourceNode($node);
+ $resourceFile->setFile($uploadedFile);
- case Asset::EXERCISE_FEEDBACK:
- /** @var TrackEExercise|null $exeAttempt */
- $exeAttempt = Container::getTrackEExerciseRepository()->find($trackExerciseId);
+ $em->persist($resourceFile);
+ $em->flush();
- if (null === $exeAttempt) {
- exit;
+ return $node;
+};
+
+switch ($type) {
+ case OralExpression::RECORDING_TYPE_ATTEMPT:
+ // Student oral expression attempt → ResourceType "attempt_file".
+ /** @var ResourceType|null $resourceType */
+ $resourceType = $resourceTypeRepo->findOneBy(['title' => 'attempt_file']);
+ if (null === $resourceType) {
+ throw new RuntimeException('ResourceType "attempt_file" not found. Audio recording cannot be stored.');
}
- // Make feedback asset unique per attempt + question (not only per question)
- $assetTitle = sprintf('feedback_%d_%d', $questionId, $trackExerciseId);
- $asset = (new Asset())
- ->setCategory(Asset::EXERCISE_FEEDBACK)
- ->setTitle($assetTitle)
- ;
+ $title = "oral_expression_attempt_q{$questionId}_u{$userId}";
+ $node = $createResourceNodeFromUploadedFile($title, $resourceType, $fileInfo, $userId);
+
+ // Keep the session key name for backward compatibility.
+ ChamiloSession::write(
+ 'oral_expression_asset_'.$questionId,
+ (string) $node->getId()
+ );
- $asset = $assetRepo->createFromRequest($asset, $_FILES['audio_blob']);
+ break;
+
+ case OralExpression::RECORDING_TYPE_FEEDBACK:
+ // Teacher feedback → ResourceType "attempt_feedback".
+ /** @var ResourceType|null $resourceType */
+ $resourceType = $resourceTypeRepo->findOneBy(['title' => 'attempt_feedback']);
+ if (null === $resourceType) {
+ throw new RuntimeException('ResourceType "attempt_feedback" not found. Audio feedback cannot be stored.');
+ }
- $attemptFeedback = (new AttemptFeedback())
- ->setAsset($asset);
+ $title = "oral_feedback_q{$questionId}_u{$userId}";
+ $node = $createResourceNodeFromUploadedFile($title, $resourceType, $fileInfo, $userId);
- $attempt = $exeAttempt->getAttemptByQuestionId($questionId);
+ /** @var TrackEExercise|null $exerciseAttempt */
+ $exerciseAttempt = Container::getTrackEExerciseRepository()->find($trackExerciseId);
+ if (null === $exerciseAttempt) {
+ break;
+ }
+ $attempt = $exerciseAttempt->getAttemptByQuestionId($questionId);
if (null === $attempt) {
- exit;
+ break;
}
+ $attemptFeedback = new AttemptFeedback();
+ $attemptFeedback->setResourceNode($node);
$attempt->addAttemptFeedback($attemptFeedback);
$em->persist($attemptFeedback);
$em->flush();
+
break;
+
default:
- throw new \Exception('Unexpected value');
+ throw new RuntimeException('Unexpected audio recording type.');
}
diff --git a/public/main/inc/lib/exercise.lib.php b/public/main/inc/lib/exercise.lib.php
index 63313156981..0e0033022e8 100644
--- a/public/main/inc/lib/exercise.lib.php
+++ b/public/main/inc/lib/exercise.lib.php
@@ -2,14 +2,15 @@
/* For licensing terms, see /license.txt */
-use Chamilo\CoreBundle\Entity\Asset;
use Chamilo\CoreBundle\Entity\Course as CourseEntity;
+use Chamilo\CoreBundle\Entity\ResourceNode;
use Chamilo\CoreBundle\Entity\Session as SessionEntity;
use Chamilo\CoreBundle\Entity\GradebookCategory;
use Chamilo\CoreBundle\Entity\TrackEExercise;
use Chamilo\CoreBundle\Enums\ActionIcon;
use Chamilo\CoreBundle\Framework\Container;
use Chamilo\CoreBundle\Helpers\ChamiloHelper;
+use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
use Chamilo\CourseBundle\Entity\CLpItem;
use Chamilo\CourseBundle\Entity\CLpItemView;
use Chamilo\CourseBundle\Entity\CQuiz;
@@ -5529,26 +5530,34 @@ public static function getFeedbackText($message)
public static function getOralFeedbackForm($attemptId, $questionId)
{
$view = new Template('', false, false, false, false, false, false);
- $view->assign('type', Asset::EXERCISE_FEEDBACK);
+
+ $view->assign('type', OralExpression::RECORDING_TYPE_FEEDBACK);
$view->assign('question_id', $questionId);
$view->assign('t_exercise_id', $attemptId);
+
$template = $view->get_template('exercise/oral_expression.html.twig');
return $view->fetch($template);
}
/**
- * Retrieves the generated audio files for an oral question in an exercise attempt.
+ * Get oral file audio for a given exercise attempt and question.
*
- * @param int $trackExerciseId The ID of the tracked exercise.
- * @param int $questionId The ID of the question.
- * @param bool $returnUrls (Optional) If set to true, only the URLs of the audio files are returned. Default is false.
+ * If $returnUrls is true, returns an array of URLs.
+ * Otherwise returns the HTML string with