Passed
Pull Request — master (#6894)
by
unknown
12:02 queued 03:47
created

CourseRecycler::cleanupScormDirsForLpIds()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 0

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 0
nc 1
nop 1
dl 0
loc 2
rs 10
c 0
b 0
f 0
1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
declare(strict_types=1);
6
7
namespace Chamilo\CourseBundle\Component\CourseCopy;
8
9
use Chamilo\CoreBundle\Entity\AbstractResource;
10
use Chamilo\CoreBundle\Entity\GradebookCategory;
11
use Chamilo\CourseBundle\Entity\CAnnouncement;
12
use Chamilo\CourseBundle\Entity\CAttendance;
13
use Chamilo\CourseBundle\Entity\CCalendarEvent;
14
use Chamilo\CourseBundle\Entity\CCourseDescription;
15
use Chamilo\CourseBundle\Entity\CDocument;
16
use Chamilo\CourseBundle\Entity\CForum;
17
use Chamilo\CourseBundle\Entity\CForumCategory;
18
use Chamilo\CourseBundle\Entity\CGlossary;
19
use Chamilo\CourseBundle\Entity\CLink;
20
use Chamilo\CourseBundle\Entity\CLinkCategory;
21
use Chamilo\CourseBundle\Entity\CLp;
22
use Chamilo\CourseBundle\Entity\CLpCategory;
23
use Chamilo\CourseBundle\Entity\CQuiz;
24
use Chamilo\CourseBundle\Entity\CQuizCategory;
25
use Chamilo\CourseBundle\Entity\CStudentPublication;
26
use Chamilo\CourseBundle\Entity\CSurvey;
27
use Chamilo\CourseBundle\Entity\CThematic;
28
use Chamilo\CourseBundle\Entity\CWiki;
29
use Chamilo\CoreBundle\Entity\Course as CoreCourse;
30
use Doctrine\ORM\EntityManagerInterface;
31
32
final class CourseRecycler
33
{
34
    public function __construct(
35
        private readonly EntityManagerInterface $em,
36
        private readonly string $courseCode,
37
        private readonly int $courseId
38
    ) {}
39
40
    /** $type: 'full_backup' | 'select_items' ; $selected: [type => [id => true]] */
41
    public function recycle(string $type, array $selected): void
42
    {
43
        $isFull = ($type === 'full_backup');
44
45
        // If your EM doesn't have wrapInTransaction(), replace by $this->em->transactional(fn() => { ... })
46
        $this->em->wrapInTransaction(function () use ($isFull, $selected) {
47
48
            $this->unplugCertificateDocsForCourse();
49
50
            // Links & categories
51
            $this->recycleGeneric($isFull, CLink::class, $selected['link'] ?? []);
52
            $this->recycleGeneric($isFull, CLinkCategory::class, $selected['link_category'] ?? [], autoClean: true);
53
54
            // Calendar & announcements
55
            $this->recycleGeneric($isFull, CCalendarEvent::class, $selected['event'] ?? []);
56
            $this->recycleGeneric($isFull, CAnnouncement::class, $selected['announcement'] ?? []);
57
58
            // Documents
59
            $this->recycleGeneric($isFull, CDocument::class, $selected['document'] ?? [], deleteFiles: true);
60
61
            // Forums & forum categories
62
            $this->recycleGeneric($isFull, CForum::class, $selected['forum'] ?? [], cascadeHeavy: true);
63
            $this->recycleGeneric($isFull, CForumCategory::class, $selected['forum_category'] ?? [], autoClean: true);
64
65
            // Quizzes & categories
66
            $this->recycleGeneric($isFull, CQuiz::class, $selected['quiz'] ?? [], cascadeHeavy: true);
67
            $this->recycleGeneric($isFull, CQuizCategory::class, $selected['test_category'] ?? []);
68
69
            // Surveys
70
            $this->recycleGeneric($isFull, CSurvey::class, $selected['survey'] ?? [], cascadeHeavy: true);
71
72
            // Learning paths & categories
73
            $this->recycleGeneric($isFull, CLp::class, $selected['learnpath'] ?? [], cascadeHeavy: true, scormCleanup: true);
74
            $this->recycleLpCategories($isFull, $selected['learnpath_category'] ?? []);
75
76
            // Other resources
77
            $this->recycleGeneric($isFull, CCourseDescription::class, $selected['course_description'] ?? []);
78
            $this->recycleGeneric($isFull, CWiki::class, $selected['wiki'] ?? [], cascadeHeavy: true);
79
            $this->recycleGeneric($isFull, CGlossary::class, $selected['glossary'] ?? []);
80
            $this->recycleGeneric($isFull, CThematic::class, $selected['thematic'] ?? [], cascadeHeavy: true);
81
            $this->recycleGeneric($isFull, CAttendance::class, $selected['attendance'] ?? [], cascadeHeavy: true);
82
            $this->recycleGeneric($isFull, CStudentPublication::class, $selected['work'] ?? [], cascadeHeavy: true);
83
84
            if ($isFull) {
85
                // If you keep cleaning course picture:
86
                // CourseManager::deleteCoursePicture($this->courseCode);
87
            }
88
        });
89
    }
90
91
    /**
92
     * Generic recycler for any AbstractResource-based entity.
93
     * - If $isFull => deletes *all resources of that type* for the course.
94
     * - If partial => deletes only the provided $ids.
95
     * Options:
96
     *  - deleteFiles: physical files are already handled by hardDelete (if repo supports it).
97
     *  - cascadeHeavy: for heavy-relations types (forums, LPs). hardDelete should traverse.
98
     *  - autoClean: e.g. remove empty categories after deleting links/forums.
99
     *  - scormCleanup: if LP SCORM → hook storage service if needed.
100
     */
101
    private function recycleGeneric(
102
        bool $isFull,
103
        string $entityClass,
104
        array $idsMap,
105
        bool $deleteFiles = false,
106
        bool $cascadeHeavy = false,
107
        bool $autoClean = false,
108
        bool $scormCleanup = false
109
    ): void {
110
        if ($isFull) {
111
            $this->deleteAllOfTypeForCourse($entityClass);
112
            if ($autoClean) {
113
                $this->autoCleanIfSupported($entityClass);
114
            }
115
            if ($scormCleanup && $entityClass === CLp::class) {
116
                $this->cleanupScormDirsForAllLp();
117
            }
118
            return;
119
        }
120
121
        $ids = $this->ids($idsMap);
122
        if (!$ids) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $ids of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
123
            if ($autoClean) {
124
                $this->autoCleanIfSupported($entityClass);
125
            }
126
            return;
127
        }
128
129
        $this->deleteSelectedOfTypeForCourse($entityClass, $ids);
130
131
        if ($autoClean) {
132
            $this->autoCleanIfSupported($entityClass);
133
        }
134
        if ($scormCleanup && $entityClass === CLp::class) {
135
            $this->cleanupScormDirsForLpIds($ids);
136
        }
137
    }
138
139
    /** LP categories: detach LPs and then delete selected/all categories */
140
    private function recycleLpCategories(bool $isFull, array $idsMap): void
141
    {
142
        if ($isFull) {
143
            // Detach all categories from LPs in course
144
            $this->clearLpCategoriesForCourse();
145
            $this->deleteAllOfTypeForCourse(CLpCategory::class);
146
            return;
147
        }
148
149
        $ids = $this->ids($idsMap);
150
        if (!$ids) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $ids of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
151
            return;
152
        }
153
154
        // Detach LPs from these categories
155
        $this->clearLpCategoriesForIds($ids);
156
        $this->deleteSelectedOfTypeForCourse(CLpCategory::class, $ids);
157
    }
