Documents: Add support for URL-specific document variations - refs #5956

pull/5976/head
Christian Beeznest 9 months ago
parent c0930e7678
commit 130b5cc2ba
  1. 1
      assets/vue/components/basecomponents/ChamiloIcons.js
  2. 5
      assets/vue/router/documents.js
  3. 189
      assets/vue/views/documents/AddVariation.vue
  4. 24
      assets/vue/views/documents/DocumentsList.vue
  5. 71
      src/CoreBundle/Controller/AddVariantResourceFileAction.php
  6. 1
      src/CoreBundle/Controller/PlatformConfigurationController.php
  7. 147
      src/CoreBundle/Controller/ResourceController.php
  8. 49
      src/CoreBundle/Entity/ResourceFile.php
  9. 60
      src/CoreBundle/Migrations/Schema/V200/Version20241214083500.php
  10. 37
      src/CoreBundle/Repository/ResourceNodeRepository.php
  11. 2
      src/CoreBundle/Settings/CourseSettingsSchema.php

@ -58,6 +58,7 @@ export const chamiloIconToClass = {
"file-text": "mdi mdi-file-document", "file-text": "mdi mdi-file-document",
"file-upload": "mdi mdi-file-upload", "file-upload": "mdi mdi-file-upload",
"file-video": "mdi mdi-file-video", "file-video": "mdi mdi-file-video",
"file-replace": "mdi mdi-file-replace",
"fit-to-screen": "", "fit-to-screen": "",
"folder-generic": "mdi mdi-folder", "folder-generic": "mdi mdi-folder",
"folder-multiple-plus": "mdi mdi-folder-multiple-plus", "folder-multiple-plus": "mdi mdi-folder-multiple-plus",

@ -42,6 +42,11 @@ export default {
path: 'show', path: 'show',
component: () => import('../views/documents/DocumentShow.vue') component: () => import('../views/documents/DocumentShow.vue')
}, },
{
name: 'DocumentsAddVariation',
path: 'add_variation/:resourceFileId',
component: () => import('../views/documents/AddVariation.vue'),
},
{ {
name: 'DocumentForHtmlEditor', name: 'DocumentForHtmlEditor',
path: 'manager', path: 'manager',

@ -0,0 +1,189 @@
<template>
<div class="p-4 space-y-8">
<SectionHeader :title="t('Add File Variation')">
<BaseButton
:label="t('Back to Documents')"
icon="back"
type="gray"
@click="goBack"
/>
</SectionHeader>
<div v-if="originalFile" class="bg-gray-100 p-4 rounded-md shadow-md">
<h3 class="text-lg font-semibold">{{ t('Original File') }}</h3>
<p><strong>{{ t('Title:') }}</strong> {{ originalFile.originalName }}</p>
<p><strong>{{ t('Format:') }}</strong> {{ originalFile.mimeType }}</p>
<p><strong>{{ t('Size:') }}</strong> {{ prettyBytes(originalFile.size) }}</p>
</div>
<div class="space-y-6">
<h3 class="text-xl font-bold">{{ t('Upload New Variation') }}</h3>
<form @submit.prevent="uploadVariation" class="flex flex-col space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<BaseFileUpload
@file-selected="onFileSelected"
:label="t('Choose file')"
accept=".pdf,.html,.docx,.mp4"
required
class="w-full"
/>
<Dropdown
v-model="selectedAccessUrl"
:options="accessUrls"
optionLabel="url"
optionValue="id"
placeholder="Select a URL"
class="w-full"
/>
</div>
<div class="flex justify-end">
<BaseButton
:label="t('Upload')"
icon="file-upload"
type="success"
:disabled="!file"
@click="uploadVariant(file, originalFile?.resourceNode?.id, selectedAccessUrl)"
/>
</div>
</form>
</div>
<div>
<h3 class="text-xl font-bold mb-4">{{ t('Current Variations') }}</h3>
<DataTable :value="variations" class="w-full">
<Column field="title" :header="t('Title')" />
<Column field="mimeType" :header="t('Format')" />
<Column field="size" :header="t('Size')">
<template #body="slotProps">
{{ prettyBytes(slotProps.data.size) }}
</template>
</Column>
<Column field="updatedAt" :header="t('Updated At')" />
<Column field="url" :header="t('URL')">
<template #body="slotProps">
<a
:href="slotProps.data.path"
target="_blank"
class="text-blue-500 hover:underline"
>
{{ t('View') }}
</a>
</template>
</Column>
<Column field="creator" :header="t('Creator')" />
</DataTable>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from "vue"
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import axios from 'axios'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import SectionHeader from "../../components/layout/SectionHeader.vue"
import BaseButton from "../../components/basecomponents/BaseButton.vue"
import BaseFileUpload from "../../components/basecomponents/BaseFileUpload.vue"
import prettyBytes from 'pretty-bytes'
import { useStore } from "vuex"
import { useCidReq } from "../../composables/cidReq"
const store = useStore()
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const { cid, sid, gid } = useCidReq()
const file = ref(null)
const variations = ref([])
const originalFile = ref(null)
const resourceFileId = route.params.resourceFileId;
const selectedAccessUrl = ref(null)
const accessUrls = ref([])
onMounted(async () => {
await fetchOriginalFile()
await fetchVariations()
await fetchAccessUrls()
})
async function fetchVariations() {
if (!originalFile.value?.resourceNode?.id) {
console.error('ResourceNodeId is undefined. Cannot fetch variations.')
return
}
try {
const resourceNodeId = originalFile.value.resourceNode.id
const response = await axios.get(`/r/resource_files/${resourceNodeId}/variants`)
variations.value = response.data
} catch (error) {
console.error('Error fetching variations:', error)
}
}
async function fetchAccessUrls() {
try {
const response = await axios.get('/api/access_urls')
if (Array.isArray(response.data['hydra:member'])) {
const currentAccessUrlId = window.access_url_id
accessUrls.value = response.data['hydra:member'].filter(
(url) => url.id !== currentAccessUrlId
)
} else {
accessUrls.value = []
}
} catch (error) {
console.error('Error fetching access URLs:', error)
accessUrls.value = []
}
}
async function fetchOriginalFile() {
try {
const response = await axios.get(`/api/resource_files/${resourceFileId}`)
originalFile.value = response.data
} catch (error) {
console.error('Error fetching original file:', error)
}
}
async function uploadVariant(file, resourceNodeId, accessUrlId) {
if (!resourceNodeId) {
console.error('ResourceNodeId is undefined. Check originalFile:', originalFile.value)
return
}
const formData = new FormData()
formData.append('file', file)
formData.append('resourceNodeId', resourceNodeId)
if (accessUrlId) {
formData.append('accessUrlId', accessUrlId)
}
try {
const response = await axios.post('/api/resource_files/add_variant', formData)
console.log('Variant uploaded or updated successfully:', response.data)
await fetchVariations()
file.value = null
selectedAccessUrl.value = null
} catch (error) {
console.error('Error uploading variant:', error)
}
}
function onFileSelected(selectedFile) {
file.value = selectedFile
}
function goBack() {
let queryParams = { cid, sid, gid }
router.push({ name: "DocumentsList", params: { node: parent.id }, query: queryParams })
}
</script>

@ -205,6 +205,15 @@
@click="btnChangeVisibilityOnClick(slotProps.data)" @click="btnChangeVisibilityOnClick(slotProps.data)"
/> />
<BaseButton
v-if="canEdit(slotProps.data) && allowAccessUrlFiles && isFile(slotProps.data)"
icon="file-replace"
size="small"
type="secondary"
:title="t('Add File Variation')"
@click="goToAddVariation(slotProps.data)"
/>
<BaseButton <BaseButton
v-if="canEdit(slotProps.data)" v-if="canEdit(slotProps.data)"
icon="edit" icon="edit"
@ -436,17 +445,21 @@ import BaseFileUpload from "../../components/basecomponents/BaseFileUpload.vue"
import { useDocumentActionButtons } from "../../composables/document/documentActionButtons" import { useDocumentActionButtons } from "../../composables/document/documentActionButtons"
import SectionHeader from "../../components/layout/SectionHeader.vue" import SectionHeader from "../../components/layout/SectionHeader.vue"
import { checkIsAllowedToEdit } from "../../composables/userPermissions" import { checkIsAllowedToEdit } from "../../composables/userPermissions"
import { usePlatformConfig } from "../../store/platformConfig"
const store = useStore() const store = useStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const securityStore = useSecurityStore() const securityStore = useSecurityStore()
const platformConfigStore = usePlatformConfig()
const allowAccessUrlFiles = computed(() => "false" !== platformConfigStore.getSetting("course.access_url_specific_files"))
const { t } = useI18n() const { t } = useI18n()
const { filters, options, onUpdateOptions, deleteItem } = useDatatableList("Documents") const { filters, options, onUpdateOptions, deleteItem } = useDatatableList("Documents")
const notification = useNotification() const notification = useNotification()
const { cid, sid, gid } = useCidReq() const { cid, sid, gid } = useCidReq()
const { isImage, isHtml } = useFileUtils() const { isImage, isHtml, isFile } = useFileUtils()
const { relativeDatetime } = useFormatDate() const { relativeDatetime } = useFormatDate()
const isAllowedToEdit = ref(false) const isAllowedToEdit = ref(false)
@ -559,6 +572,15 @@ const showBackButtonIfNotRootFolder = computed(() => {
return resourceNode.value.resourceType.title !== "courses" return resourceNode.value.resourceType.title !== "courses"
}) })
function goToAddVariation(item) {
const resourceFileId = item.resourceNode.firstResourceFile.id
router.push({
name: 'DocumentsAddVariation',
params: { resourceFileId, node: route.params.node },
query: { cid, sid, gid },
})
}
function back() { function back() {
if (!resourceNode.value) { if (!resourceNode.value) {
return return

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\CoreBundle\Controller;
use Chamilo\CoreBundle\Entity\ResourceFile;
use Chamilo\CoreBundle\Entity\ResourceNode;
use Chamilo\CoreBundle\Entity\AccessUrl;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class AddVariantResourceFileAction
{
public function __invoke(Request $request, EntityManagerInterface $em): ResourceFile
{
$uploadedFile = $request->files->get('file');
if (!$uploadedFile) {
throw new BadRequestHttpException('"file" is required');
}
$resourceNodeId = $request->get('resourceNodeId');
if (!$resourceNodeId) {
throw new BadRequestHttpException('"resourceNodeId" is required');
}
$resourceNode = $em->getRepository(ResourceNode::class)->find($resourceNodeId);
if (!$resourceNode) {
throw new NotFoundHttpException('ResourceNode not found');
}
$accessUrlId = $request->get('accessUrlId');
$accessUrl = null;
if ($accessUrlId) {
$accessUrl = $em->getRepository(AccessUrl::class)->find($accessUrlId);
if (!$accessUrl) {
throw new NotFoundHttpException('AccessUrl not found');
}
}
$existingResourceFile = $em->getRepository(ResourceFile::class)->findOneBy([
'resourceNode' => $resourceNode,
'accessUrl' => $accessUrl,
]);
if ($existingResourceFile) {
$existingResourceFile->setTitle($uploadedFile->getClientOriginalName());
$existingResourceFile->setFile($uploadedFile);
$existingResourceFile->setUpdatedAt(\DateTime::createFromImmutable(new \DateTimeImmutable()));
$resourceFile = $existingResourceFile;
} else {
$resourceFile = new ResourceFile();
$resourceFile->setTitle($uploadedFile->getClientOriginalName());
$resourceFile->setFile($uploadedFile);
$resourceFile->setResourceNode($resourceNode);
if ($accessUrl) {
$resourceFile->setAccessUrl($accessUrl);
}
}
$em->persist($resourceFile);
$em->flush();
return $resourceFile;
}
}

@ -92,6 +92,7 @@ class PlatformConfigurationController extends AbstractController
'social.hide_social_groups_block', 'social.hide_social_groups_block',
'course.show_course_duration', 'course.show_course_duration',
'exercise.allow_exercise_auto_launch', 'exercise.allow_exercise_auto_launch',
'course.access_url_specific_files',
]; ];
$user = $this->userHelper->getCurrent(); $user = $this->userHelper->getCurrent();

@ -7,15 +7,19 @@ declare(strict_types=1);
namespace Chamilo\CoreBundle\Controller; namespace Chamilo\CoreBundle\Controller;
use Chamilo\CoreBundle\Entity\Course; use Chamilo\CoreBundle\Entity\Course;
use Chamilo\CoreBundle\Entity\ResourceFile;
use Chamilo\CoreBundle\Entity\ResourceLink; use Chamilo\CoreBundle\Entity\ResourceLink;
use Chamilo\CoreBundle\Entity\ResourceNode; use Chamilo\CoreBundle\Entity\ResourceNode;
use Chamilo\CoreBundle\Entity\Session; use Chamilo\CoreBundle\Entity\Session;
use Chamilo\CoreBundle\Entity\User; use Chamilo\CoreBundle\Entity\User;
use Chamilo\CoreBundle\Repository\ResourceFileRepository;
use Chamilo\CoreBundle\Repository\ResourceNodeRepository; use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
use Chamilo\CoreBundle\Repository\ResourceWithLinkInterface; use Chamilo\CoreBundle\Repository\ResourceWithLinkInterface;
use Chamilo\CoreBundle\Repository\TrackEDownloadsRepository; use Chamilo\CoreBundle\Repository\TrackEDownloadsRepository;
use Chamilo\CoreBundle\Security\Authorization\Voter\ResourceNodeVoter; use Chamilo\CoreBundle\Security\Authorization\Voter\ResourceNodeVoter;
use Chamilo\CoreBundle\ServiceHelper\AccessUrlHelper;
use Chamilo\CoreBundle\ServiceHelper\UserHelper; use Chamilo\CoreBundle\ServiceHelper\UserHelper;
use Chamilo\CoreBundle\Settings\SettingsManager;
use Chamilo\CoreBundle\Tool\ToolChain; use Chamilo\CoreBundle\Tool\ToolChain;
use Chamilo\CoreBundle\Traits\ControllerTrait; use Chamilo\CoreBundle\Traits\ControllerTrait;
use Chamilo\CoreBundle\Traits\CourseControllerTrait; use Chamilo\CoreBundle\Traits\CourseControllerTrait;
@ -59,6 +63,7 @@ class ResourceController extends AbstractResourceController implements CourseCon
public function __construct( public function __construct(
private readonly UserHelper $userHelper, private readonly UserHelper $userHelper,
private readonly ResourceNodeRepository $resourceNodeRepository, private readonly ResourceNodeRepository $resourceNodeRepository,
private readonly ResourceFileRepository $resourceFileRepository
) {} ) {}
#[Route(path: '/{tool}/{type}/{id}/disk_space', methods: ['GET', 'POST'], name: 'chamilo_core_resource_disk_space')] #[Route(path: '/{tool}/{type}/{id}/disk_space', methods: ['GET', 'POST'], name: 'chamilo_core_resource_disk_space')]
@ -136,21 +141,55 @@ class ResourceController extends AbstractResourceController implements CourseCon
* View file of a resource node. * View file of a resource node.
*/ */
#[Route('/{tool}/{type}/{id}/view', name: 'chamilo_core_resource_view', methods: ['GET'])] #[Route('/{tool}/{type}/{id}/view', name: 'chamilo_core_resource_view', methods: ['GET'])]
public function view(Request $request, TrackEDownloadsRepository $trackEDownloadsRepository): Response public function view(
{ Request $request,
TrackEDownloadsRepository $trackEDownloadsRepository,
SettingsManager $settingsManager,
AccessUrlHelper $accessUrlHelper
): Response {
$id = $request->get('id'); $id = $request->get('id');
$filter = (string) $request->get('filter'); // See filters definitions in /config/services.yml. $resourceFileId = $request->get('resourceFileId');
$filter = (string) $request->get('filter');
$resourceNode = $this->getResourceNodeRepository()->findOneBy(['uuid' => $id]); $resourceNode = $this->getResourceNodeRepository()->findOneBy(['uuid' => $id]);
if (null === $resourceNode) { if (null === $resourceNode) {
throw new FileNotFoundException($this->trans('Resource not found')); throw new FileNotFoundException($this->trans('Resource not found'));
} }
$resourceFile = null;
if ($resourceFileId) {
$resourceFile = $this->resourceFileRepository->find($resourceFileId);
}
if (!$resourceFile) {
$accessUrlSpecificFiles = $settingsManager->getSetting('course.access_url_specific_files') && $accessUrlHelper->isMultiple();
$currentUrl = $accessUrlHelper->getCurrent()?->getUrl();
$resourceFiles = $resourceNode->getResourceFiles();
if ($accessUrlSpecificFiles) {
foreach ($resourceFiles as $file) {
if ($file->getAccessUrl() && $file->getAccessUrl()->getUrl() === $currentUrl) {
$resourceFile = $file;
break;
}
}
}
if (!$resourceFile) {
$resourceFile = $resourceFiles->filter(fn($file) => $file->getAccessUrl() === null)->first();
}
}
if (!$resourceFile) {
throw new FileNotFoundException($this->trans('Resource file not found for the given resource node'));
}
$user = $this->userHelper->getCurrent(); $user = $this->userHelper->getCurrent();
$firstResourceLink = $resourceNode->getResourceLinks()->first(); $firstResourceLink = $resourceNode->getResourceLinks()->first();
$firstResourceFile = $resourceNode->getResourceFiles()->first(); if ($firstResourceLink && $user) {
if ($firstResourceLink && $user && $firstResourceFile) { $url = $resourceFile->getOriginalName();
$url = $firstResourceFile->getOriginalName();
$trackEDownloadsRepository->saveDownload($user, $firstResourceLink, $url); $trackEDownloadsRepository->saveDownload($user, $firstResourceLink, $url);
} }
@ -166,7 +205,7 @@ class ResourceController extends AbstractResourceController implements CourseCon
); );
} }
return $this->processFile($request, $resourceNode, 'show', $filter, $allUserInfo); return $this->processFile($request, $resourceNode, 'show', $filter, $allUserInfo, $resourceFile);
} }
/** /**
@ -212,8 +251,12 @@ class ResourceController extends AbstractResourceController implements CourseCon
* Download file of a resource node. * Download file of a resource node.
*/ */
#[Route('/{tool}/{type}/{id}/download', name: 'chamilo_core_resource_download', methods: ['GET'])] #[Route('/{tool}/{type}/{id}/download', name: 'chamilo_core_resource_download', methods: ['GET'])]
public function download(Request $request, TrackEDownloadsRepository $trackEDownloadsRepository): Response public function download(
{ Request $request,
TrackEDownloadsRepository $trackEDownloadsRepository,
SettingsManager $settingsManager,
AccessUrlHelper $accessUrlHelper
): Response {
$id = $request->get('id'); $id = $request->get('id');
$resourceNode = $this->getResourceNodeRepository()->findOneBy(['uuid' => $id]); $resourceNode = $this->getResourceNodeRepository()->findOneBy(['uuid' => $id]);
@ -229,21 +272,38 @@ class ResourceController extends AbstractResourceController implements CourseCon
$this->trans('Unauthorised access to resource') $this->trans('Unauthorised access to resource')
); );
$accessUrlSpecificFiles = $settingsManager->getSetting('course.access_url_specific_files') && $accessUrlHelper->isMultiple();
$currentUrl = $accessUrlHelper->getCurrent()?->getUrl();
$resourceFiles = $resourceNode->getResourceFiles();
$resourceFile = null;
if ($accessUrlSpecificFiles) {
foreach ($resourceFiles as $file) {
if ($file->getAccessUrl() && $file->getAccessUrl()->getUrl() === $currentUrl) {
$resourceFile = $file;
break;
}
}
}
$resourceFile ??= $resourceFiles->filter(fn($file) => $file->getAccessUrl() === null)->first();
// If resource node has a file just download it. Don't download the children. // If resource node has a file just download it. Don't download the children.
if ($resourceNode->hasResourceFile()) { if ($resourceFile) {
$user = $this->userHelper->getCurrent(); $user = $this->userHelper->getCurrent();
$firstResourceLink = $resourceNode->getResourceLinks()->first(); $firstResourceLink = $resourceNode->getResourceLinks()->first();
if ($firstResourceLink) {
$url = $resourceNode->getResourceFiles()->first()->getOriginalName(); if ($firstResourceLink && $user) {
$url = $resourceFile->getOriginalName();
$trackEDownloadsRepository->saveDownload($user, $firstResourceLink, $url); $trackEDownloadsRepository->saveDownload($user, $firstResourceLink, $url);
} }
// Redirect to download single file. // Redirect to download single file.
return $this->processFile($request, $resourceNode, 'download'); return $this->processFile($request, $resourceNode, 'download', '', null, $resourceFile);
} }
$zipName = $resourceNode->getSlug().'.zip'; $zipName = $resourceNode->getSlug().'.zip';
// $rootNodePath = $resourceNode->getPathForDisplay();
$resourceNodeRepo = $repo->getResourceNodeRepository(); $resourceNodeRepo = $repo->getResourceNodeRepository();
$type = $repo->getResourceType(); $type = $repo->getResourceType();
@ -282,12 +342,14 @@ class ResourceController extends AbstractResourceController implements CourseCon
/** @var ResourceNode $node */ /** @var ResourceNode $node */
foreach ($children as $node) { foreach ($children as $node) {
$stream = $repo->getResourceNodeFileStream($node); $resourceFiles = $node->getResourceFiles();
$fileName = $node->getResourceFiles()->first()->getOriginalName(); $resourceFile = $resourceFiles->filter(fn($file) => $file->getAccessUrl() === null)->first();
// $fileToDisplay = basename($node->getPathForDisplay());
// $fileToDisplay = str_replace($rootNodePath, '', $node->getPathForDisplay()); if ($resourceFile) {
// error_log($fileToDisplay); $stream = $repo->getResourceNodeFileStream($resourceFile);
$zip->addFileFromStream($fileName, $stream); $fileName = $resourceFile->getOriginalName();
$zip->addFileFromStream($fileName, $stream);
}
} }
$zip->finish(); $zip->finish();
} }
@ -458,7 +520,38 @@ class ResourceController extends AbstractResourceController implements CourseCon
return new Response(null, Response::HTTP_NO_CONTENT); return new Response(null, Response::HTTP_NO_CONTENT);
} }
private function processFile(Request $request, ResourceNode $resourceNode, string $mode = 'show', string $filter = '', ?array $allUserInfo = null): mixed #[Route('/resource_files/{resourceNodeId}/variants', name: 'chamilo_core_resource_files_variants', methods: ['GET'])]
public function getVariants(string $resourceNodeId, EntityManagerInterface $em): JsonResponse
{
$variants = $em->getRepository(ResourceFile::class)->createQueryBuilder('rf')
->join('rf.resourceNode', 'rn')
->leftJoin('rn.creator', 'creator')
->where('rf.resourceNode = :resourceNodeId')
->andWhere('rf.accessUrl IS NOT NULL')
->setParameter('resourceNodeId', $resourceNodeId)
->getQuery()
->getResult();
$data = [];
/* @var ResourceFile $variant */
foreach ($variants as $variant) {
$data[] = [
'id' => $variant->getId(),
'title' => $variant->getOriginalName(),
'mimeType' => $variant->getMimeType(),
'size' => $variant->getSize(),
'updatedAt' => $variant->getUpdatedAt()->format('Y-m-d H:i:s'),
'url' => $variant->getAccessUrl() ? $variant->getAccessUrl()->getUrl() : null,
'path' => $this->resourceNodeRepository->getResourceFileUrl($variant->getResourceNode(), [], null, $variant),
'creator' => $variant->getResourceNode()->getCreator() ? $variant->getResourceNode()->getCreator()->getFullName() : 'Unknown',
];
}
return $this->json($data);
}
private function processFile(Request $request, ResourceNode $resourceNode, string $mode = 'show', string $filter = '', ?array $allUserInfo = null, ?ResourceFile $resourceFile = null): mixed
{ {
$this->denyAccessUnlessGranted( $this->denyAccessUnlessGranted(
ResourceNodeVoter::VIEW, ResourceNodeVoter::VIEW,
@ -466,7 +559,7 @@ class ResourceController extends AbstractResourceController implements CourseCon
$this->trans('Unauthorised view access to resource') $this->trans('Unauthorised view access to resource')
); );
$resourceFile = $resourceNode->getResourceFiles()->first(); $resourceFile ??= $resourceNode->getResourceFiles()->first();
if (!$resourceFile) { if (!$resourceFile) {
throw $this->createNotFoundException($this->trans('File not found for resource')); throw $this->createNotFoundException($this->trans('File not found for resource'));
@ -523,7 +616,7 @@ class ResourceController extends AbstractResourceController implements CourseCon
// Modify the HTML content before displaying it. // Modify the HTML content before displaying it.
if (str_contains($mimeType, 'html')) { if (str_contains($mimeType, 'html')) {
$content = $resourceNodeRepo->getResourceNodeFileContent($resourceNode); $content = $resourceNodeRepo->getResourceNodeFileContent($resourceNode, $resourceFile);
if (null !== $allUserInfo) { if (null !== $allUserInfo) {
$tagsToReplace = $allUserInfo[0]; $tagsToReplace = $allUserInfo[0];
@ -569,8 +662,8 @@ class ResourceController extends AbstractResourceController implements CourseCon
} }
$response = new StreamedResponse( $response = new StreamedResponse(
function () use ($resourceNode, $start, $length): void { function () use ($resourceNodeRepo, $resourceFile, $start, $length): void {
$this->streamFileContent($resourceNode, $start, $length); $this->streamFileContent($resourceNodeRepo, $resourceFile, $start, $length);
} }
); );
@ -611,9 +704,9 @@ class ResourceController extends AbstractResourceController implements CourseCon
return [$start, $end, $length]; return [$start, $end, $length];
} }
private function streamFileContent(ResourceNode $resourceNode, int $start, int $length): void private function streamFileContent(ResourceNodeRepository $resourceNodeRepo, ResourceFile $resourceFile, int $start, int $length): void
{ {
$stream = $this->resourceNodeRepository->getResourceNodeFileStream($resourceNode); $stream = $resourceNodeRepo->getResourceNodeFileStream($resourceFile->getResourceNode(), $resourceFile);
fseek($stream, $start); fseek($stream, $start);

@ -14,6 +14,7 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\Serializer\Filter\PropertyFilter; use ApiPlatform\Serializer\Filter\PropertyFilter;
use Chamilo\CoreBundle\Controller\AddVariantResourceFileAction;
use Chamilo\CoreBundle\Controller\CreateResourceFileAction; use Chamilo\CoreBundle\Controller\CreateResourceFileAction;
use Chamilo\CoreBundle\Repository\ResourceFileRepository; use Chamilo\CoreBundle\Repository\ResourceFileRepository;
use DateTime; use DateTime;
@ -40,6 +41,7 @@ use Vich\UploaderBundle\Mapping\Annotation as Vich;
new Post( new Post(
controller: CreateResourceFileAction::class, controller: CreateResourceFileAction::class,
openapiContext: [ openapiContext: [
'summary' => 'Create a new resource file',
'requestBody' => [ 'requestBody' => [
'content' => [ 'content' => [
'multipart/form-data' => [ 'multipart/form-data' => [
@ -62,6 +64,37 @@ use Vich\UploaderBundle\Mapping\Annotation as Vich;
], ],
deserialize: false deserialize: false
), ),
new Post(
uriTemplate: '/resource_files/add_variant',
controller: AddVariantResourceFileAction::class,
openapiContext: [
'summary' => 'Add a variant to an existing resource file',
'requestBody' => [
'content' => [
'multipart/form-data' => [
'schema' => [
'type' => 'object',
'properties' => [
'file' => [
'type' => 'string',
'format' => 'binary',
],
'resourceNodeId' => [
'type' => 'integer',
],
'accessUrlId' => [
'type' => 'integer',
],
],
],
],
],
],
],
security: 'is_granted(\'ROLE_USER\')',
deserialize: false,
name: 'add_variant'
),
new GetCollection(), new GetCollection(),
], ],
normalizationContext: [ normalizationContext: [
@ -150,6 +183,11 @@ class ResourceFile implements Stringable
#[ORM\Column(type: 'datetime')] #[ORM\Column(type: 'datetime')]
protected $updatedAt; protected $updatedAt;
#[ORM\ManyToOne(targetEntity: AccessUrl::class)]
#[ORM\JoinColumn(name: 'access_url_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
protected ?AccessUrl $accessUrl = null;
#[Groups(['resource_file:read', 'resource_node:read', 'document:read'])]
#[ORM\ManyToOne(inversedBy: 'resourceFiles')] #[ORM\ManyToOne(inversedBy: 'resourceFiles')]
private ?ResourceNode $resourceNode = null; private ?ResourceNode $resourceNode = null;
@ -332,6 +370,17 @@ class ResourceFile implements Stringable
return $this; return $this;
} }
public function getAccessUrl(): ?AccessUrl
{
return $this->accessUrl;
}
public function setAccessUrl(?AccessUrl $accessUrl): self
{
$this->accessUrl = $accessUrl;
return $this;
}
public function getResourceNode(): ?ResourceNode public function getResourceNode(): ?ResourceNode
{ {
return $this->resourceNode; return $this->resourceNode;

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\CoreBundle\Migrations\Schema\V200;
use Chamilo\CoreBundle\Migrations\AbstractMigrationChamilo;
use Doctrine\DBAL\Schema\Schema;
final class Version20241214083500 extends AbstractMigrationChamilo
{
public function getDescription(): string
{
return 'Add access_url_id field to resource_file table';
}
public function up(Schema $schema): void
{
if ($schema->hasTable('resource_file')) {
$this->addSql(
'ALTER TABLE resource_file ADD access_url_id INT DEFAULT NULL'
);
$this->addSql(
'ALTER TABLE resource_file ADD CONSTRAINT FK_RESOURCE_FILE_ACCESS_URL FOREIGN KEY (access_url_id) REFERENCES access_url (id) ON DELETE SET NULL'
);
$this->addSql(
'CREATE INDEX IDX_RESOURCE_FILE_ACCESS_URL ON resource_file (access_url_id)'
);
}
$result = $this->connection
->executeQuery(
"SELECT COUNT(1) FROM settings WHERE variable = 'access_url_specific_files' AND category = 'course'"
)
;
$count = $result->fetchNumeric()[0];
if (empty($count)) {
$this->addSql(
"INSERT INTO settings (variable, category, selected_value, title, comment, scope, subkeytext, access_url_changeable) VALUES ('access_url_specific_files','course','false','Access Url Specific Files','','',NULL, 1)"
);
}
}
public function down(Schema $schema): void
{
if ($schema->hasTable('resource_file')) {
$this->addSql(
'ALTER TABLE resource_file DROP FOREIGN KEY FK_RESOURCE_FILE_ACCESS_URL'
);
$this->addSql(
'DROP INDEX IDX_RESOURCE_FILE_ACCESS_URL ON resource_file'
);
$this->addSql(
'ALTER TABLE resource_file DROP COLUMN access_url_id'
);
}
}
}

@ -11,6 +11,8 @@ use Chamilo\CoreBundle\Entity\ResourceFile;
use Chamilo\CoreBundle\Entity\ResourceNode; use Chamilo\CoreBundle\Entity\ResourceNode;
use Chamilo\CoreBundle\Entity\ResourceType; use Chamilo\CoreBundle\Entity\ResourceType;
use Chamilo\CoreBundle\Entity\Session; use Chamilo\CoreBundle\Entity\Session;
use Chamilo\CoreBundle\ServiceHelper\AccessUrlHelper;
use Chamilo\CoreBundle\Settings\SettingsManager;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Gedmo\Tree\Entity\Repository\MaterializedPathRepository; use Gedmo\Tree\Entity\Repository\MaterializedPathRepository;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
@ -25,17 +27,18 @@ use Vich\UploaderBundle\Storage\FlysystemStorage;
*/ */
class ResourceNodeRepository extends MaterializedPathRepository class ResourceNodeRepository extends MaterializedPathRepository
{ {
protected FlysystemStorage $storage;
protected FilesystemOperator $filesystem; protected FilesystemOperator $filesystem;
protected RouterInterface $router;
public function __construct(EntityManagerInterface $manager, FlysystemStorage $storage, FilesystemOperator $resourceFilesystem, RouterInterface $router) public function __construct(
{ private readonly EntityManagerInterface $manager,
private readonly FlysystemStorage $storage,
private readonly FilesystemOperator $resourceFilesystem,
private readonly RouterInterface $router,
private readonly AccessUrlHelper $accessUrlHelper,
private readonly SettingsManager $settingsManager
) {
$this->filesystem = $resourceFilesystem; // Asignar el filesystem correcto
parent::__construct($manager, $manager->getClassMetadata(ResourceNode::class)); parent::__construct($manager, $manager->getClassMetadata(ResourceNode::class));
$this->storage = $storage;
// Flysystem mount name is saved in config/packages/oneup_flysystem.yaml
$this->filesystem = $resourceFilesystem;
$this->router = $router;
} }
public function getFilename(ResourceFile $resourceFile): ?string public function getFilename(ResourceFile $resourceFile): ?string
@ -61,10 +64,10 @@ class ResourceNodeRepository extends MaterializedPathRepository
return $this->filesystem; return $this->filesystem;
} }
public function getResourceNodeFileContent(ResourceNode $resourceNode): string public function getResourceNodeFileContent(ResourceNode $resourceNode, ?ResourceFile $resourceFile = null): string
{ {
try { try {
$resourceFile = $resourceNode->getResourceFiles()->first(); $resourceFile ??= $resourceNode->getResourceFiles()->first();
if ($resourceFile) { if ($resourceFile) {
$fileName = $this->getFilename($resourceFile); $fileName = $this->getFilename($resourceFile);
@ -81,10 +84,10 @@ class ResourceNodeRepository extends MaterializedPathRepository
/** /**
* @return false|resource * @return false|resource
*/ */
public function getResourceNodeFileStream(ResourceNode $resourceNode) public function getResourceNodeFileStream(ResourceNode $resourceNode, ?ResourceFile $resourceFile = null)
{ {
try { try {
$resourceFile = $resourceNode->getResourceFiles()->first(); $resourceFile ??= $resourceNode->getResourceFiles()->first();
if ($resourceFile) { if ($resourceFile) {
$fileName = $this->getFilename($resourceFile); $fileName = $this->getFilename($resourceFile);
@ -98,16 +101,22 @@ class ResourceNodeRepository extends MaterializedPathRepository
} }
} }
public function getResourceFileUrl(ResourceNode $resourceNode, array $extraParams = [], ?int $referenceType = null): string public function getResourceFileUrl(?ResourceNode $resourceNode, array $extraParams = [], ?int $referenceType = null, ?ResourceFile $resourceFile = null): string
{ {
try { try {
if ($resourceNode->hasResourceFile()) { $file = $resourceFile ?? $resourceNode?->getResourceFiles()->first();
if ($file) {
$params = [ $params = [
'tool' => $resourceNode->getResourceType()->getTool(), 'tool' => $resourceNode->getResourceType()->getTool(),
'type' => $resourceNode->getResourceType(), 'type' => $resourceNode->getResourceType(),
'id' => $resourceNode->getUuid(), 'id' => $resourceNode->getUuid(),
]; ];
if ($resourceFile) {
$params['resourceFileId'] = $resourceFile->getId();
}
if (!empty($extraParams)) { if (!empty($extraParams)) {
$params = array_merge($params, $extraParams); $params = array_merge($params, $extraParams);
} }

@ -120,6 +120,7 @@ class CourseSettingsSchema extends AbstractSettingsSchema
'course_creation_user_course_extra_field_relation_to_prefill' => '', 'course_creation_user_course_extra_field_relation_to_prefill' => '',
'allow_edit_tool_visibility_in_session' => 'true', 'allow_edit_tool_visibility_in_session' => 'true',
'show_course_duration' => 'false', 'show_course_duration' => 'false',
'access_url_specific_files' => 'false',
] ]
) )
->setTransformer( ->setTransformer(
@ -369,6 +370,7 @@ class CourseSettingsSchema extends AbstractSettingsSchema
) )
->add('allow_edit_tool_visibility_in_session', YesNoType::class) ->add('allow_edit_tool_visibility_in_session', YesNoType::class)
->add('show_course_duration', YesNoType::class) ->add('show_course_duration', YesNoType::class)
->add('access_url_specific_files', YesNoType::class)
; ;
$this->updateFormFieldsFromSettingsInfo($builder); $this->updateFormFieldsFromSettingsInfo($builder);

Loading…
Cancel
Save