Skip to content

Commit f52bbb9

Browse files
Merge pull request #6955 from christianbeeznest/GH-6870-7
Course: Improve copy/import mzb files - refs #6870
2 parents 24a37fc + ba700d3 commit f52bbb9

File tree

6 files changed

+599
-179
lines changed

6 files changed

+599
-179
lines changed

src/CoreBundle/Controller/CourseMaintenanceController.php

Lines changed: 72 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,7 @@ public function moodleExportOptions(int $node, Request $req, UserRepository $use
676676
['value' => 'glossary', 'label' => 'Glossary'],
677677
['value' => 'learnpaths', 'label' => 'Paths learning'],
678678
['value' => 'tool_intro', 'label' => 'Course Introduction'],
679+
['value' => 'course_description', 'label' => 'Course descriptions'],
679680
];
680681

681682
$defaults['tools'] = array_column($tools, 'value');
@@ -709,6 +710,7 @@ public function moodleExportResources(int $node, Request $req): JsonResponse
709710
'learnpaths', 'learnpath_category',
710711
'works', 'glossary',
711712
'tool_intro',
713+
'course_descriptions',
712714
];
713715

714716
// Use client tools if provided; otherwise our Moodle-safe defaults
@@ -774,14 +776,14 @@ public function moodleExportExecute(int $node, Request $req, UserRepository $use
774776
{
775777
$this->setDebugFromRequest($req);
776778

777-
$p = json_decode($req->getContent() ?: '{}', true);
778-
$moodleVersion = (string) ($p['moodleVersion'] ?? '4');
779-
$scope = (string) ($p['scope'] ?? 'full');
780-
$adminId = (int) ($p['adminId'] ?? 0);
781-
$adminLogin = trim((string) ($p['adminLogin'] ?? ''));
782-
$adminEmail = trim((string) ($p['adminEmail'] ?? ''));
783-
$selected = (array) ($p['resources'] ?? []);
784-
$toolsInput = (array) ($p['tools'] ?? []);
779+
$p = json_decode($req->getContent() ?: '{}', true) ?: [];
780+
$moodleVersion = (string) ($p['moodleVersion'] ?? '4'); // "3" | "4"
781+
$scope = (string) ($p['scope'] ?? 'full'); // "full" | "selected"
782+
$adminId = (int) ($p['adminId'] ?? 0);
783+
$adminLogin = trim((string) ($p['adminLogin'] ?? ''));
784+
$adminEmail = trim((string) ($p['adminEmail'] ?? ''));
785+
$selected = is_array($p['resources'] ?? null) ? (array) $p['resources'] : [];
786+
$toolsInput = is_array($p['tools'] ?? null) ? (array) $p['tools'] : [];
785787

786788
if (!\in_array($moodleVersion, ['3', '4'], true)) {
787789
return $this->json(['error' => 'Unsupported Moodle version'], 400);
@@ -790,7 +792,15 @@ public function moodleExportExecute(int $node, Request $req, UserRepository $use
790792
return $this->json(['error' => 'No resources selected'], 400);
791793
}
792794

793-
// Normalize tools from client (adds implied deps)
795+
$defaultTools = [
796+
'documents', 'links', 'forums',
797+
'quizzes', 'quiz_questions',
798+
'surveys', 'survey_questions',
799+
'learnpaths', 'learnpath_category',
800+
'works', 'glossary',
801+
'course_descriptions',
802+
];
803+
794804
$tools = $this->normalizeSelectedTools($toolsInput);
795805

796806
// If scope=selected, merge inferred tools from selection
@@ -799,64 +809,86 @@ public function moodleExportExecute(int $node, Request $req, UserRepository $use
799809
$tools = $this->normalizeSelectedTools(array_merge($tools, $inferred));
800810
}
801811

812+
// Remove unsupported tools
802813
$tools = array_values(array_unique(array_diff($tools, ['gradebook'])));
803-
if (!in_array('tool_intro', $tools, true)) {
804-
$tools[] = 'tool_intro';
814+
$clientSentNoTools = empty($toolsInput);
815+
$useDefault = ($scope === 'full' && $clientSentNoTools);
816+
$toolsToBuild = $useDefault ? $defaultTools : $tools;
817+
818+
// Ensure "tool_intro" is present (append only if missing)
819+
if (!in_array('tool_intro', $toolsToBuild, true)) {
820+
$toolsToBuild[] = 'tool_intro';
805821
}
806822

807-
if ($adminId <= 0 || '' === $adminLogin || '' === $adminEmail) {
808-
$adm = $users->getDefaultAdminForExport();
809-
$adminId = $adminId > 0 ? $adminId : (int) ($adm['id'] ?? 1);
810-
$adminLogin = '' !== $adminLogin ? $adminLogin : (string) ($adm['username'] ?? 'admin');
811-
$adminEmail = '' !== $adminEmail ? $adminEmail : (string) ($adm['email'] ?? 'admin@example.com');
823+
// Final dedupe/normalize
824+
$toolsToBuild = array_values(array_unique($toolsToBuild));
825+
826+
$this->logDebug('[moodleExportExecute] course tools to build (final)', $toolsToBuild);
827+
828+
if ($adminId <= 0 || $adminLogin === '' || $adminEmail === '') {
829+
$adm = $users->getDefaultAdminForExport();
830+
$adminId = $adminId > 0 ? $adminId : (int) ($adm['id'] ?? 1);
831+
$adminLogin = $adminLogin !== '' ? $adminLogin : (string) ($adm['username'] ?? 'admin');
832+
$adminEmail = $adminEmail !== '' ? $adminEmail : (string) ($adm['email'] ?? 'admin@example.com');
812833
}
813834

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

816-
// Build legacy Course from CURRENT course
817840
$cb = new CourseBuilder();
818-
$cb->set_tools_to_build(!empty($tools) ? $tools : [
819-
// Fallback should mirror the Moodle-safe list used in the picker
820-
'documents', 'links', 'forums',
821-
'quizzes', 'quiz_questions',
822-
'surveys', 'survey_questions',
823-
'learnpaths', 'learnpath_category',
824-
'works', 'glossary',
825-
'tool_intro',
826-
]);
827-
$course = $cb->build(0, api_get_course_id());
841+
$cb->set_tools_to_build($toolsToBuild);
842+
$course = $cb->build(0, $courseId);
828843

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

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

845-
$courseId = api_get_course_id();
846-
$exportDir = 'moodle_export_'.date('Ymd_His');
847-
$versionNum = ('3' === $moodleVersion) ? 3 : 4;
857+
$exportDir = 'moodle_export_' . date('Ymd_His');
858+
$versionNum = ($moodleVersion === '3') ? 3 : 4;
859+
860+
$this->logDebug('[moodleExportExecute] starting exporter', [
861+
'courseId' => $courseId,
862+
'exportDir' => $exportDir,
863+
'versionNum' => $versionNum,
864+
'selection' => $selectionMode,
865+
'scope' => $scope,
866+
]);
848867

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

870+
if (!\is_string($mbzPath) || $mbzPath === '' || !is_file($mbzPath)) {
871+
return $this->json(['error' => 'Moodle export failed: artifact not found'], 500);
872+
}
873+
874+
// Build download response
851875
$resp = new BinaryFileResponse($mbzPath);
852876
$resp->setContentDisposition(
853877
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
854878
basename($mbzPath)
855879
);
880+
$resp->headers->set('X-Moodle-Version', (string) $versionNum);
881+
$resp->headers->set('X-Export-Scope', $scope);
882+
$resp->headers->set('X-Selection-Mode', $selectionMode ? '1' : '0');
856883

857884
return $resp;
858-
} catch (Throwable $e) {
859-
return $this->json(['error' => 'Moodle export failed: '.$e->getMessage()], 500);
885+
} catch (\Throwable $e) {
886+
$this->logDebug('[moodleExportExecute] exception', [
887+
'message' => $e->getMessage(),
888+
'code' => (int) $e->getCode(),
889+
]);
890+
891+
return $this->json(['error' => 'Moodle export failed: ' . $e->getMessage()], 500);
860892
}
861893
}
862894

@@ -3145,7 +3177,7 @@ private function filterCourseResources(object $course, array $selected): void
31453177
'survey_questions' => RESOURCE_SURVEYQUESTION,
31463178
'announcements' => RESOURCE_ANNOUNCEMENT,
31473179
'events' => RESOURCE_EVENT,
3148-
'course_descriptions' => RESOURCE_COURSEDESCRIPTION,
3180+
'course_description' => RESOURCE_COURSEDESCRIPTION,
31493181
'glossary' => RESOURCE_GLOSSARY,
31503182
'wiki' => RESOURCE_WIKI,
31513183
'thematic' => RESOURCE_THEMATIC,
@@ -3608,6 +3640,7 @@ private function inferToolsFromSelection(array $selected): array
36083640
if ($has('work')) { $want[] = 'works'; }
36093641
if ($has('glossary')) { $want[] = 'glossary'; }
36103642
if ($has('tool_intro')) { $want[] = 'tool_intro'; }
3643+
if ($has('course_descriptions') || $has('course_description')) { $tools[] = 'course_descriptions'; }
36113644

36123645
// Dedup
36133646
return array_values(array_unique(array_filter($want)));

src/CourseBundle/Component/CourseCopy/CourseRecycler.php

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,22 @@ private function recycleGeneric(
108108
bool $autoClean = false,
109109
bool $scormCleanup = false
110110
): void {
111+
$repo = $this->em->getRepository($entityClass);
112+
$hasHardDelete = method_exists($repo, 'hardDelete');
113+
111114
if ($isFull) {
112-
$this->deleteAllOfTypeForCourse($entityClass);
115+
$resources = $this->fetchResourcesForCourse($entityClass, null);
116+
if ($resources) {
117+
$this->hardDeleteMany($entityClass, $resources);
118+
119+
// Physical delete fallback for documents if repo lacks hardDelete()
120+
if ($deleteFiles && !$hasHardDelete && CDocument::class === $entityClass) {
121+
foreach ($resources as $res) {
122+
$this->physicallyDeleteDocumentFiles($res);
123+
}
124+
}
125+
}
126+
113127
if ($autoClean) {
114128
$this->autoCleanIfSupported($entityClass);
115129
}
@@ -129,7 +143,16 @@ private function recycleGeneric(
129143
return;
130144
}
131145

132-
$this->deleteSelectedOfTypeForCourse($entityClass, $ids);
146+
$resources = $this->fetchResourcesForCourse($entityClass, $ids);
147+
if ($resources) {
148+
$this->hardDeleteMany($entityClass, $resources);
149+
150+
if ($deleteFiles && !$hasHardDelete && CDocument::class === $entityClass) {
151+
foreach ($resources as $res) {
152+
$this->physicallyDeleteDocumentFiles($res);
153+
}
154+
}
155+
}
133156

134157
if ($autoClean) {
135158
$this->autoCleanIfSupported($entityClass);
@@ -196,7 +219,16 @@ private function fetchResourcesForCourse(string $entityClass, ?array $ids = null
196219
if (method_exists($repo, 'getResourcesByCourseIgnoreVisibility')) {
197220
$qb = $repo->getResourcesByCourseIgnoreVisibility($this->courseRef());
198221
if ($ids && \count($ids) > 0) {
199-
$qb->andWhere('resource.iid IN (:ids)')->setParameter('ids', $ids);
222+
// Try iid first; if the entity has no iid, fall back to id
223+
$meta = $this->em->getClassMetadata($entityClass);
224+
$hasIid = $meta->hasField('iid');
225+
226+
if ($hasIid) {
227+
$qb->andWhere('resource.iid IN (:ids)');
228+
} else {
229+
$qb->andWhere('resource.id IN (:ids)');
230+
}
231+
$qb->setParameter('ids', $ids);
200232
}
201233

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

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

219253
return $qb->getQuery()->getResult();
@@ -269,9 +303,11 @@ private function hardDeleteMany(string $entityClass, array $resources): void
269303
}
270304
}
271305

272-
if ($usedFallback) {
273-
$this->em->flush();
274-
}
306+
// Always flush once at the end of the batch to materialize changes
307+
$this->em->flush();
308+
309+
// Optional: clear EM to reduce memory in huge batches
310+
// $this->em->clear();
275311
}
276312

277313
/**
@@ -388,6 +424,28 @@ private function unplugCertificateDocsForCourse(): void
388424
}
389425
}
390426

427+
/** @param CDocument $doc */
428+
private function physicallyDeleteDocumentFiles(AbstractResource $doc): void
429+
{
430+
// This generic example traverses node->resourceFiles and removes them from disk.
431+
$node = $doc->getResourceNode();
432+
if (!method_exists($node, 'getResourceFiles')) {
433+
return;
434+
}
435+
436+
foreach ($node->getResourceFiles() as $rf) {
437+
// Example: if you have an absolute path getter or storage key
438+
if (method_exists($rf, 'getAbsolutePath')) {
439+
$path = (string) $rf->getAbsolutePath();
440+
if ($path && file_exists($path)) {
441+
@unlink($path);
442+
}
443+
}
444+
// If you use a storage service, call it here instead of unlink()
445+
// $this->storage->delete($rf->getStorageKey());
446+
}
447+
}
448+
391449
/**
392450
* SCORM directory cleanup for ALL LPs (hook your storage service here if needed).
393451
*/

0 commit comments

Comments
 (0)