158
159
    /** Normalizes IDs from [id => true] maps into int/string scalars */
160
    private function ids(array $map): array
161
    {
162
        return array_values(array_filter(array_map(
163
            static fn($k) => is_numeric($k) ? (int) $k : (string) $k,
164
            array_keys($map)
165
        ), static fn($v) => $v !== '' && $v !== null));
166
    }
167
168
    /** Lightweight Course reference for query builders */
169
    private function courseRef(): CoreCourse
170
    {
171
        /** @var CoreCourse $ref */
172
        $ref = $this->em->getReference(CoreCourse::class, $this->courseId);
173
        return $ref;
174
    }
175
176
    /**
177
     * Fetches resources by entity class within course, optionally filtering by resource iid.
178
     * If the repository doesn't extend ResourceRepository, falls back to a generic QB.
179
     *
180
     * @return array<int, AbstractResource>
181
     */
182
    private function fetchResourcesForCourse(string $entityClass, ?array $ids = null): array
183
    {
184
        $repo = $this->em->getRepository($entityClass);
185
186
        // Path A: repository exposes ResourceRepository API
187
        if (method_exists($repo, 'getResourcesByCourseIgnoreVisibility')) {
188
            $qb = $repo->getResourcesByCourseIgnoreVisibility($this->courseRef());
189
            if ($ids && \count($ids) > 0) {
190
                $qb->andWhere('resource.iid IN (:ids)')->setParameter('ids', $ids);
191
            }
192
            return $qb->getQuery()->getResult();
193
        }
194
195
        // Path B: generic fallback (join to ResourceNode/ResourceLinks and filter by course)
196
        $qb = $this->em->createQueryBuilder()
197
            ->select('resource')
198
            ->from($entityClass, 'resource')
199
            ->innerJoin('resource.resourceNode', 'node')
200
            ->innerJoin('node.resourceLinks', 'links')
201
            ->andWhere('links.course = :course')
202
            ->setParameter('course', $this->courseRef());
203
204
        if ($ids && \count($ids) > 0) {
205
            $qb->andWhere('resource.iid IN (:ids)')->setParameter('ids', $ids);
206
        }
207
208
        return $qb->getQuery()->getResult();
209
    }
