Skip to content
Merged
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
111 changes: 72 additions & 39 deletions src/CoreBundle/Controller/CourseMaintenanceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,7 @@
['value' => 'glossary', 'label' => 'Glossary'],
['value' => 'learnpaths', 'label' => 'Paths learning'],
['value' => 'tool_intro', 'label' => 'Course Introduction'],
['value' => 'course_description', 'label' => 'Course descriptions'],
];

$defaults['tools'] = array_column($tools, 'value');
Expand Down Expand Up @@ -709,6 +710,7 @@
'learnpaths', 'learnpath_category',
'works', 'glossary',
'tool_intro',
'course_descriptions',
];

// Use client tools if provided; otherwise our Moodle-safe defaults
Expand Down Expand Up @@ -774,14 +776,14 @@
{
$this->setDebugFromRequest($req);

$p = json_decode($req->getContent() ?: '{}', true);
$moodleVersion = (string) ($p['moodleVersion'] ?? '4');
$scope = (string) ($p['scope'] ?? 'full');
$adminId = (int) ($p['adminId'] ?? 0);
$adminLogin = trim((string) ($p['adminLogin'] ?? ''));
$adminEmail = trim((string) ($p['adminEmail'] ?? ''));
$selected = (array) ($p['resources'] ?? []);
$toolsInput = (array) ($p['tools'] ?? []);
$p = json_decode($req->getContent() ?: '{}', true) ?: [];
$moodleVersion = (string) ($p['moodleVersion'] ?? '4'); // "3" | "4"
$scope = (string) ($p['scope'] ?? 'full'); // "full" | "selected"
$adminId = (int) ($p['adminId'] ?? 0);
$adminLogin = trim((string) ($p['adminLogin'] ?? ''));
$adminEmail = trim((string) ($p['adminEmail'] ?? ''));
$selected = is_array($p['resources'] ?? null) ? (array) $p['resources'] : [];
$toolsInput = is_array($p['tools'] ?? null) ? (array) $p['tools'] : [];

if (!\in_array($moodleVersion, ['3', '4'], true)) {
return $this->json(['error' => 'Unsupported Moodle version'], 400);
Expand All @@ -790,7 +792,15 @@
return $this->json(['error' => 'No resources selected'], 400);
}

// Normalize tools from client (adds implied deps)
$defaultTools = [
'documents', 'links', 'forums',
'quizzes', 'quiz_questions',
'surveys', 'survey_questions',
'learnpaths', 'learnpath_category',
'works', 'glossary',
'course_descriptions',
];

$tools = $this->normalizeSelectedTools($toolsInput);

// If scope=selected, merge inferred tools from selection
Expand All @@ -799,64 +809,86 @@
$tools = $this->normalizeSelectedTools(array_merge($tools, $inferred));
}

