diff --git a/assets/vue/components/basecomponents/ChamiloIcons.js b/assets/vue/components/basecomponents/ChamiloIcons.js
index 3466f590755..6b23dd2061b 100644
--- a/assets/vue/components/basecomponents/ChamiloIcons.js
+++ b/assets/vue/components/basecomponents/ChamiloIcons.js
@@ -55,6 +55,7 @@ export const chamiloIconToClass = {
"file-generic": "mdi mdi-file",
"file-image": "mdi mdi-file-image",
"file-pdf": "mdi mdi-file-pdf-box",
+ "file-swap": "mdi mdi-swap-horizontal",
"file-text": "mdi mdi-file-document",
"file-upload": "mdi mdi-file-upload",
"file-video": "mdi mdi-file-video",
diff --git a/assets/vue/views/documents/DocumentsList.vue b/assets/vue/views/documents/DocumentsList.vue
index a82904a3ea5..569f2b4a9b9 100644
--- a/assets/vue/views/documents/DocumentsList.vue
+++ b/assets/vue/views/documents/DocumentsList.vue
@@ -182,6 +182,14 @@
type="secondary"
@click="openMoveDialog(slotProps.data)"
/>
+
+
+
+
+
{
const isHtmlFile = (fileData) => isHtml(fileData)
+const isReplaceDialogVisible = ref(false)
+const selectedReplaceFile = ref(null)
+const documentToReplace = ref(null)
+
onMounted(async () => {
isAllowedToEdit.value = await checkIsAllowedToEdit(true, true, true)
filters.value.loadNode = 1
@@ -784,6 +811,41 @@ function openMoveDialog(document) {
isMoveDialogVisible.value = true
}
+function openReplaceDialog(document) {
+ documentToReplace.value = document
+ isReplaceDialogVisible.value = true
+}
+
+async function replaceDocument() {
+ if (!selectedReplaceFile.value) {
+ notification.showErrorNotification(t("No file selected."))
+ return
+ }
+
+ if (documentToReplace.value.filetype !== 'file') {
+ notification.showErrorNotification(t("Only files can be replaced."))
+ return
+ }
+
+ const formData = new FormData()
+ console.log(selectedReplaceFile.value)
+ formData.append('file', selectedReplaceFile.value)
+
+ try {
+ await axios.post(`/api/documents/${documentToReplace.value.iid}/replace`, formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ })
+ notification.showSuccessNotification(t("Document replaced successfully."))
+ isReplaceDialogVisible.value = false
+ onUpdateOptions(options.value)
+ } catch (error) {
+ notification.showErrorNotification(t("Error replacing document."))
+ console.error(error)
+ }
+}
+
async function fetchFolders(nodeId = null, parentPath = "") {
const foldersList = [
{
diff --git a/src/CoreBundle/Controller/Api/ReplaceDocumentFileAction.php b/src/CoreBundle/Controller/Api/ReplaceDocumentFileAction.php
new file mode 100644
index 00000000000..d8ef1277711
--- /dev/null
+++ b/src/CoreBundle/Controller/Api/ReplaceDocumentFileAction.php
@@ -0,0 +1,99 @@
+uploadBasePath = $kernel->getProjectDir() . '/var/upload/resource';
+ }
+
+ public function __invoke(
+ CDocument $document,
+ Request $request,
+ ResourceNodeRepository $resourceNodeRepository,
+ EntityManagerInterface $em
+ ): Response {
+ $uploadedFile = $request->files->get('file');
+ if (!$uploadedFile) {
+ throw new BadRequestHttpException('"file" is required.');
+ }
+
+ $resourceNode = $document->getResourceNode();
+ if (!$resourceNode) {
+ throw new BadRequestHttpException('ResourceNode not found.');
+ }
+
+ $resourceFile = $resourceNode->getFirstResourceFile();
+ if (!$resourceFile) {
+ throw new BadRequestHttpException('No file found in the resource node.');
+ }
+
+ $filePath = $this->uploadBasePath . $resourceNodeRepository->getFilename($resourceFile);
+ if (!$filePath) {
+ throw new BadRequestHttpException('File path could not be resolved.');
+ }
+
+ $this->prepareDirectory($filePath);
+
+ try {
+ $uploadedFile->move(dirname($filePath), basename($filePath));
+ } catch (FileException $e) {
+ throw new BadRequestHttpException(sprintf('Failed to move the file: %s', $e->getMessage()));
+ }
+
+ $movedFilePath = $filePath;
+ if (!file_exists($movedFilePath)) {
+ throw new \RuntimeException('The moved file does not exist at the expected location.');
+ }
+ $fileSize = filesize($movedFilePath);
+ $resourceFile->setSize($fileSize);
+
+ $newFileName = $uploadedFile->getClientOriginalName();
+ $document->setTitle($newFileName);
+ $resourceFile->setOriginalName($newFileName);
+
+ $resourceNode->setUpdatedAt(new \DateTime());
+
+ $em->persist($document);
+ $em->persist($resourceFile);
+ $em->flush();
+
+ return new Response('Document replaced successfully.', Response::HTTP_OK);
+ }
+
+ /**
+ * Prepares the directory to ensure it exists and is writable.
+ */
+ protected function prepareDirectory(string $filePath): void
+ {
+ $directory = dirname($filePath);
+
+ if (!is_dir($directory)) {
+ if (!mkdir($directory, 0775, true) && !is_dir($directory)) {
+ throw new \RuntimeException(sprintf('Unable to create directory "%s".', $directory));
+ }
+ }
+
+ if (!is_writable($directory)) {
+ throw new \RuntimeException(sprintf('Directory "%s" is not writable.', $directory));
+ }
+ }
+
+}
diff --git a/src/CourseBundle/Entity/CDocument.php b/src/CourseBundle/Entity/CDocument.php
index 92dfc44e5d1..cae4b29c837 100644
--- a/src/CourseBundle/Entity/CDocument.php
+++ b/src/CourseBundle/Entity/CDocument.php
@@ -18,6 +18,7 @@
use ApiPlatform\Metadata\Put;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use Chamilo\CoreBundle\Controller\Api\CreateDocumentFileAction;
+use Chamilo\CoreBundle\Controller\Api\ReplaceDocumentFileAction;
use Chamilo\CoreBundle\Controller\Api\UpdateDocumentFileAction;
use Chamilo\CoreBundle\Controller\Api\UpdateVisibilityDocument;
use Chamilo\CoreBundle\Entity\AbstractResource;
@@ -60,6 +61,31 @@
security: "is_granted('EDIT', object.resourceNode)",
deserialize: true
),
+ new Post(
+ uriTemplate: '/documents/{iid}/replace',
+ controller: ReplaceDocumentFileAction::class,
+ openapiContext: [
+ 'summary' => 'Replace a document file, maintaining the same IDs.',
+ 'requestBody' => [
+ 'content' => [
+ 'multipart/form-data' => [
+ 'schema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'file' => [
+ 'type' => 'string',
+ 'format' => 'binary',
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER') or is_granted('ROLE_TEACHER')",
+ validationContext: ['groups' => ['Default', 'media_object_create', 'document:write']],
+ deserialize: false
+ ),
new Get(security: "is_granted('VIEW', object.resourceNode)"),
new Delete(security: "is_granted('DELETE', object.resourceNode)"),
new Post(