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.