// Remove unsupported tools
$tools = array_values(array_unique(array_diff($tools, ['gradebook'])));
if (!in_array('tool_intro', $tools, true)) {
$tools[] = 'tool_intro';
$clientSentNoTools = empty($toolsInput);
$useDefault = ($scope === 'full' && $clientSentNoTools);
$toolsToBuild = $useDefault ? $defaultTools : $tools;

// Ensure "tool_intro" is present (append only if missing)
if (!in_array('tool_intro', $toolsToBuild, true)) {
$toolsToBuild[] = 'tool_intro';
}

if ($adminId <= 0 || '' === $adminLogin || '' === $adminEmail) {
$adm = $users->getDefaultAdminForExport();
$adminId = $adminId > 0 ? $adminId : (int) ($adm['id'] ?? 1);
$adminLogin = '' !== $adminLogin ? $adminLogin : (string) ($adm['username'] ?? 'admin');
$adminEmail = '' !== $adminEmail ? $adminEmail : (string) ($adm['email'] ?? 'admin@example.com');
// Final dedupe/normalize
$toolsToBuild = array_values(array_unique($toolsToBuild));

$this->logDebug('[moodleExportExecute] course tools to build (final)', $toolsToBuild);

if ($adminId <= 0 || $adminLogin === '' || $adminEmail === '') {
$adm = $users->getDefaultAdminForExport();
$adminId = $adminId > 0 ? $adminId : (int) ($adm['id'] ?? 1);
$adminLogin = $adminLogin !== '' ? $adminLogin : (string) ($adm['username'] ?? 'admin');
$adminEmail = $adminEmail !== '' ? $adminEmail : (string) ($adm['email'] ?? 'admin@example.com');
}

$this->logDebug('[moodleExportExecute] tools for CourseBuilder', $tools);
$courseId = api_get_course_id();
if (empty($courseId)) {
return $this->json(['error' => 'No active course context'], 400);
}

// Build legacy Course from CURRENT course
$cb = new CourseBuilder();
$cb->set_tools_to_build(!empty($tools) ? $tools : [
// Fallback should mirror the Moodle-safe list used in the picker
'documents', 'links', 'forums',
'quizzes', 'quiz_questions',
'surveys', 'survey_questions',
'learnpaths', 'learnpath_category',
'works', 'glossary',
'tool_intro',
]);
$course = $cb->build(0, api_get_course_id());
$cb->set_tools_to_build($toolsToBuild);
$course = $cb->build(0, $courseId);

// IMPORTANT: when scope === 'selected', use the same robust selection filter as copy-course
if ('selected' === $scope) {
// This method trims buckets to only selected items and pulls needed deps (LP/quiz/survey)
$course = $this->filterLegacyCourseBySelection($course, $selected);
// Safety guard: fail if nothing remains after filtering
if (empty($course->resources) || !\is_array($course->resources)) {
return $this->json(['error' => 'Selection produced no resources to export'], 400);
}
}

try {
// Pass selection flag to exporter so it does NOT re-hydrate from a complete snapshot.
$selectionMode = ('selected' === $scope);
// === Export to Moodle MBZ ===
$selectionMode = ($scope === 'selected');
$exporter = new MoodleExport($course, $selectionMode);
$exporter->setAdminUserData($adminId, $adminLogin, $adminEmail);

$courseId = api_get_course_id();
$exportDir = 'moodle_export_'.date('Ymd_His');
$versionNum = ('3' === $moodleVersion) ? 3 : 4;
$exportDir = 'moodle_export_' . date('Ymd_His');
$versionNum = ($moodleVersion === '3') ? 3 : 4;

$this->logDebug('[moodleExportExecute] starting exporter', [
'courseId' => $courseId,
'exportDir' => $exportDir,
'versionNum' => $versionNum,
'selection' => $selectionMode,
'scope' => $scope,
]);

$mbzPath = $exporter->export($courseId, $exportDir, $versionNum);

if (!\is_string($mbzPath) || $mbzPath === '' || !is_file($mbzPath)) {
return $this->json(['error' => 'Moodle export failed: artifact not found'], 500);
}

// Build download response
$resp = new BinaryFileResponse($mbzPath);
$resp->setContentDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
basename($mbzPath)
);
$resp->headers->set('X-Moodle-Version', (string) $versionNum);
$resp->headers->set('X-Export-Scope', $scope);
$resp->headers->set('X-Selection-Mode', $selectionMode ? '1' : '0');

return $resp;
} catch (Throwable $e) {
return $this->json(['error' => 'Moodle export failed: '.$e->getMessage()], 500);
} catch (\Throwable $e) {
$this->logDebug('[moodleExportExecute] exception', [
'message' => $e->getMessage(),
'code' => (int) $e->getCode(),
]);

return $this->json(['error' => 'Moodle export failed: ' . $e->getMessage()], 500);
}
}

