diff --git a/assets/vue/components/basecomponents/BaseChart.vue b/assets/vue/components/basecomponents/BaseChart.vue index 4a2d46d4d0f..84f5c65c88a 100644 --- a/assets/vue/components/basecomponents/BaseChart.vue +++ b/assets/vue/components/basecomponents/BaseChart.vue @@ -2,7 +2,7 @@ diff --git a/assets/vue/views/documents/DocumentsList.vue b/assets/vue/views/documents/DocumentsList.vue index 992842fa46e..11e0dff08c8 100644 --- a/assets/vue/views/documents/DocumentsList.vue +++ b/assets/vue/views/documents/DocumentsList.vue @@ -350,8 +350,8 @@ size="big" /> {{ t("Are you sure you want to delete") }} {{ item.title }}?{{ t("Are you sure you want to delete") }} {{ item.title }}? @@ -392,7 +392,6 @@ :style="{ width: '28rem' }" :title="t('Space available')" > -

This feature is in development, this is a mockup with placeholder data!

@@ -954,14 +953,26 @@ function showSlideShowWithFirstImage() { document.querySelector("button.fancybox-button--play")?.click() } -function showUsageDialog() { - usageData.value = { - datasets: [{ data: [83, 14, 5] }], - labels: ["Course", "Teacher", "Available space"], +async function showUsageDialog() { + try { + const response = await axios.get(`/api/documents/${cid}/usage`, { + headers: { Accept: 'application/json' }, + params: { sid, gid }, + }) + + usageData.value = response.data + } catch (error) { + console.error('Error fetching documents quota usage:', error) + usageData.value = { + datasets: [{ data: [100] }], + labels: [t('Storage usage unavailable')], + } } + isFileUsageDialogVisible.value = true } + function showRecordAudioDialog() { isRecordAudioDialogVisible.value = true } @@ -997,22 +1008,6 @@ function normalizeResourceNodeId(value) { return null } - -function getRootNodeIdForFolders() { - let node = resourceNode.value - let fallback = - normalizeResourceNodeId(node?.id) ?? - normalizeResourceNodeId(route.params.node) ?? - normalizeResourceNodeId(route.query.node) - - while (node?.parent) { - if (node?.resourceType?.title === "courses") break - node = node.parent - } - - return normalizeResourceNodeId(node?.id) ?? fallback -} - async function fetchFolders(nodeId = null, parentPath = "") { const startId = normalizeResourceNodeId(nodeId || route.params.node || route.query.node) diff --git a/src/CoreBundle/Controller/Api/DocumentUsageAction.php b/src/CoreBundle/Controller/Api/DocumentUsageAction.php new file mode 100644 index 00000000000..0b5465a38aa --- /dev/null +++ b/src/CoreBundle/Controller/Api/DocumentUsageAction.php @@ -0,0 +1,110 @@ +courseRepository->find($courseId); + if (null === $course) { + return new JsonResponse(['error' => 'Course not found'], 404); + } + + // Resolve quota in MB safely (avoid "($x * ...) ?? fallback"). + $quotaMb = (int) ($course->getDiskQuota() ?? 0); + if ($quotaMb <= 0) { + $quotaMb = self::DEFAULT_QUOTA_MB; + } + + $totalQuotaBytes = $quotaMb * 1024 * 1024; + + // Compute usage using repository logic (deduplicated). + $usage = $this->documentRepository->getDocumentUsageBreakdownByCourse($course); + + $bytesCourse = (int) ($usage['course'] ?? 0); + $bytesSessions = (int) ($usage['sessions'] ?? 0); + $bytesGroups = (int) ($usage['groups'] ?? 0); + + $usedBytes = (int) ($usage['used'] ?? ($bytesCourse + $bytesSessions + $bytesGroups)); + + // Keep the pie meaningful even when used > quota. + $denomBytes = max($totalQuotaBytes, $usedBytes, 1); + + $availableBytes = max($totalQuotaBytes - $usedBytes, 0); + + $labels = []; + $data = []; + + if ($bytesCourse > 0) { + $labels[] = get_lang('Course').' ('.$this->formatBytes($bytesCourse).')'; + $data[] = $this->pct($bytesCourse, $denomBytes); + } + + if ($bytesSessions > 0) { + $labels[] = get_lang('Session').' ('.$this->formatBytes($bytesSessions).')'; + $data[] = $this->pct($bytesSessions, $denomBytes); + } + + if ($bytesGroups > 0) { + $labels[] = get_lang('Group').' ('.$this->formatBytes($bytesGroups).')'; + $data[] = $this->pct($bytesGroups, $denomBytes); + } + + $labels[] = get_lang('Available space').' ('.$this->formatBytes($availableBytes).')'; + $data[] = $this->pct($availableBytes, $denomBytes); + + return new JsonResponse([ + 'datasets' => [ + ['data' => $data], + ], + 'labels' => $labels, + ]); + } + + private function pct(int $part, int $total): float + { + if ($total <= 0) { + return 0.0; + } + + return round(($part / $total) * 100, 2); + } + + private function formatBytes(int $bytes): string + { + // Simple dependency-free formatter for API responses. + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $size = (float) max($bytes, 0); + $i = 0; + + $max = count($units) - 1; + while ($size >= 1024 && $i < $max) { + $size /= 1024; + $i++; + } + + return round($size, 2).' '.$units[$i]; + } +} diff --git a/src/CoreBundle/Repository/Node/CourseRepository.php b/src/CoreBundle/Repository/Node/CourseRepository.php index 4bff028b532..02e93199e04 100644 --- a/src/CoreBundle/Repository/Node/CourseRepository.php +++ b/src/CoreBundle/Repository/Node/CourseRepository.php @@ -449,4 +449,23 @@ public function getCoursesByAccessUrl(AccessUrl $url): array ->getResult() ; } + + public function getUsersByCourse(Course $course): array + { + $qb = $this->getEntityManager()->createQueryBuilder(); + + $qb + ->select('DISTINCT user') + ->from(User::class, 'user') + ->innerJoin(CourseRelUser::class, 'courseRelUser', Join::WITH, 'courseRelUser.user = user.id') + ->where('courseRelUser.course = :course') + ->setParameter('course', $course) + ->orderBy('user.lastname', 'ASC') + ->addOrderBy('user.firstname', 'ASC') + ; + + $query = $qb->getQuery(); + + return $query->getResult(); + } } diff --git a/src/CoreBundle/Repository/SessionRepository.php b/src/CoreBundle/Repository/SessionRepository.php index 61a1a049e03..38532491b87 100644 --- a/src/CoreBundle/Repository/SessionRepository.php +++ b/src/CoreBundle/Repository/SessionRepository.php @@ -86,6 +86,17 @@ public function getSessionsByUser(User $user, AccessUrl $url): QueryBuilder return $qb; } + public function getSessionsByCourse(Course $course): array + { + $qb = $this->createQueryBuilder('s'); + + return $qb + ->innerJoin('s.courses', 'src') + ->where($qb->expr()->eq('src.course', ':course')) + ->setParameter('course', $course) + ->getQuery()->getResult(); + } + /** * @return array * diff --git a/src/CourseBundle/Entity/CDocument.php b/src/CourseBundle/Entity/CDocument.php index a15f916ef92..ac5db67f3c4 100644 --- a/src/CourseBundle/Entity/CDocument.php +++ b/src/CourseBundle/Entity/CDocument.php @@ -24,6 +24,7 @@ use ArrayObject; use Chamilo\CoreBundle\Controller\Api\CreateDocumentFileAction; use Chamilo\CoreBundle\Controller\Api\DocumentLearningPathUsageAction; +use Chamilo\CoreBundle\Controller\Api\DocumentUsageAction; use Chamilo\CoreBundle\Controller\Api\DownloadSelectedDocumentsAction; use Chamilo\CoreBundle\Controller\Api\MoveDocumentAction; use Chamilo\CoreBundle\Controller\Api\ReplaceDocumentFileAction; @@ -211,6 +212,16 @@ ), provider: DocumentCollectionStateProvider::class ), + new Get( + uriTemplate: '/documents/{cid}/usage', + controller: DocumentUsageAction::class, + openapiContext: [ + 'summary' => 'Get usage/quota information for documents.', + ], + security: "is_granted('ROLE_USER')", + read: false, + name: 'api_documents_usage' + ) ], normalizationContext: [ 'groups' => ['document:read', 'resource_node:read'], diff --git a/src/CourseBundle/Repository/CDocumentRepository.php b/src/CourseBundle/Repository/CDocumentRepository.php index 63572b96f4a..5b4446ca5fe 100644 --- a/src/CourseBundle/Repository/CDocumentRepository.php +++ b/src/CourseBundle/Repository/CDocumentRepository.php @@ -889,4 +889,103 @@ private function buildFolderPathForLink( return $pathCache[$id] = $path; } + + /** + * Compute document storage usage breakdown for a course. + * + * - Counts only the "document" tool (resource_type_group = document resource type id). + * - Deduplicates by ResourceFile ID to avoid double counting when the same file is linked multiple times. + * - Classifies each file into the most specific context: + * group > session > course + * + * @return array{ + * course: int, + * sessions: int, + * groups: int, + * used: int + * } + */ + public function getDocumentUsageBreakdownByCourse(Course $course): array + { + $courseId = (int) $course->getId(); + $typeGroupId = (int) $this->getResourceType()->getId(); + + $conn = $this->getEntityManager()->getConnection(); + + $sql = <<fetchAllAssociative($sql, [ + 'courseId' => $courseId, + 'typeGroupId' => $typeGroupId, + ]); + + $fileSizes = []; // file_id => size + $hasSession = []; // file_id => bool + $hasGroup = []; // file_id => bool + + foreach ($rows as $row) { + $fileId = (int) ($row['file_id'] ?? 0); + if ($fileId <= 0) { + continue; + } + + $size = (int) ($row['file_size'] ?? 0); + + if (!isset($fileSizes[$fileId])) { + $fileSizes[$fileId] = $size; + $hasSession[$fileId] = false; + $hasGroup[$fileId] = false; + } + + $sid = (int) ($row['session_id'] ?? 0); + $gid = (int) ($row['group_id'] ?? 0); + + if ($sid > 0) { + $hasSession[$fileId] = true; + } + if ($gid > 0) { + $hasGroup[$fileId] = true; + } + } + + $bytesCourse = 0; + $bytesSessions = 0; + $bytesGroups = 0; + + foreach ($fileSizes as $fileId => $size) { + if (($hasGroup[$fileId] ?? false) === true) { + $bytesGroups += $size; + continue; + } + + if (($hasSession[$fileId] ?? false) === true) { + $bytesSessions += $size; + continue; + } + + $bytesCourse += $size; + } + + $used = $bytesCourse + $bytesSessions + $bytesGroups; + + return [ + 'course' => $bytesCourse, + 'sessions' => $bytesSessions, + 'groups' => $bytesGroups, + 'used' => $used, + ]; + } }