Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/vue/components/basecomponents/BaseChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<PrimeChart
:data="data"
:options="{}"
class="w-full md:w-30rem"
class="w-full md:w-30rem flex justify-center"
type="pie"
/>
</template>
Expand Down
31 changes: 26 additions & 5 deletions assets/vue/views/documents/DocumentsList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,6 @@
:style="{ width: '28rem' }"
:title="t('Space available')"
>
<p>This feature is in development, this is a mockup with placeholder data!</p>
<BaseChart :data="usageData" />
</BaseDialog>

Expand Down Expand Up @@ -954,10 +953,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
}
Expand Down
135 changes: 135 additions & 0 deletions src/CoreBundle/Controller/Api/DocumentUsageAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

declare(strict_types=1);

namespace Chamilo\CoreBundle\Controller\Api;

use Chamilo\CoreBundle\Helpers\CidReqHelper;
use Chamilo\CoreBundle\Repository\Node\CourseRepository;
use Chamilo\CourseBundle\Repository\CDocumentRepository;
use Chamilo\CourseBundle\Repository\CGroupRepository;
use DocumentManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\AsController;


#[AsController]
class DocumentUsageAction extends AbstractController
{

public function __construct(
private readonly CourseRepository $courseRepository,
private readonly CDocumentRepository $documentRepository,
private readonly CGroupRepository $groupRepository,
) {
}

public function __invoke($cid): JsonResponse
{
$courseId = (int) $cid;

$courseEntity = $this->courseRepository->find($courseId);
if (!$courseEntity) {
return new JsonResponse(['error' => 'Course not found'], 404);
}

$totalQuotaBytes = ($courseEntity->getDiskQuota() * 1024 * 1024) ?? DEFAULT_DOCUMENT_QUOTA;

Check failure on line 37 in src/CoreBundle/Controller/Api/DocumentUsageAction.php

View workflow job for this annotation

GitHub Actions / PHP 8.2 Test on ubuntu-latest

UndefinedConstant

src/CoreBundle/Controller/Api/DocumentUsageAction.php:37:77: UndefinedConstant: Const DEFAULT_DOCUMENT_QUOTA is not defined (see https://psalm.dev/020)
$usedQuotaBytes = $this->documentRepository->getTotalSpaceByCourse($courseEntity);

$chartData = [];

// Process sessions
$this->processCourseSessions($courseEntity, $totalQuotaBytes, $usedQuotaBytes, $chartData);

// Process groups
$this->processCourseGroups($courseEntity, $totalQuotaBytes, $usedQuotaBytes, $chartData);

// Process user documents
$users = $this->courseRepository->getUsersByCourse($courseEntity);
foreach ($users as $user) {
$this->processUserDocuments($courseEntity, $user, $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 $totalQuotaBytes, int &$usedQuotaBytes, array &$chartData): void
{
foreach ($courseEntity->getSessions() as $sessionRel) {
$session = $sessionRel->getSession();
$quotaBytes = $this->documentRepository->getTotalSpaceByCourse($courseEntity, null, $session);

if ($quotaBytes > 0) {
$usedQuotaBytes += $quotaBytes;
$chartData[] = [
'label' => addslashes(get_lang('Session') . ': ' . $session->getTitle()) . ' (' . format_file_size($quotaBytes) . ')',
'percentage' => $this->calculatePercentage($quotaBytes, $totalQuotaBytes),
];
}
}
}

private function processCourseGroups($courseEntity, 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) {
$usedQuotaBytes += $quotaBytes;
$chartData[] = [
'label' => addslashes(get_lang('Group') . ': ' . $groupEntity->getTitle() . ' (' . format_file_size($quotaBytes) . ')'),
'percentage' => $this->calculatePercentage($quotaBytes, $totalQuotaBytes),
];
}
}
}

private function processUserDocuments($courseEntity, $user, int $totalQuotaBytes, array &$chartData): void
{
$documentsList = $this->documentRepository->getAllDocumentDataByUserAndGroup($courseEntity);
$userQuotaBytes = 0;

foreach ($documentsList as $documentEntity) {
if ($documentEntity->getResourceNode()->getCreator()?->getId() === $user->getId()
&& $documentEntity->getFiletype() === 'file') {
$resourceFiles = $documentEntity->getResourceNode()->getResourceFiles();
if (!$resourceFiles->isEmpty()) {
$userQuotaBytes += $resourceFiles->first()->getSize();
}
}
}

if ($userQuotaBytes > 0) {
$chartData[] = [
'label' => addslashes(get_lang('Teacher') . ': ' . $user->getFullName()) . ' (' . format_file_size($userQuotaBytes) . ')',
'percentage' => $this->calculatePercentage($userQuotaBytes, $totalQuotaBytes),
];
}
}

private function calculatePercentage(int $bytes, int $totalBytes): float
{
if ($totalBytes === 0) {
return 0.0;
}

return round(($bytes / $totalBytes) * 100, 2);
}
}
19 changes: 19 additions & 0 deletions src/CoreBundle/Repository/Node/CourseRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
11 changes: 11 additions & 0 deletions src/CoreBundle/Repository/SessionRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, Session>
*
Expand Down
11 changes: 11 additions & 0 deletions src/CourseBundle/Entity/CDocument.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'],
Expand Down
80 changes: 80 additions & 0 deletions src/CourseBundle/Repository/CDocumentRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,86 @@ public function findChildNodeByTitle(ResourceNode $parent, string $title): ?Reso
;
}

/**
* Fetches all document data for the given user/group using Doctrine ORM.
*
* @param Course $course
* @param string $path
* @param int $toGroupId
* @param int|null $toUserId
* @param bool $search
* @param Session|null $session
*
* @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 ($toUserId !== null) {
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.
Expand Down
Loading