Skip to content
New issue

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

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

Already on GitHub? # to your account

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

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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')"
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 document')"
@confirm-clicked="replaceDocument"
@cancel-clicked="isReplaceDialogVisible = false"
>
<BaseFileUpload
id="replace-file"
:label="t('Select file to replace')"
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("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 = [
{
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);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a single space around assignment operators


/* 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