Expand Down Expand Up @@ -1001,15 +1033,15 @@
if ($selectionMode) {
// Convert to the expected structure for filterCourseResources()
$safeSelected = [
'documents' => array_fill_keys(array_map('intval', array_keys($normSel['documents'] ?? [])), true),

Check failure on line 1036 in src/CoreBundle/Controller/CourseMaintenanceController.php

View workflow job for this annotation

GitHub Actions / PHP 8.2 Test on ubuntu-latest

EmptyArrayAccess

src/CoreBundle/Controller/CourseMaintenanceController.php:1036:72: EmptyArrayAccess: Cannot access value on empty array variable (see https://psalm.dev/100)
'links' => array_fill_keys(array_map('intval', array_keys($normSel['links'] ?? [])), true),

Check failure on line 1037 in src/CoreBundle/Controller/CourseMaintenanceController.php

View workflow job for this annotation

GitHub Actions / PHP 8.2 Test on ubuntu-latest

EmptyArrayAccess

src/CoreBundle/Controller/CourseMaintenanceController.php:1037:72: EmptyArrayAccess: Cannot access value on empty array variable (see https://psalm.dev/100)
'forums' => array_fill_keys(array_map('intval', array_keys($normSel['forums'] ?? [])), true),

Check failure on line 1038 in src/CoreBundle/Controller/CourseMaintenanceController.php

View workflow job for this annotation

GitHub Actions / PHP 8.2 Test on ubuntu-latest

EmptyArrayAccess

src/CoreBundle/Controller/CourseMaintenanceController.php:1038:72: EmptyArrayAccess: Cannot access value on empty array variable (see https://psalm.dev/100)
];
// Also include expansions from categories
$fullSnapshot = isset($courseFull) ? $courseFull : $course;
$expandedAll = $this->expandCc13SelectionFromCategories($fullSnapshot, $normSel);
foreach (['documents','links','forums'] as $k) {
foreach (array_keys($expandedAll[$k] ?? []) as $idStr) {

Check failure on line 1044 in src/CoreBundle/Controller/CourseMaintenanceController.php

View workflow job for this annotation

GitHub Actions / PHP 8.2 Test on ubuntu-latest

NoValue

src/CoreBundle/Controller/CourseMaintenanceController.php:1044:68: NoValue: All possible types for this assignment were invalidated - This may be dead code (see https://psalm.dev/179)
$safeSelected[$k][(int)$idStr] = true;
}
}
Expand Down Expand Up @@ -3145,7 +3177,7 @@
'survey_questions' => RESOURCE_SURVEYQUESTION,
'announcements' => RESOURCE_ANNOUNCEMENT,
'events' => RESOURCE_EVENT,
'course_descriptions' => RESOURCE_COURSEDESCRIPTION,
'course_description' => RESOURCE_COURSEDESCRIPTION,
'glossary' => RESOURCE_GLOSSARY,
'wiki' => RESOURCE_WIKI,
'thematic' => RESOURCE_THEMATIC,
Expand Down Expand Up @@ -3608,6 +3640,7 @@
if ($has('work')) { $want[] = 'works'; }
if ($has('glossary')) { $want[] = 'glossary'; }
if ($has('tool_intro')) { $want[] = 'tool_intro'; }
if ($has('course_descriptions') || $has('course_description')) { $tools[] = 'course_descriptions'; }

// Dedup
return array_values(array_unique(array_filter($want)));
Expand Down
72 changes: 65 additions & 7 deletions src/CourseBundle/Component/CourseCopy/CourseRecycler.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,22 @@ private function recycleGeneric(
bool $autoClean = false,
bool $scormCleanup = false
): void {
$repo = $this->em->getRepository($entityClass);
$hasHardDelete = method_exists($repo, 'hardDelete');

if ($isFull) {
$this->deleteAllOfTypeForCourse($entityClass);
$resources = $this->fetchResourcesForCourse($entityClass, null);
if ($resources) {
$this->hardDeleteMany($entityClass, $resources);

// Physical delete fallback for documents if repo lacks hardDelete()
if ($deleteFiles && !$hasHardDelete && CDocument::class === $entityClass) {
foreach ($resources as $res) {
$this->physicallyDeleteDocumentFiles($res);
}
}
}

if ($autoClean) {
$this->autoCleanIfSupported($entityClass);
}
Expand All @@ -129,7 +143,16 @@ private function recycleGeneric(
return;
}

$this->deleteSelectedOfTypeForCourse($entityClass, $ids);
$resources = $this->fetchResourcesForCourse($entityClass, $ids);
if ($resources) {
$this->hardDeleteMany($entityClass, $resources);

if ($deleteFiles && !$hasHardDelete && CDocument::class === $entityClass) {
foreach ($resources as $res) {
$this->physicallyDeleteDocumentFiles($res);
}
}
}

if ($autoClean) {
$this->autoCleanIfSupported($entityClass);
Expand Down Expand Up @@ -196,7 +219,16 @@ private function fetchResourcesForCourse(string $entityClass, ?array $ids = null
if (method_exists($repo, 'getResourcesByCourseIgnoreVisibility')) {
$qb = $repo->getResourcesByCourseIgnoreVisibility($this->courseRef());
if ($ids && \count($ids) > 0) {
$qb->andWhere('resource.iid IN (:ids)')->setParameter('ids', $ids);
// Try iid first; if the entity has no iid, fall back to id
$meta = $this->em->getClassMetadata($entityClass);
$hasIid = $meta->hasField('iid');

if ($hasIid) {
$qb->andWhere('resource.iid IN (:ids)');
} else {
$qb->andWhere('resource.id IN (:ids)');
}
$qb->setParameter('ids', $ids);
}

return $qb->getQuery()->getResult();
Expand All @@ -213,7 +245,9 @@ private function fetchResourcesForCourse(string $entityClass, ?array $ids = null
;

if ($ids && \count($ids) > 0) {
$qb->andWhere('resource.iid IN (:ids)')->setParameter('ids', $ids);
$meta = $this->em->getClassMetadata($entityClass);
$field = $meta->hasField('iid') ? 'resource.iid' : 'resource.id';
$qb->andWhere("$field IN (:ids)")->setParameter('ids', $ids);
}

return $qb->getQuery()->getResult();
Expand Down Expand Up @@ -269,9 +303,11 @@ private function hardDeleteMany(string $entityClass, array $resources): void
}
}

if ($usedFallback) {
$this->em->flush();
}
// Always flush once at the end of the batch to materialize changes
$this->em->flush();

// Optional: clear EM to reduce memory in huge batches
// $this->em->clear();
}

/**
Expand Down Expand Up @@ -388,6 +424,28 @@ private function unplugCertificateDocsForCourse(): void
}
}

/** @param CDocument $doc */
private function physicallyDeleteDocumentFiles(AbstractResource $doc): void
{
// This generic example traverses node->resourceFiles and removes them from disk.
$node = $doc->getResourceNode();
if (!method_exists($node, 'getResourceFiles')) {
return;
}

foreach ($node->getResourceFiles() as $rf) {
// Example: if you have an absolute path getter or storage key
if (method_exists($rf, 'getAbsolutePath')) {
$path = (string) $rf->getAbsolutePath();
if ($path && file_exists($path)) {
@unlink($path);
}
}
// If you use a storage service, call it here instead of unlink()
// $this->storage->delete($rf->getStorageKey());
}
}

/**
* SCORM directory cleanup for ALL LPs (hook your storage service here if needed).
*/
Expand Down
Loading
Loading