Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Documents: Add replace document functionality - refs #5957 #6022

Merged
merged 3 commits into from
Mar 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions assets/vue/components/basecomponents/ChamiloIcons.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
62 changes: 62 additions & 0 deletions assets/vue/views/documents/DocumentsList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,14 @@
type="secondary"
@click="openMoveDialog(slotProps.data)"
/>
<BaseButton
:disabled="slotProps.data.filetype !== 'file'"
:title="slotProps.data.filetype !== 'file' ? t('Replace (files only)') : t('Replace file')"
icon="file-swap"
size="small"
type="secondary"
@click="slotProps.data.filetype === 'file' && openReplaceDialog(slotProps.data)"
/>
<BaseButton
:title="t('Information')"
icon="information"
Expand Down Expand Up @@ -352,6 +360,21 @@
</div>
</BaseDialogConfirmCancel>

<BaseDialogConfirmCancel
v-model:is-visible="isReplaceDialogVisible"
:title="t('Replace file')"
@confirm-clicked="replaceDocument"
@cancel-clicked="isReplaceDialogVisible = false"
>
<BaseFileUpload
id="replace-file"
:label="t('Select replacement file')"
accept="*/*"
model-value="selectedReplaceFile"
@file-selected="selectedReplaceFile = $event"
/>
</BaseDialogConfirmCancel>

<BaseDialog
v-model:is-visible="isFileUsageDialogVisible"
:style="{ width: '28rem' }"
Expand Down Expand Up @@ -519,6 +542,10 @@ const isSessionDocument = (item) => {

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
Expand Down Expand Up @@ -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("File replaced"))
isReplaceDialogVisible.value = false
onUpdateOptions(options.value)
} catch (error) {
notification.showErrorNotification(t("Error replacing file."))
console.error(error)
}
}

async function fetchFolders(nodeId = null, parentPath = "") {
const foldersList = [
{
Expand Down
99 changes: 99 additions & 0 deletions src/CoreBundle/Controller/Api/ReplaceDocumentFileAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

/* For licensing terms, see /license.txt */

namespace Chamilo\CoreBundle\Controller\Api;

use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
use Chamilo\CourseBundle\Entity\CDocument;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\HttpFoundation\File\Exception\FileException;

class ReplaceDocumentFileAction extends BaseResourceFileAction
{
private string $uploadBasePath;

public function __construct(KernelInterface $kernel)
{
$this->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));
}
}

}
26 changes: 26 additions & 0 deletions src/CourseBundle/Entity/CDocument.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
Loading