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 639998f60e7..1a052f471da 100644
--- a/assets/vue/views/documents/DocumentsList.vue
+++ b/assets/vue/views/documents/DocumentsList.vue
@@ -392,7 +392,6 @@
:style="{ width: '28rem' }"
:title="t('Space available')"
>
-
This feature is in development, this is a mockup with placeholder data!
@@ -947,10 +946,32 @@ 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',
+ 'Content-Type': 'application/json'
+ }
+ })
+
+ usageData.value = response.data
+ } catch (error) {
+ console.error("Error fetching storage usage:", error)
+
+ usageData.value = {
+ datasets: [{
+ data: [100],
+ backgroundColor: ['#CCCCCC', '#CCCCCC', '#CCCCCC'],
+ borderWidth: 2,
+ borderColor: '#E0E0E0'
+ }],
+ labels: [
+ t('Course storage (unavailable)'),
+ t('Teacher storage (unavailable)'),
+ t('Total storage (unavailable)')
+ ],
+ }
}
isFileUsageDialogVisible.value = true
}
diff --git a/src/CoreBundle/Controller/Api/DocumentUsageAction.php b/src/CoreBundle/Controller/Api/DocumentUsageAction.php
new file mode 100644
index 00000000000..81e2ed98101
--- /dev/null
+++ b/src/CoreBundle/Controller/Api/DocumentUsageAction.php
@@ -0,0 +1,173 @@
+courseRepository->find($courseId);
+ if (!$courseEntity) {
+ return new JsonResponse(['error' => 'Course not found'], 404);
+ }
+
+ $sessionEntity = api_get_session_entity();
+
+ $totalQuotaBytes = ($courseEntity->getDiskQuota() * 1024 * 1024) ?? DEFAULT_DOCUMENT_QUOTA;
+ $usedQuotaBytes = $this->documentRepository->getTotalSpaceByCourse($courseEntity);
+
+ $chartData = [];
+
+ // Process sessions
+ $this->processCourseSessions($courseEntity, $sessionId, $totalQuotaBytes, $usedQuotaBytes, $chartData);
+
+ // Process groups
+ $this->processCourseGroups($courseEntity, $groupId, $totalQuotaBytes, $usedQuotaBytes, $chartData);
+
+ // Process user documents
+ $users = $this->courseRepository->getUsersByCourse($courseEntity);
+ foreach ($users as $user) {
+ $userId = $user->getId();
+ $userName = $user->getFullName();
+ $this->processUserDocuments($courseEntity, $sessionEntity, $userId, $userName, $totalQuotaBytes, $chartData);
+ }
+
+ // Add available space
+ $availableBytes = $totalQuotaBytes - $usedQuotaBytes;
+ $availablePercentage = $this->calculatePercentage($availableBytes, $totalQuotaBytes);
+
+ $chartData[] = [
+ 'label' => addslashes(get_lang('Available space')).' ('.format_file_size($availableBytes).')',
+ 'percentage' => $availablePercentage,
+ ];
+
+ return new JsonResponse([
+ 'datasets' => [
+ ['data' => array_column($chartData, 'percentage')],
+ ],
+ 'labels' => array_column($chartData, 'label'),
+ ]);
+ }
+
+ private function processCourseSessions($courseEntity, int $sessionId, int $totalQuotaBytes, int &$usedQuotaBytes, array &$chartData): void
+ {
+ $sessions = $this->sessionRepository->getSessionsByCourse($courseEntity);
+
+ foreach ($sessions as $session) {
+ $quotaBytes = $this->documentRepository->getTotalSpaceByCourse($courseEntity, null, $session);
+
+ if ($quotaBytes > 0) {
+ $sessionName = $session->getTitle();
+ if ($sessionId === $session->getId()) {
+ $sessionName .= ' * ';
+ }
+
+ $usedQuotaBytes += $quotaBytes;
+ $chartData[] = [
+ 'label' => addslashes(get_lang('Session').': '.$sessionName).' ('.format_file_size($quotaBytes).')',
+ 'percentage' => $this->calculatePercentage($quotaBytes, $totalQuotaBytes),
+ ];
+ }
+ }
+ }
+
+ private function processCourseGroups($courseEntity, int $groupId, int $totalQuotaBytes, int &$usedQuotaBytes, array &$chartData): void
+ {
+ $groupsList = $this->groupRepository->findAllByCourse($courseEntity)->getQuery()->getResult();
+
+ foreach ($groupsList as $groupEntity) {
+ $quotaBytes = $this->documentRepository->getTotalSpaceByCourse($courseEntity, $groupEntity->getIid());
+
+ if ($quotaBytes > 0) {
+ $groupName = $groupEntity->getTitle();
+ if ($groupId === $groupEntity->getIid()) {
+ $groupName .= ' * ';
+ }
+
+ $usedQuotaBytes += $quotaBytes;
+ $chartData[] = [
+ 'label' => addslashes(get_lang('Group').': '.$groupName).' ('.format_file_size($quotaBytes).')',
+ 'percentage' => $this->calculatePercentage($quotaBytes, $totalQuotaBytes),
+ ];
+ }
+ }
+ }
+
+ private function processUserDocuments($courseEntity, $sessionEntity, int $userId, string $userName, int $totalQuotaBytes, array &$chartData): void
+ {
+ $documentsList = $this->documentRepository->getAllDocumentDataByUserAndGroup($courseEntity);
+ $userQuotaBytes = 0;
+
+ foreach ($documentsList as $documentEntity) {
+ if ($documentEntity->getResourceNode()->getCreator()?->getId() === $userId
+ && 'file' === $documentEntity->getFiletype()) {
+ $resourceFiles = $documentEntity->getResourceNode()->getResourceFiles();
+ if (!$resourceFiles->isEmpty()) {
+ $userQuotaBytes += $resourceFiles->first()->getSize();
+ }
+ }
+ }
+
+ if ($userQuotaBytes > 0) {
+ $chartData[] = [
+ 'label' => addslashes(get_lang('Teacher').': '.$userName).' ('.format_file_size($userQuotaBytes).')',
+ 'percentage' => $this->calculatePercentage($userQuotaBytes, $totalQuotaBytes),
+ ];
+
+ // Handle session context
+ if ($sessionEntity) {
+ $sessionTotalQuota = $this->calculateSessionTotalQuota($sessionEntity);
+ if ($sessionTotalQuota > 0) {
+ $chartData[] = [
+ 'label' => addslashes(\sprintf(get_lang('TeacherXInSession'), $userName)),
+ 'percentage' => $this->calculatePercentage($userQuotaBytes, $sessionTotalQuota),
+ ];
+ }
+ }
+ }
+ }
+
+ private function calculateSessionTotalQuota($sessionEntity): int
+ {
+ $total = 0;
+ $sessionCourses = $sessionEntity->getCourses();
+
+ foreach ($sessionCourses as $courseEntity) {
+ $total += DocumentManager::get_course_quota($courseEntity->getId());
+ }
+
+ return $total;
+ }
+
+ private function calculatePercentage(int $bytes, int $totalBytes): float
+ {
+ if (0 === $totalBytes) {
+ return 0.0;
+ }
+
+ return round(($bytes / $totalBytes) * 100, 2);
+ }
+}
diff --git a/src/CoreBundle/Migrations/AbstractMigrationChamilo.php b/src/CoreBundle/Migrations/AbstractMigrationChamilo.php
index 227b255e221..44eda5420f7 100644
--- a/src/CoreBundle/Migrations/AbstractMigrationChamilo.php
+++ b/src/CoreBundle/Migrations/AbstractMigrationChamilo.php
@@ -29,7 +29,6 @@
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Filesystem\Filesystem;
-use Symfony\Component\Finder\Finder;
use Symfony\Component\HttpFoundation\File\UploadedFile;
abstract class AbstractMigrationChamilo extends AbstractMigration
diff --git a/src/CoreBundle/Migrations/Schema/V200/Version20250306101000.php b/src/CoreBundle/Migrations/Schema/V200/Version20250306101000.php
index 61c92946f6d..712467be6c5 100644
--- a/src/CoreBundle/Migrations/Schema/V200/Version20250306101000.php
+++ b/src/CoreBundle/Migrations/Schema/V200/Version20250306101000.php
@@ -11,7 +11,6 @@
use Chamilo\CoreBundle\Migrations\AbstractMigrationChamilo;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Schema\Schema;
-use Symfony\Component\Finder\Finder;
final class Version20250306101000 extends AbstractMigrationChamilo
{
@@ -29,7 +28,7 @@ public function up(Schema $schema): void
$pluginTitle = (string) $pluginTitle['title'];
}
- if (!array_key_exists($pluginTitle, $replacements)) {
+ if (!\array_key_exists($pluginTitle, $replacements)) {
continue;
}
diff --git a/src/CoreBundle/Migrations/Schema/V200/Version20251009111300.php b/src/CoreBundle/Migrations/Schema/V200/Version20251009111300.php
index 53cf48d5ede..9ed54235da5 100644
--- a/src/CoreBundle/Migrations/Schema/V200/Version20251009111300.php
+++ b/src/CoreBundle/Migrations/Schema/V200/Version20251009111300.php
@@ -18,20 +18,17 @@ public function getDescription(): string
return 'Fix plugin titles and remove plugins without a corresponding directory';
}
- /**
- * @inheritDoc
- */
public function up(Schema $schema): void
{
$replacements = self::pluginNameReplacements();
$idListToDelete = [];
- $pluginRows = $this->connection->executeQuery("SELECT id, title, source FROM plugin")->fetchAllAssociative();
+ $pluginRows = $this->connection->executeQuery('SELECT id, title, source FROM plugin')->fetchAllAssociative();
foreach ($pluginRows as $pluginRow) {
$title = $pluginRow['title'];
- if (!array_key_exists($title, $replacements)) {
+ if (!\array_key_exists($title, $replacements)) {
$idListToDelete[] = $pluginRow['id'];
continue;
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..71fec29573d 100644
--- a/src/CoreBundle/Repository/SessionRepository.php
+++ b/src/CoreBundle/Repository/SessionRepository.php
@@ -86,6 +86,18 @@ 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 03c7d29f15d..570f8ac8731 100644
--- a/src/CourseBundle/Entity/CDocument.php
+++ b/src/CourseBundle/Entity/CDocument.php
@@ -19,6 +19,7 @@
use ApiPlatform\Serializer\Filter\PropertyFilter;
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\ReplaceDocumentFileAction;
use Chamilo\CoreBundle\Controller\Api\UpdateDocumentFileAction;
@@ -190,6 +191,16 @@
],
]
),
+ 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 8149ea5733c..285e0d6c788 100644
--- a/src/CourseBundle/Repository/CDocumentRepository.php
+++ b/src/CourseBundle/Repository/CDocumentRepository.php
@@ -366,6 +366,87 @@ public function findChildNodeByTitle(ResourceNode $parent, string $title): ?Reso
;
}
+ /**
+ * Fetches all document data for the given user/group using Doctrine ORM.
+ *
+ * @return CDocument[]
+ */
+ public function getAllDocumentDataByUserAndGroup(
+ Course $course,
+ string $path = '/',
+ int $toGroupId = 0,
+ ?int $toUserId = null,
+ bool $search = false,
+ ?Session $session = null
+ ): array {
+ $qb = $this->createQueryBuilder('d');
+
+ $qb->innerJoin('d.resourceNode', 'rn')
+ ->innerJoin('rn.resourceLinks', 'rl')
+ ->where('rl.course = :course')
+ ->setParameter('course', $course)
+ ;
+
+ // Session filtering
+ if ($session) {
+ $qb->andWhere('(rl.session = :session OR rl.session IS NULL)')
+ ->setParameter('session', $session)
+ ;
+ } else {
+ $qb->andWhere('rl.session IS NULL');
+ }
+
+ // Path filtering - convert document.lib.php logic to Doctrine
+ if ('/' !== $path) {
+ // The original uses LIKE with path patterns
+ $pathPattern = rtrim($path, '/').'/%';
+ $qb->andWhere('rn.title LIKE :pathPattern OR rn.title = :exactPath')
+ ->setParameter('pathPattern', $pathPattern)
+ ->setParameter('exactPath', ltrim($path, '/'))
+ ;
+
+ // Exclude deeper nested paths if not searching
+ if (!$search) {
+ // Exclude paths with additional slashes beyond the current level
+ $excludePattern = rtrim($path, '/').'/%/%';
+ $qb->andWhere('rn.title NOT LIKE :excludePattern')
+ ->setParameter('excludePattern', $excludePattern)
+ ;
+ }
+ }
+
+ // User/Group filtering
+ if (null !== $toUserId) {
+ if ($toUserId > 0) {
+ $qb->andWhere('rl.user = :userId')
+ ->setParameter('userId', $toUserId)
+ ;
+ } else {
+ $qb->andWhere('rl.user IS NULL');
+ }
+ } else {
+ if ($toGroupId > 0) {
+ $qb->andWhere('rl.group = :groupId')
+ ->setParameter('groupId', $toGroupId)
+ ;
+ } else {
+ $qb->andWhere('rl.group IS NULL');
+ }
+ }
+
+ // Exclude deleted documents (like %_DELETED_% in original)
+ $qb->andWhere('rn.title NOT LIKE :deletedPattern')
+ ->setParameter('deletedPattern', '%_DELETED_%')
+ ;
+
+ // Order by creation date (equivalent to last.iid DESC)
+ $qb->orderBy('rn.createdAt', 'DESC')
+ ->addOrderBy('rn.id', 'DESC')
+ ;
+
+ return $qb->getQuery()->getResult();
+ }
+
/**
* Ensure "Learning paths" exists directly under the course resource node.
* Links are created for course (and optional session) context.