diff --git a/assets/vue/services/courseMaintenance.js b/assets/vue/services/courseMaintenance.js
index e63fe5c6bd4..88197e2fb45 100644
--- a/assets/vue/services/courseMaintenance.js
+++ b/assets/vue/services/courseMaintenance.js
@@ -219,8 +219,11 @@ async function recycleCourse(node = resolveNodeFromPath(), payload) {
const resp = await http.post(base.recycleCourse(node), payload, { params: withCourseParams() })
return resp.data
}
-async function deleteCourse(node = resolveNodeFromPath(), confirmText) {
- const resp = await http.post(base.deleteCourse(node), { confirm: confirmText }, { params: withCourseParams() })
+async function deleteCourse(node = resolveNodeFromPath(), payloadOrConfirm) {
+ const payload = typeof payloadOrConfirm === "string" ? { confirm: payloadOrConfirm } : payloadOrConfirm || {}
+
+ const resp = await http.post(base.deleteCourse(node), payload, { params: withCourseParams() })
+
return resp.data
}
diff --git a/assets/vue/views/coursemaintenance/DeleteCourse.vue b/assets/vue/views/coursemaintenance/DeleteCourse.vue
index 3e433826d12..df43d76b92c 100644
--- a/assets/vue/views/coursemaintenance/DeleteCourse.vue
+++ b/assets/vue/views/coursemaintenance/DeleteCourse.vue
@@ -1,35 +1,82 @@
-
+
- {{ t("Confirm deletion") }}
+
+ {{ t("Confirm deletion") }}
+
{{ t("Type the course code to confirm. All data will be permanently removed.") }}
-
{{ t("Course code") }}
+
+ {{ t("Course code") }}
+
-
+
{{ t("The code must match exactly:") }} {{ courseCode }}
+
+
+
+
+
+ {{ t("Also delete documents that are only used in this course (if any).") }}
+
+ {{
+ t(
+ "If unchecked, those files will remain available to the platform administrator through the 'File information' tool.",
+ )
+ }}
+
+
+
+
-
- {{ t("Delete course") }}
+
+
+ {{ t("Delete course") }}
-
-
+
+
@@ -47,11 +94,11 @@ const route = useRoute()
const node = ref(Number(route.params.node || 0))
const confirmText = ref("")
+const deleteDocs = ref(false)
const loading = ref(false)
const error = ref("")
const notice = ref("")
-// Read current course from Pinia (header ya lo muestra)
const cidReq = useCidReqStore()
const { course } = storeToRefs(cidReq)
const courseCode = computed(() => String(course?.value?.code || ""))
@@ -61,12 +108,23 @@ const canDelete = computed(() => !!confirmText.value && confirmText.value === co
async function submit() {
if (!confirm(t("This action cannot be undone. Continue?"))) return
- error.value = ""; notice.value = ""
+
+ error.value = ""
+ notice.value = ""
+
try {
loading.value = true
- const res = await svc.deleteCourse(node.value, confirmText.value)
+
+ const payload = {
+ confirm: confirmText.value,
+ delete_docs: deleteDocs.value ? 1 : 0,
+ }
+
+ const res = await svc.deleteCourse(node.value, payload)
notice.value = res.message || t("Course deleted successfully.")
- if (res.redirectUrl) window.location.href = res.redirectUrl
+ if (res.redirectUrl) {
+ window.location.href = res.redirectUrl
+ }
} catch (e) {
error.value = e?.response?.data?.error || t("Failed to delete course.")
} finally {
diff --git a/public/main/admin/course_list.php b/public/main/admin/course_list.php
index 80ca1d279af..b5df579928d 100644
--- a/public/main/admin/course_list.php
+++ b/public/main/admin/course_list.php
@@ -240,6 +240,8 @@ function get_course_data(
),
$path.'course_copy/create_backup.php?'.api_get_cidreq_params($courseId)
);
+
+ // Single course delete: ask if exclusive documents should also be removed.
$actions[] = Display::url(
Display::getMdiIcon(
ActionIcon::DELETE,
@@ -251,12 +253,12 @@ function get_course_data(
$path.'admin/course_list.php?'
.http_build_query([
'delete_course' => $course['col0'],
+ // Default: keep documents; JS will toggle this param to 1 if admin agrees.
+ 'delete_docs' => 0,
'sec_token' => Security::getTokenFromSession(),
]),
[
- 'onclick' => "javascript: if (!confirm('"
- .addslashes(api_htmlentities(get_lang('Please confirm your choice'), \ENT_QUOTES))
- ."')) return false;",
+ 'onclick' => 'return confirmDeleteCourseWithDocs(this);',
]
);
@@ -361,9 +363,11 @@ function get_course_visibility_icon(int $visibility): string
if ('delete_courses' == $_POST['action']) {
if (!empty($_POST['course'])) {
$course_codes = $_POST['course'];
+ $deleteDocs = isset($_POST['delete_docs']) && (int) $_POST['delete_docs'] === 1;
+
if (count($course_codes) > 0) {
foreach ($course_codes as $course_code) {
- CourseManager::delete_course($course_code);
+ CourseManager::delete_course($course_code, $deleteDocs);
}
}
@@ -459,8 +463,11 @@ function get_course_visibility_icon(int $visibility): string
$content .= $form->returnForm();
} else {
$tool_name = get_lang('Course list');
+
+ // Single course deletion (from action icon)
if (isset($_GET['delete_course']) && Security::check_token('get')) {
- $result = CourseManager::delete_course($_GET['delete_course']);
+ $deleteDocs = isset($_GET['delete_docs']) && (int) $_GET['delete_docs'] === 1;
+ $result = CourseManager::delete_course($_GET['delete_course'], $deleteDocs);
if ($result) {
Display::addFlash(Display::return_message(get_lang('Deleted')));
}
@@ -573,6 +580,7 @@ function get_course_visibility_icon(int $visibility): string
';
$actions = Display::toolbarAction('toolbar', [$actions1, $actions3.$actions4.$actions2]);
+
// Create a sortable table with the course data
$table = new SortableTable(
'courses',
@@ -584,10 +592,12 @@ function get_course_visibility_icon(int $visibility): string
'course-list'
);
- $parameters = [];
- $parameters['sec_token'] = Security::get_token();
+ $parameters = [
+ 'sec_token' => Security::get_token(),
+ ];
+
if (isset($_GET['keyword'])) {
- $parameters = ['keyword' => Security::remove_XSS($_GET['keyword'])];
+ $parameters['keyword'] = Security::remove_XSS($_GET['keyword']);
} elseif (isset($_GET['keyword_code'])) {
$parameters['keyword_code'] = Security::remove_XSS($_GET['keyword_code']);
$parameters['keyword_title'] = Security::remove_XSS($_GET['keyword_title']);
@@ -624,6 +634,114 @@ function get_course_visibility_icon(int $visibility): string
$tab = CourseManager::getCourseListTabs('simple');
$content .= $tab.$table->return_table();
+
+ // JS helper to ask for exclusive document deletion both for single and bulk delete.
+ $deleteDocsMessage = addslashes(
+ get_lang(
+ 'When deleting a course or multiple selected courses, any documents that are only used in those course(s) (if any) will normally be kept as orphan files and will remain visible in the "File information" tool (platform admin only). Click "OK" if you also want to permanently delete those orphan files from disk; click "Cancel" to keep them as orphan files.'
+ )
+ );
+
+ // Fallback confirmation text; SortableTable uses data-confirm on the link.
+ $baseConfirmMessage = addslashes(get_lang('Please confirm your choice'));
+
+ $content .= '';
+
}
$tpl = new Template($tool_name);
diff --git a/public/main/admin/course_list_admin.php b/public/main/admin/course_list_admin.php
index b842623eb27..074f5ff33b1 100644
--- a/public/main/admin/course_list_admin.php
+++ b/public/main/admin/course_list_admin.php
@@ -39,7 +39,7 @@ function get_number_of_courses()
*
* @throws Exception
*
- * @return array
+ * @return array|int
*/
function get_course_data($from, $number_of_items, $column, $direction, $dataFunctions = [], $getCount = false)
{
@@ -87,7 +87,7 @@ function get_course_data($from, $number_of_items, $column, $direction, $dataFunc
$sql .= ' WHERE 1=1 ';
if (isset($_GET['keyword'])) {
- $keyword = Database::escape_string("%".trim($_GET['keyword'])."%");
+ $keyword = Database::escape_string('%'.trim($_GET['keyword']).'%');
$sql .= " AND (
title LIKE '".$keyword."' OR
code LIKE '".$keyword."' OR
@@ -95,13 +95,13 @@ function get_course_data($from, $number_of_items, $column, $direction, $dataFunc
)
";
} elseif (isset($_GET['keyword_code'])) {
- $keyword_code = Database::escape_string("%".$_GET['keyword_code']."%");
- $keyword_title = Database::escape_string("%".$_GET['keyword_title']."%");
+ $keyword_code = Database::escape_string('%'.$_GET['keyword_code'].'%');
+ $keyword_title = Database::escape_string('%'.$_GET['keyword_title'].'%');
$keyword_category = isset($_GET['keyword_category'])
- ? Database::escape_string("%".$_GET['keyword_category']."%")
+ ? Database::escape_string('%'.$_GET['keyword_category'].'%')
: null;
- $keyword_language = Database::escape_string("%".$_GET['keyword_language']."%");
- $keyword_visibility = Database::escape_string("%".$_GET['keyword_visibility']."%");
+ $keyword_language = Database::escape_string('%'.$_GET['keyword_language'].'%');
+ $keyword_visibility = Database::escape_string('%'.$_GET['keyword_visibility'].'%');
$keyword_subscribe = Database::escape_string($_GET['keyword_subscribe']);
$keyword_unsubscribe = Database::escape_string($_GET['keyword_unsubscribe']);
@@ -120,7 +120,7 @@ function get_course_data($from, $number_of_items, $column, $direction, $dataFunc
// Adding the filter to see the user's only of the current access_url.
if (api_is_multiple_url_enabled()) {
- $sql .= " AND url_rel_course.access_url_id = ".api_get_current_access_url_id();
+ $sql .= ' AND url_rel_course.access_url_id = '.api_get_current_access_url_id();
}
if ($addTeacherColumn) {
@@ -138,7 +138,7 @@ function get_course_data($from, $number_of_items, $column, $direction, $dataFunc
}
if (false === $getCount) {
- $sql .= " GROUP BY course.id ";
+ $sql .= ' GROUP BY course.id ';
}
}
@@ -193,15 +193,20 @@ function get_course_data($from, $number_of_items, $column, $direction, $dataFunc
Display::getMdiIcon('backup', 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Create a backup')),
$path.'course_copy/create_backup.php?'.api_get_cidreq_params($courseId)
);
- $actions[] = Display::url(
- Display::getMdiIcon('delete', 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Delete')),
- $path.'admin/course_list_admin.php?'.http_build_query([
+
+ // Build delete URL with delete_docs=0 by default so JS can flip it when needed.
+ $deleteUrl = $path.'admin/course_list_admin.php?'.http_build_query([
'delete_course' => $courseCode,
+ 'delete_docs' => 0,
'sec_token' => Security::getTokenFromSession(),
- ]),
+ ]);
+
+ $actions[] = Display::url(
+ Display::getMdiIcon('delete', 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Delete')),
+ $deleteUrl,
[
- 'onclick' => "javascript: if (!confirm('"
- .addslashes(api_htmlentities(get_lang('Please confirm your choice'), ENT_QUOTES))."')) return false;",
+ // JS helper will show both confirms (course + docs).
+ 'onclick' => 'return confirmDeleteCourseWithDocs(this);',
]
);
@@ -252,19 +257,14 @@ function get_course_visibility_icon($visibility)
switch ($visibility) {
case 0:
return Display::getMdiIcon(StateIcon::CLOSED_VISIBILITY, 'ch-tool-icon', $style, ICON_SIZE_SMALL, get_lang('Closed - the course is only accessible to the teachers'));
- break;
case 1:
return Display::getMdiIcon(StateIcon::PRIVATE_VISIBILITY, 'ch-tool-icon', $style, ICON_SIZE_SMALL, get_lang('Private access (access authorized to group members only)'));
- break;
case 2:
return Display::getMdiIcon(StateIcon::OPEN_VISIBILITY, 'ch-tool-icon', $style, ICON_SIZE_SMALL, get_lang('Open - access allowed for users registered on the platform'));
- break;
case 3:
return Display::getMdiIcon(StateIcon::PUBLIC_VISIBILITY, 'ch-tool-icon', $style, ICON_SIZE_SMALL, get_lang('Public - access allowed for the whole world'));
- break;
case 4:
return Display::getMdiIcon(StateIcon::HIDDEN_VISIBILITY, 'ch-tool-icon', $style, ICON_SIZE_SMALL, get_lang('Hidden - Completely hidden to all users except the administrators'));
- break;
default:
return '';
}
@@ -276,9 +276,14 @@ function get_course_visibility_icon($visibility)
case 'delete_courses':
if (!empty($_POST['course'])) {
$course_codes = $_POST['course'];
+
+ // Read delete_docs flag from bulk form (0 by default).
+ $deleteDocsFlag = isset($_POST['delete_docs']) && '1' === (string) $_POST['delete_docs'];
+
if (count($course_codes) > 0) {
foreach ($course_codes as $course_code) {
- CourseManager::delete_course($course_code);
+ // Second parameter controls whether orphan-only documents are also deleted from disk.
+ CourseManager::delete_course($course_code, $deleteDocsFlag);
}
}
@@ -363,7 +368,10 @@ function get_course_visibility_icon($visibility)
];
$tool_name = get_lang('Course list');
if (isset($_GET['delete_course']) && Security::check_token('get')) {
- $result = CourseManager::delete_course($_GET['delete_course']);
+ // Read delete_docs flag from single delete link (defaults to 0 when absent).
+ $deleteDocsFlag = isset($_GET['delete_docs']) && '1' === (string) $_GET['delete_docs'];
+
+ $result = CourseManager::delete_course($_GET['delete_course'], $deleteDocsFlag);
if ($result) {
Display::addFlash(Display::return_message(get_lang('Deleted')));
}
@@ -446,7 +454,7 @@ function get_course_visibility_icon($visibility)
if (isset($_GET['course_teachers'])) {
$parsed = array_map('intval', $_GET['course_teachers']);
- $parameters["course_teachers"] = '';
+ $parameters['course_teachers'] = '';
foreach ($parsed as $key => $teacherId) {
$parameters["course_teachers[$key]"] = $teacherId;
}
@@ -458,9 +466,6 @@ function get_course_visibility_icon($visibility)
$table->set_header($column++, get_lang('Title'), true, null, ['class' => 'title']);
$table->set_header($column++, get_lang('Creation date'), true, 'width="70px"');
$table->set_header($column++, get_lang('Latest access in course'), false, 'width="70px"');
- //$table->set_header($column++, get_lang('Category'));
- //$table->set_header($column++, get_lang('Registr. allowed'), true, 'width="60px"');
- //$table->set_header($column++, get_lang('Unreg. allowed'), false, 'width="50px"');
if ($addTeacherColumn) {
$table->set_header($column++, get_lang('Teachers'), true);
}
@@ -479,6 +484,7 @@ function get_course_visibility_icon($visibility)
$content .= $tab.$table->return_table();
}
+// Small JS helper for teacher filter.
$htmlHeadXtra[] = '
';
+$deleteDocsMessage = addslashes(
+ get_lang(
+ 'When deleting a course or multiple selected courses, any documents that are only used in those course(s) (if any) will normally be kept as orphan files and will remain visible in the "File information" tool (platform admin only). Click "OK" if you also want to permanently delete those orphan files from disk; click "Cancel" to keep them as orphan files.'
+ )
+);
+
+$htmlHeadXtra[] = '
+';
+
+
$tpl = new Template($tool_name);
$tpl->assign('actions', $actions);
$tpl->assign('message', $message);
diff --git a/public/main/inc/lib/course.lib.php b/public/main/inc/lib/course.lib.php
index ffa27ac10a5..fa482594394 100644
--- a/public/main/inc/lib/course.lib.php
+++ b/public/main/inc/lib/course.lib.php
@@ -6,6 +6,8 @@
use Chamilo\CoreBundle\Entity\Course;
use Chamilo\CoreBundle\Entity\CourseRelUser;
use Chamilo\CoreBundle\Entity\ExtraField as EntityExtraField;
+use Chamilo\CoreBundle\Entity\ResourceFile;
+use Chamilo\CoreBundle\Entity\ResourceNode;
use Chamilo\CoreBundle\Entity\SequenceResource;
use Chamilo\CoreBundle\Entity\Session as SessionEntity;
use Chamilo\CoreBundle\Entity\User;
@@ -2437,20 +2439,18 @@ public static function get_group_list_of_course(
}
/**
- * Delete a course
- * This function deletes a whole course-area from the platform. When the
- * given course is a virtual course, the database and directory will not be
- * deleted.
- * When the given course is a real course, also all virtual courses refering
- * to the given course will be deleted.
- * Considering the fact that we remove all traces of the course in the main
- * database, it makes sense to remove all tracking as well (if stats databases exist)
- * so that a new course created with this code would not use the remains of an older
- * course.
- *
- * @param string $code The code of the course to delete
+ * Delete a course and all its related data.
+ *
+ * Optionally, also delete ResourceFile entries (and their physical files)
+ * that are only used in this course and become unused after deletion.
+ *
+ * @param string $code Course code
+ * @param bool $deleteExclusiveDocuments If true, delete documents that are
+ * only used in this course.
+ *
+ * @return bool
*/
- public static function delete_course($code)
+ public static function delete_course($code, bool $deleteExclusiveDocuments = false)
{
$table_course_user = Database::get_main_table(TABLE_MAIN_COURSE_USER);
$table_session_course = Database::get_main_table(TABLE_MAIN_SESSION_COURSE);
@@ -2499,6 +2499,13 @@ public static function delete_course($code)
return false;
}
+ // Collect exclusive documents before removing the course, so we still
+ // have access to the course links when evaluating exclusivity.
+ $exclusiveFiles = [];
+ if ($deleteExclusiveDocuments) {
+ $exclusiveFiles = self::getExclusiveResourceFilesForCourse($course);
+ }
+
$count = 0;
if (api_is_multiple_url_enabled()) {
$url_id = 1;
@@ -2585,7 +2592,7 @@ public static function delete_course($code)
// Class
$table = Database::get_main_table(TABLE_USERGROUP_REL_COURSE);
$sql = "DELETE FROM $table
- WHERE course_id = $courseId";
+ WHERE course_id = $courseId";
Database::query($sql);
// Skills
@@ -2597,7 +2604,7 @@ public static function delete_course($code)
)
);
$sql = "UPDATE $table SET course_id = NULL, session_id = NULL, argumentation = '$argumentation'
- WHERE course_id = $courseId";
+ WHERE course_id = $courseId";
Database::query($sql);
// Should be deleted by doctrine
@@ -2605,7 +2612,7 @@ public static function delete_course($code)
//Database::query($sql);
// Deletes all groups, group-users, group-tutors information
- // To prevent fK mix up on some tables
+ // To prevent FK mix up on some tables
//GroupManager::deleteAllGroupsFromCourse($courseId);
$appPlugin = new AppPlugin();
@@ -2617,6 +2624,12 @@ public static function delete_course($code)
// Delete the course from the database
$courseRepo->deleteCourse($course);
+ // Delete documents that were exclusively used by this course,
+ // if the administrator explicitly requested it.
+ if ($deleteExclusiveDocuments && !empty($exclusiveFiles)) {
+ self::deleteExclusiveResourceFiles($exclusiveFiles);
+ }
+
// delete extra course fields
$extraFieldValues = new ExtraFieldValue('course');
$extraFieldValues->deleteValuesByItem($courseId);
@@ -2633,6 +2646,99 @@ public static function delete_course($code)
return true;
}
+
+ return false;
+ }
+
+ /**
+ * Return ResourceFile entities that are only used in the given course.
+ *
+ * A "course-exclusive" file is defined as:
+ * - At least one ResourceLink exists for this course.
+ * - No ResourceLink exists for any other course (or other context)
+ * for the same ResourceNode.
+ *
+ * These are the files that will become completely unused once the course
+ * is deleted, and are candidates for physical deletion.
+ *
+ * @param Course $course
+ *
+ * @return ResourceFile[]
+ */
+ private static function getExclusiveResourceFilesForCourse(Course $course): array
+ {
+ $em = Database::getManager();
+
+ $dql = '
+ SELECT DISTINCT rf
+ FROM Chamilo\CoreBundle\Entity\ResourceFile rf
+ JOIN rf.resourceNode rn
+ JOIN rn.resourceLinks rl
+ WHERE rl.course = :course
+ AND NOT EXISTS (
+ SELECT 1
+ FROM Chamilo\CoreBundle\Entity\ResourceLink rl2
+ WHERE rl2.resourceNode = rn
+ AND rl2.course != :course
+ )
+ ';
+
+ return $em->createQuery($dql)
+ ->setParameter('course', $course)
+ ->getResult();
+ }
+
+ /**
+ * Delete the given ResourceFile entries and their physical files
+ * under var/upload/resource.
+ *
+ * @param ResourceFile[] $files
+ */
+ private static function deleteExclusiveResourceFiles(array $files): void
+ {
+ if (empty($files)) {
+ return;
+ }
+
+ $em = Database::getManager();
+ $nodeRepo = Container::getResourceNodeRepository();
+
+ // Base directory for stored resources; we rely on getFilename($file)
+ // returning a path relative to this root (usually starting with "/").
+ $basePath = api_get_path(SYS_PATH).'var/upload/resource';
+
+ foreach ($files as $file) {
+ if (!$file instanceof ResourceFile) {
+ continue;
+ }
+
+ // Compute physical path before manipulating Doctrine state
+ $relativePath = $nodeRepo->getFilename($file);
+ $absolutePath = $basePath.$relativePath;
+
+ if (is_file($absolutePath) && is_writable($absolutePath)) {
+ @unlink($absolutePath);
+ }
+
+ // Ensure ResourceFile is managed before removal
+ if (!$em->contains($file)) {
+ $file = $em->getReference(ResourceFile::class, $file->getId());
+ }
+
+ $resourceNode = $file->getResourceNode();
+ if ($resourceNode instanceof ResourceNode) {
+ // Ensure ResourceNode is managed before removal
+ if (!$em->contains($resourceNode)) {
+ $resourceNode = $em->getReference(ResourceNode::class, $resourceNode->getId());
+ }
+
+ $em->remove($resourceNode);
+ }
+
+ $em->remove($file);
+ }
+
+ $em->flush();
}
/**
diff --git a/src/CoreBundle/Controller/Admin/AdminController.php b/src/CoreBundle/Controller/Admin/AdminController.php
index fa00d6e2fae..902aad5b2ed 100644
--- a/src/CoreBundle/Controller/Admin/AdminController.php
+++ b/src/CoreBundle/Controller/Admin/AdminController.php
@@ -8,11 +8,14 @@
use Chamilo\CoreBundle\Component\Composer\ScriptHandler;
use Chamilo\CoreBundle\Controller\BaseController;
+use Chamilo\CoreBundle\Entity\Course;
+use Chamilo\CoreBundle\Entity\ResourceFile;
use Chamilo\CoreBundle\Entity\ResourceLink;
use Chamilo\CoreBundle\Entity\ResourceType;
use Chamilo\CoreBundle\Helpers\AccessUrlHelper;
use Chamilo\CoreBundle\Helpers\QueryCacheHelper;
use Chamilo\CoreBundle\Helpers\TempUploadHelper;
+use Chamilo\CoreBundle\Repository\Node\CourseRepository;
use Chamilo\CoreBundle\Repository\Node\UserRepository;
use Chamilo\CoreBundle\Repository\ResourceFileRepository;
use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
@@ -64,20 +67,31 @@ public function listFilesInfo(Request $request, ResourceFileRepository $resource
$files = $resourceFileRepository->searchFiles($search, $offset, self::ITEMS_PER_PAGE);
$totalItems = $resourceFileRepository->countFiles($search);
- $totalPages = $totalItems > 0 ? ceil($totalItems / self::ITEMS_PER_PAGE) : 1;
+ $totalPages = $totalItems > 0 ? (int) ceil($totalItems / self::ITEMS_PER_PAGE) : 1;
$fileUrls = [];
$filePaths = [];
+ $orphanFlags = [];
+ $linksCount = [];
+
foreach ($files as $file) {
$resourceNode = $file->getResourceNode();
+ $count = 0;
+
if ($resourceNode) {
$fileUrls[$file->getId()] = $this->resourceNodeRepository->getResourceFileUrl($resourceNode);
- $creator = $resourceNode->getCreator();
+
+ // Count how many ResourceLinks still point to this node
+ $links = $resourceNode->getResourceLinks();
+ $count = $links ? $links->count() : 0;
} else {
$fileUrls[$file->getId()] = null;
- $creator = null;
}
+
$filePaths[$file->getId()] = '/upload/resource'.$this->resourceNodeRepository->getFilename($file);
+
+ $linksCount[$file->getId()] = $count;
+ $orphanFlags[$file->getId()] = 0 === $count;
}
return $this->render('@ChamiloCore/Admin/files_info.html.twig', [
@@ -87,6 +101,191 @@ public function listFilesInfo(Request $request, ResourceFileRepository $resource
'totalPages' => $totalPages,
'currentPage' => $page,
'search' => $search,
+ 'orphanFlags' => $orphanFlags,
+ 'linksCount' => $linksCount,
+ ]);
+ }
+
+ #[IsGranted('ROLE_ADMIN')]
+ #[Route('/files_info/attach', name: 'admin_files_info_attach', methods: ['POST'])]
+ public function attachOrphanFileToCourse(
+ Request $request,
+ ResourceFileRepository $resourceFileRepository,
+ CourseRepository $courseRepository,
+ EntityManagerInterface $em
+ ): Response {
+ $token = (string) $request->request->get('_token', '');
+ if (!$this->isCsrfTokenValid('attach_orphan_file', $token)) {
+ throw $this->createAccessDeniedException('Invalid CSRF token.');
+ }
+
+ $fileId = $request->request->getInt('resource_file_id', 0);
+ $courseCode = trim((string) $request->request->get('course_code', ''));
+
+ $page = $request->request->getInt('page', 1);
+ $search = (string) $request->request->get('search', '');
+
+ if ($fileId <= 0) {
+ $this->addFlash('error', 'Missing resource file identifier.');
+
+ return $this->redirectToRoute('admin_files_info', [
+ 'page' => $page,
+ 'search' => $search,
+ ]);
+ }
+
+ if ('' === $courseCode) {
+ $this->addFlash('error', 'Please provide a course code.');
+
+ return $this->redirectToRoute('admin_files_info', [
+ 'page' => $page,
+ 'search' => $search,
+ ]);
+ }
+
+ /** @var ResourceFile|null $resourceFile */
+ $resourceFile = $resourceFileRepository->find($fileId);
+ if (!$resourceFile) {
+ $this->addFlash('error', 'Resource file not found.');
+
+ return $this->redirectToRoute('admin_files_info', [
+ 'page' => $page,
+ 'search' => $search,
+ ]);
+ }
+
+ $resourceNode = $resourceFile->getResourceNode();
+ $linksCount = $resourceNode ? $resourceNode->getResourceLinks()->count() : 0;
+ if ($linksCount > 0) {
+ // Safety check: this file is not orphan anymore.
+ $this->addFlash('warning', 'This file is no longer orphan and cannot be attached.');
+
+ return $this->redirectToRoute('admin_files_info', [
+ 'page' => $page,
+ 'search' => $search,
+ ]);
+ }
+
+ /** @var Course|null $course */
+ $course = $courseRepository->findOneBy(['code' => $courseCode]);
+ if (!$course) {
+ $this->addFlash('error', sprintf('Course with code "%s" was not found.', $courseCode));
+
+ return $this->redirectToRoute('admin_files_info', [
+ 'page' => $page,
+ 'search' => $search,
+ ]);
+ }
+
+ if (!$resourceNode) {
+ $this->addFlash('error', 'This resource file has no resource node and cannot be attached.');
+
+ return $this->redirectToRoute('admin_files_info', [
+ 'page' => $page,
+ 'search' => $search,
+ ]);
+ }
+
+ // re-parent the ResourceNode to the course documents root
+ if (method_exists($course, 'getResourceNode')) {
+ $courseRootNode = $course->getResourceNode();
+
+ if ($courseRootNode) {
+ $resourceNode->setParent($courseRootNode);
+ }
+ }
+
+ // Create a new ResourceLink so that the file appears in the course context
+ $link = new ResourceLink();
+ $link->setResourceNode($resourceNode);
+ $link->setCourse($course);
+ $link->setSession(null);
+ $em->persist($link);
+ $em->flush();
+
+ $this->addFlash(
+ 'success',
+ sprintf(
+ 'File "%s" has been attached to course "%s" (hidden in the documents root).',
+ (string) ($resourceFile->getOriginalName() ?? $resourceFile->getTitle() ?? $resourceFile->getId()),
+ (string) $course->getTitle()
+ )
+ );
+
+ return $this->redirectToRoute('admin_files_info', [
+ 'page' => $page,
+ 'search' => $search,
+ ]);
+ }
+
+ #[IsGranted('ROLE_ADMIN')]
+ #[Route('/files_info/delete', name: 'admin_files_info_delete', methods: ['POST'])]
+ public function deleteOrphanFile(
+ Request $request,
+ ResourceFileRepository $resourceFileRepository,
+ EntityManagerInterface $em
+ ): Response {
+ $token = (string) $request->request->get('_token', '');
+ if (!$this->isCsrfTokenValid('delete_orphan_file', $token)) {
+ throw $this->createAccessDeniedException('Invalid CSRF token.');
+ }
+
+ $fileId = $request->request->getInt('resource_file_id', 0);
+ $page = $request->request->getInt('page', 1);
+ $search = (string) $request->request->get('search', '');
+
+ if ($fileId <= 0) {
+ $this->addFlash('error', 'Missing resource file identifier.');
+
+ return $this->redirectToRoute('admin_files_info', [
+ 'page' => $page,
+ 'search' => $search,
+ ]);
+ }
+
+ $resourceFile = $resourceFileRepository->find($fileId);
+ if (!$resourceFile) {
+ $this->addFlash('error', 'Resource file not found.');
+
+ return $this->redirectToRoute('admin_files_info', [
+ 'page' => $page,
+ 'search' => $search,
+ ]);
+ }
+
+ $resourceNode = $resourceFile->getResourceNode();
+ $linksCount = $resourceNode ? $resourceNode->getResourceLinks()->count() : 0;
+ if ($linksCount > 0) {
+ $this->addFlash('warning', 'This file is still used by at least one course/session and cannot be deleted.');
+
+ return $this->redirectToRoute('admin_files_info', [
+ 'page' => $page,
+ 'search' => $search,
+ ]);
+ }
+
+ // Compute physical path in var/upload/resource (adapt if you use another directory).
+ $relativePath = $this->resourceNodeRepository->getFilename($resourceFile);
+ $storageRoot = $this->getParameter('kernel.project_dir').'/var/upload/resource';
+ $absolutePath = $storageRoot.$relativePath;
+
+ if (is_file($absolutePath) && is_writable($absolutePath)) {
+ @unlink($absolutePath);
+ }
+
+ // Optionally remove the resource node as well if it is really orphan.
+ if ($resourceNode) {
+ $em->remove($resourceNode);
+ }
+
+ $em->remove($resourceFile);
+ $em->flush();
+
+ $this->addFlash('success', 'Orphan file and its physical content have been deleted definitively.');
+
+ return $this->redirectToRoute('admin_files_info', [
+ 'page' => $page,
+ 'search' => $search,
]);
}
diff --git a/src/CoreBundle/Controller/CourseMaintenanceController.php b/src/CoreBundle/Controller/CourseMaintenanceController.php
index 435efea2c7d..54381f96095 100644
--- a/src/CoreBundle/Controller/CourseMaintenanceController.php
+++ b/src/CoreBundle/Controller/CourseMaintenanceController.php
@@ -601,7 +601,11 @@ public function recycleExecute(Request $req, EntityManagerInterface $em): JsonRe
public function deleteCourse(int $node, Request $req): JsonResponse
{
// Basic permission gate (adjust roles to your policy if needed)
- if (!$this->isGranted('ROLE_ADMIN') && !$this->isGranted('ROLE_TEACHER') && !$this->isGranted('ROLE_CURRENT_COURSE_TEACHER')) {
+ if (
+ !$this->isGranted('ROLE_ADMIN')
+ && !$this->isGranted('ROLE_TEACHER')
+ && !$this->isGranted('ROLE_CURRENT_COURSE_TEACHER')
+ ) {
return $this->json(['error' => 'You are not allowed to delete this course'], 403);
}
@@ -613,6 +617,11 @@ public function deleteCourse(int $node, Request $req): JsonResponse
return $this->json(['error' => 'Missing confirmation value'], 400);
}
+ // Optional flag: also delete orphan documents that belong only to this course
+ // Accepts 1/0, true/false, "1"/"0"
+ $deleteDocsRaw = $payload['delete_docs'] ?? 0;
+ $deleteDocs = filter_var($deleteDocsRaw, \FILTER_VALIDATE_BOOL);
+
// Current course
$courseInfo = api_get_course_info();
if (empty($courseInfo)) {
@@ -620,8 +629,8 @@ public function deleteCourse(int $node, Request $req): JsonResponse
}
$officialCode = (string) ($courseInfo['official_code'] ?? '');
- $runtimeCode = (string) api_get_course_id(); // often equals official code
- $sysCode = (string) ($courseInfo['sysCode'] ?? ''); // used by legacy delete
+ $runtimeCode = (string) api_get_course_id(); // often equals official code
+ $sysCode = (string) ($courseInfo['sysCode'] ?? ''); // used by legacy delete
if ('' === $sysCode) {
return $this->json(['error' => 'Invalid course system code'], 400);
@@ -634,20 +643,19 @@ public function deleteCourse(int $node, Request $req): JsonResponse
}
// Legacy delete (removes course data + unregisters members in this course)
- // Throws on failure or returns void
- CourseManager::delete_course($sysCode);
+ // Now with optional orphan-docs deletion flag.
+ CourseManager::delete_course($sysCode, $deleteDocs);
// Best-effort cleanup of legacy course session flags
try {
$ses = $req->getSession();
$ses?->remove('_cid');
$ses?->remove('_real_cid');
- } catch (Throwable) {
+ } catch (\Throwable $e) {
// swallow — not critical
}
// Decide where to send the user afterwards
- // You can use '/index.php' or a landing page
$redirectUrl = '/index.php';
return $this->json([
@@ -655,7 +663,7 @@ public function deleteCourse(int $node, Request $req): JsonResponse
'message' => 'Course deleted successfully',
'redirectUrl' => $redirectUrl,
]);
- } catch (Throwable $e) {
+ } catch (\Throwable $e) {
return $this->json([
'error' => 'Failed to delete course: '.$e->getMessage(),
'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
diff --git a/src/CoreBundle/Resources/views/Admin/files_info.html.twig b/src/CoreBundle/Resources/views/Admin/files_info.html.twig
index e8654d2a932..a14844404d4 100644
--- a/src/CoreBundle/Resources/views/Admin/files_info.html.twig
+++ b/src/CoreBundle/Resources/views/Admin/files_info.html.twig
@@ -4,6 +4,66 @@
{{ 'File Information'|trans }}
+ {# Local styles for table layout #}
+
+
@@ -111,6 +240,11 @@
var span = document.getElementsByClassName("close-button")[0];
var copyPathButton = document.getElementById('copy-path');
+ // Orphan-specific elements
+ var orphanActions = document.getElementById('orphan-actions');
+ var attachIdInput = document.getElementById('attach-resource-file-id');
+ var deleteIdInput = document.getElementById('delete-resource-file-id');
+
document.querySelectorAll('.open-modal').forEach(function(button) {
button.onclick = function(event) {
event.preventDefault();
@@ -124,7 +258,9 @@
var fileUrl = button.getAttribute('data-file-url');
var resourceNodeId = button.getAttribute('data-resource-node-id');
var resourceFileId = button.getAttribute('data-resource-file-id');
+ var isOrphan = button.getAttribute('data-is-orphan') === '1';
+ // Populate modal static info
document.getElementById('file-title').textContent = title || '';
document.getElementById('file-mime-type').textContent = mimeType || '';
document.getElementById('file-original-name').textContent = originalName || '';
@@ -136,6 +272,17 @@
document.getElementById('resource-node-id').textContent = resourceNodeId || '';
document.getElementById('resource-file-id').textContent = resourceFileId || '';
+ // Toggle orphan actions and set hidden ids
+ if (orphanActions) {
+ orphanActions.style.display = isOrphan ? 'block' : 'none';
+ }
+ if (attachIdInput) {
+ attachIdInput.value = resourceFileId || '';
+ }
+ if (deleteIdInput) {
+ deleteIdInput.value = resourceFileId || '';
+ }
+
modal.style.display = "block";
};
});