Skip to content

Commit 675facb

Browse files
authored
Merge pull request #7068 from christianbeeznest/GH-6372
Admin: Manage orphan course documents on deletion - refs #6372
2 parents b85dcb9 + a78c2db commit 675facb

File tree

8 files changed

+882
-129
lines changed

8 files changed

+882
-129
lines changed

assets/vue/services/courseMaintenance.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,11 @@ async function recycleCourse(node = resolveNodeFromPath(), payload) {
219219
const resp = await http.post(base.recycleCourse(node), payload, { params: withCourseParams() })
220220
return resp.data
221221
}
222-
async function deleteCourse(node = resolveNodeFromPath(), confirmText) {
223-
const resp = await http.post(base.deleteCourse(node), { confirm: confirmText }, { params: withCourseParams() })
222+
async function deleteCourse(node = resolveNodeFromPath(), payloadOrConfirm) {
223+
const payload = typeof payloadOrConfirm === "string" ? { confirm: payloadOrConfirm } : payloadOrConfirm || {}
224+
225+
const resp = await http.post(base.deleteCourse(node), payload, { params: withCourseParams() })
226+
224227
return resp.data
225228
}
226229

assets/vue/views/coursemaintenance/DeleteCourse.vue

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,82 @@
11
<template>
22
<div class="space-y-6">
3-
<CMAlert type="error" :text="t('Danger zone: deleting a course is permanent.')" />
3+
<CMAlert
4+
type="error"
5+
:text="t('Danger zone: deleting a course is permanent.')"
6+
/>
47

58
<section class="rounded-lg border border-rose-200 bg-rose-50 p-4">
6-
<h3 class="mb-2 text-sm font-semibold text-rose-900">{{ t("Confirm deletion") }}</h3>
9+
<h3 class="mb-2 text-sm font-semibold text-rose-900">
10+
{{ t("Confirm deletion") }}
11+
</h3>
712
<p class="mb-3 text-sm text-rose-800">
813
{{ t("Type the course code to confirm. All data will be permanently removed.") }}
914
</p>
1015

1116
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
1217
<div>
13-
<label class="mb-1 block text-xs font-medium text-rose-900">{{ t("Course code") }}</label>
18+
<label class="mb-1 block text-xs font-medium text-rose-900">
19+
{{ t("Course code") }}
20+
</label>
1421
<input
1522
v-model="confirmText"
1623
class="w-full rounded border border-rose-300 p-2 text-sm"
1724
:placeholder="courseCode || 'ABC101'"
1825
/>
19-
<p v-if="confirmText && !canDelete" class="mt-1 text-xs text-rose-700">
26+
<p
27+
v-if="confirmText && !canDelete"
28+
class="mt-1 text-xs text-rose-700"
29+
>
2030
{{ t("The code must match exactly:") }} <strong>{{ courseCode }}</strong>
2131
</p>
32+
33+
<!-- Extra option: delete orphan documents too -->
34+
<div class="mt-3 flex items-start gap-2 text-xs text-rose-900">
35+
<input
36+
id="delete-docs"
37+
v-model="deleteDocs"
38+
type="checkbox"
39+
class="mt-[2px] h-4 w-4 rounded border-rose-300"
40+
/>
41+
<label
42+
for="delete-docs"
43+
class="select-none"
44+
>
45+
{{ t("Also delete documents that are only used in this course (if any).") }}
46+
<span class="block text-[11px] text-rose-700">
47+
{{
48+
t(
49+
"If unchecked, those files will remain available to the platform administrator through the 'File information' tool.",
50+
)
51+
}}
52+
</span>
53+
</label>
54+
</div>
2255
</div>
56+
2357
<div class="flex items-end">
24-
<button class="btn-danger" :disabled="loading || !canDelete" @click="submit">
25-
<i class="mdi mdi-delete-alert"></i> {{ t("Delete course") }}
58+
<button
59+
class="btn-danger"
60+
:disabled="loading || !canDelete"
61+
@click="submit"
62+
>
63+
<i class="mdi mdi-delete-alert"></i>
64+
{{ t("Delete course") }}
2665
</button>
2766
</div>
2867
</div>
2968
</section>
3069

31-
<CMAlert v-if="error" type="error" :text="error" />
32-
<CMAlert v-if="notice" type="success" :text="notice" />
70+
<CMAlert
71+
v-if="error"
72+
type="error"
73+
:text="error"
74+
/>
75+
<CMAlert
76+
v-if="notice"
77+
type="success"
78+
:text="notice"
79+
/>
3380
<CMLoader v-if="loading" />
3481
</div>
3582
</template>
@@ -47,11 +94,11 @@ const route = useRoute()
4794
const node = ref(Number(route.params.node || 0))
4895
4996
const confirmText = ref("")
97+
const deleteDocs = ref(false)
5098
const loading = ref(false)
5199
const error = ref("")
52100
const notice = ref("")
53101
54-
// Read current course from Pinia (header ya lo muestra)
55102
const cidReq = useCidReqStore()
56103
const { course } = storeToRefs(cidReq)
57104
const courseCode = computed(() => String(course?.value?.code || ""))
@@ -61,12 +108,23 @@ const canDelete = computed(() => !!confirmText.value && confirmText.value === co
61108
62109
async function submit() {
63110
if (!confirm(t("This action cannot be undone. Continue?"))) return
64-
error.value = ""; notice.value = ""
111+
112+
error.value = ""
113+
notice.value = ""
114+
65115
try {
66116
loading.value = true
67-
const res = await svc.deleteCourse(node.value, confirmText.value)
117+
118+
const payload = {
119+
confirm: confirmText.value,
120+
delete_docs: deleteDocs.value ? 1 : 0,
121+
}
122+
123+
const res = await svc.deleteCourse(node.value, payload)
68124
notice.value = res.message || t("Course deleted successfully.")
69-
if (res.redirectUrl) window.location.href = res.redirectUrl
125+
if (res.redirectUrl) {
126+
window.location.href = res.redirectUrl
127+
}
70128
} catch (e) {
71129
error.value = e?.response?.data?.error || t("Failed to delete course.")
72130
} finally {

public/main/admin/course_list.php

Lines changed: 126 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,8 @@ function get_course_data(
240240
),
241241
$path.'course_copy/create_backup.php?'.api_get_cidreq_params($courseId)
242242
);
243+
244+
// Single course delete: ask if exclusive documents should also be removed.
243245
$actions[] = Display::url(
244246
Display::getMdiIcon(
245247
ActionIcon::DELETE,
@@ -251,12 +253,12 @@ function get_course_data(
251253
$path.'admin/course_list.php?'
252254
.http_build_query([
253255
'delete_course' => $course['col0'],
256+
// Default: keep documents; JS will toggle this param to 1 if admin agrees.
257+
'delete_docs' => 0,
254258
'sec_token' => Security::getTokenFromSession(),
255259
]),
256260
[
257-
'onclick' => "javascript: if (!confirm('"
258-
.addslashes(api_htmlentities(get_lang('Please confirm your choice'), \ENT_QUOTES))
259-
."')) return false;",
261+
'onclick' => 'return confirmDeleteCourseWithDocs(this);',
260262
]
261263
);
262264

@@ -361,9 +363,11 @@ function get_course_visibility_icon(int $visibility): string
361363
if ('delete_courses' == $_POST['action']) {
362364
if (!empty($_POST['course'])) {
363365
$course_codes = $_POST['course'];
366+
$deleteDocs = isset($_POST['delete_docs']) && (int) $_POST['delete_docs'] === 1;
367+
364368
if (count($course_codes) > 0) {
365369
foreach ($course_codes as $course_code) {
366-
CourseManager::delete_course($course_code);
370+
CourseManager::delete_course($course_code, $deleteDocs);
367371
}
368372
}
369373

@@ -459,8 +463,11 @@ function get_course_visibility_icon(int $visibility): string
459463
$content .= $form->returnForm();
460464
} else {
461465
$tool_name = get_lang('Course list');
466+
467+
// Single course deletion (from action icon)
462468
if (isset($_GET['delete_course']) && Security::check_token('get')) {
463-
$result = CourseManager::delete_course($_GET['delete_course']);
469+
$deleteDocs = isset($_GET['delete_docs']) && (int) $_GET['delete_docs'] === 1;
470+
$result = CourseManager::delete_course($_GET['delete_course'], $deleteDocs);
464471
if ($result) {
465472
Display::addFlash(Display::return_message(get_lang('Deleted')));
466473
}
@@ -573,6 +580,7 @@ function get_course_visibility_icon(int $visibility): string
573580
</script>';
574581

575582
$actions = Display::toolbarAction('toolbar', [$actions1, $actions3.$actions4.$actions2]);
583+
576584
// Create a sortable table with the course data
577585
$table = new SortableTable(
578586
'courses',
@@ -584,10 +592,12 @@ function get_course_visibility_icon(int $visibility): string
584592
'course-list'
585593
);
586594

587-
$parameters = [];
588-
$parameters['sec_token'] = Security::get_token();
595+
$parameters = [
596+
'sec_token' => Security::get_token(),
597+
];
598+
589599
if (isset($_GET['keyword'])) {
590-
$parameters = ['keyword' => Security::remove_XSS($_GET['keyword'])];
600+
$parameters['keyword'] = Security::remove_XSS($_GET['keyword']);
591601
} elseif (isset($_GET['keyword_code'])) {
592602
$parameters['keyword_code'] = Security::remove_XSS($_GET['keyword_code']);
593603
$parameters['keyword_title'] = Security::remove_XSS($_GET['keyword_title']);
@@ -624,6 +634,114 @@ function get_course_visibility_icon(int $visibility): string
624634
$tab = CourseManager::getCourseListTabs('simple');
625635

626636
$content .= $tab.$table->return_table();
637+
638+
// JS helper to ask for exclusive document deletion both for single and bulk delete.
639+
$deleteDocsMessage = addslashes(
640+
get_lang(
641+
'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.'
642+
)
643+
);
644+
645+
// Fallback confirmation text; SortableTable uses data-confirm on the link.
646+
$baseConfirmMessage = addslashes(get_lang('Please confirm your choice'));
647+
648+
$content .= '<script>
649+
(function () {
650+
var docsMsg = "'.$deleteDocsMessage.'";
651+
var defaultConfirmMsg = "'.$baseConfirmMessage.'";
652+
653+
// Single-course delete (trash icon per row)
654+
window.confirmDeleteCourseWithDocs = function (link) {
655+
var baseMsg = link.getAttribute("data-confirm") || defaultConfirmMsg;
656+
657+
// Confirm course deletion.
658+
if (!window.confirm(baseMsg)) {
659+
return false;
660+
}
661+
662+
// Ask about orphan documents on disk.
663+
if (window.confirm(docsMsg)) {
664+
if (link.href.indexOf("delete_docs=0") !== -1) {
665+
link.href = link.href.replace("delete_docs=0", "delete_docs=1");
666+
} else if (link.href.indexOf("delete_docs=") === -1) {
667+
var sep = link.href.indexOf("?") === -1 ? "?" : "&";
668+
link.href = link.href + sep + "delete_docs=1";
669+
}
670+
}
671+
672+
return true;
673+
};
674+
675+
// Bulk delete (SortableTable dropdown)
676+
function wrapActionClick() {
677+
// Ensure we only wrap once and only if action_click exists.
678+
if (!window.action_click || window.action_click.__wrappedForCourseList) {
679+
return;
680+
}
681+
682+
var originalActionClick = window.action_click;
683+
var docsMsgLocal = docsMsg;
684+
685+
window.action_click = function (el, formId) {
686+
var action = el.getAttribute("data-action");
687+
var confirmMsg = el.getAttribute("data-confirm") || defaultConfirmMsg;
688+
689+
// Intercept only the bulk delete of this page.
690+
if (formId === "form_courses_id" && action === "delete_courses") {
691+
var form = document.getElementById(formId);
692+
if (!form) {
693+
return false;
694+
}
695+
696+
// 1) Confirm deletion of selected courses.
697+
if (confirmMsg && !window.confirm(confirmMsg)) {
698+
return false;
699+
}
700+
701+
// 2) Ask if orphan documents should also be deleted from disk.
702+
var deleteDocs = window.confirm(docsMsgLocal);
703+
704+
// Ensure "action" hidden field exists and is set.
705+
var actionInput = form.querySelector(\'input[name="action"]\');
706+
if (!actionInput) {
707+
actionInput = document.createElement("input");
708+
actionInput.type = "hidden";
709+
actionInput.name = "action";
710+
form.appendChild(actionInput);
711+
}
712+
actionInput.value = action;
713+
714+
// If user accepted the docs deletion, set the delete_docs flag.
715+
if (deleteDocs) {
716+
var deleteDocsInput = form.querySelector(\'input[name="delete_docs"]\');
717+
if (!deleteDocsInput) {
718+
deleteDocsInput = document.createElement("input");
719+
deleteDocsInput.type = "hidden";
720+
deleteDocsInput.name = "delete_docs";
721+
form.appendChild(deleteDocsInput);
722+
}
723+
deleteDocsInput.value = "1";
724+
}
725+
726+
form.submit();
727+
return false;
728+
}
729+
730+
// Fallback: keep original behavior for any other action/form.
731+
return originalActionClick(el, formId);
732+
};
733+
734+
window.action_click.__wrappedForCourseList = true;
735+
}
736+
737+
if (document.readyState === "loading") {
738+
document.addEventListener("DOMContentLoaded", wrapActionClick);
739+
} else {
740+
wrapActionClick();
741+
}
742+
})();
743+
</script>';
744+
627745
}
628746

629747
$tpl = new Template($tool_name);

0 commit comments

Comments
 (0)