210
211
    /**
212
     * Force-unlink associations that can trigger cascade-persist on delete.
213
     * We always null GradebookCategory->document before removing the category.
214
     */
215
    private function preUnlinkBeforeDelete(array $entities): void
216
    {
217
        $changed = false;
218
219
        foreach ($entities as $e) {
220
            if ($e instanceof GradebookCategory
221
                && method_exists($e, 'getDocument')
222
                && method_exists($e, 'setDocument')
223
            ) {
224
                if ($e->getDocument() !== null) {
225
                    // Prevent "new entity found through relationship" on flush
226
                    $e->setDocument(null);
227
                    $this->em->persist($e);
228
                    $changed = true;
229
                }
230
            }
231
        }
232
233
        if ($changed) {
234
            $this->em->flush();
235
        }
236
    }
237
238
    /**
239
     * Hard-deletes a list of resources. If repository doesn't provide hardDelete(),
240
     * falls back to EM->remove() and a final flush (expect proper cascade mappings).
241
     */
242
    private function hardDeleteMany(string $entityClass, array $resources): void
243
    {
244
        $repo = $this->em->getRepository($entityClass);
245
246
        // Unlink problematic associations up front (prevents cascade-persist on flush)
247
        $this->preUnlinkBeforeDelete($resources);
248
249
        $usedFallback = false;
250
        foreach ($resources as $res) {
251
            if (method_exists($repo, 'hardDelete')) {
252
                // Repo handles full hard delete (nodes/links/files)
253
                $repo->hardDelete($res);
254
            } else {
255
                // Fallback: standard remove (expect proper cascades elsewhere)
256
                $this->em->remove($res);
257
                $usedFallback = true;
258
            }
259
        }
260
261
        if ($usedFallback) {
262
            $this->em->flush();
263
        }
264
    }
265
266
    /** Deletes all resources of a type in the course */
267
    private function deleteAllOfTypeForCourse(string $entityClass): void
268
    {
269
        $resources = $this->fetchResourcesForCourse($entityClass, null);
270
        if ($resources) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $resources of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
271
            $this->hardDeleteMany($entityClass, $resources);
272
        }
273
    }
274
275
    /** Deletes selected resources (by iid) of a type in the course */
