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,
+ ];
+ }
}