276
    private function deleteSelectedOfTypeForCourse(string $entityClass, array $ids): void
277
    {
278
        if (!$ids) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $ids of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
279
            return;
280
        }
281
        $resources = $this->fetchResourcesForCourse($entityClass, $ids);
282
        if ($resources) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $resources of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
283
            $this->hardDeleteMany($entityClass, $resources);
284
        }
285
    }
286
287
    /** Optional post-clean for empty categories if repository supports it */
288
    private function autoCleanIfSupported(string $entityClass): void
289
    {
290
        $repo = $this->em->getRepository($entityClass);
291
        if (method_exists($repo, 'deleteEmptyByCourse')) {
292
            $repo->deleteEmptyByCourse($this->courseId);
293
        }
294
    }
295
296
    /** Detach categories from ALL LPs in course (repo-level bulk method preferred if available) */
297
    private function clearLpCategoriesForCourse(): void
298
    {
299
        $lps = $this->fetchResourcesForCourse(CLp::class, null);
300
        $changed = false;
301
        foreach ($lps as $lp) {
302
            if (method_exists($lp, 'getCategory') && method_exists($lp, 'setCategory')) {
303
                if ($lp->getCategory()) {
304
                    $lp->setCategory(null);
305
                    $this->em->persist($lp);
306
                    $changed = true;
307
                }
308
            }
309
        }
310
        if ($changed) {
311
            $this->em->flush();
312
        }
313
    }
314
315
    /** Detach categories only for LPs that are linked to given category ids */
316
    private function clearLpCategoriesForIds(array $catIds): void
317
    {
318
        $lps = $this->fetchResourcesForCourse(CLp::class, null);
319
        $changed = false;
320
        foreach ($lps as $lp) {
321
            $cat = method_exists($lp, 'getCategory') ? $lp->getCategory() : null;
322
            $catId = $cat?->getId();
323
            if ($catId !== null && \in_array($catId, $catIds, true) && method_exists($lp, 'setCategory')) {
324
                $lp->setCategory(null);
325
                $this->em->persist($lp);
326
                $changed = true;
327
            }
328
        }
329
        if ($changed) {
330
            $this->em->flush();
331
        }
332
    }
333
334
    private function unplugCertificateDocsForCourse(): void
335
    {
336
        // Detach any certificate-type document from gradebook categories of this course
337
        // Reason: avoid "A new entity was found through the relationship ... #document" on flush.
338
        $qb = $this->em->createQueryBuilder()
339
            ->select('c', 'd')
340
            ->from(GradebookCategory::class, 'c')
341
            ->innerJoin('c.course', 'course')
342
            ->leftJoin('c.document', 'd')
343
            ->where('course.id = :cid')
344
            ->andWhere('d IS NOT NULL')
345
            ->andWhere('d.filetype = :ft')
346
            ->setParameter('cid', $this->courseId)
347
            ->setParameter('ft', 'certificate');
348
349
        /** @var GradebookCategory[] $cats */
350
        $cats = $qb->getQuery()->getResult();
351
352
        $changed = false;
353
        foreach ($cats as $cat) {
354
            $doc = $cat->getDocument();
355
            if ($doc instanceof CDocument) {
356
                // Prevent transient Document from being cascaded/persisted during delete
357
                $cat->setDocument(null);
358
                $this->em->persist($cat);
359
                $changed = true;
360
            }
361
        }
362
363
        if ($changed) {
364
            // Materialize unlink before any deletion happens
365
            $this->em->flush();
366
        }
367
    }
368
369
    /** SCORM directory cleanup for ALL LPs (hook your storage service here if needed) */
370
    private function cleanupScormDirsForAllLp(): void
371
    {
372
        // If you have a storage/scorm service, invoke it here.
373
        // By default, nothing: hardDelete already deletes files linked to ResourceNode.
374
    }
375
376
    /** SCORM directory cleanup for selected LPs */
377
    private function cleanupScormDirsForLpIds(array $lpIds): void
378
    {
379
        // Same as above, but limited to provided LP ids.
380
    }
381
}
382