Passed
Pull Request — master (#6955)
by
unknown
12:34
created

CourseRecycler   F

Complexity

Total Complexity 80

Size/Duplication

Total Lines 431
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 171
c 0
b 0
f 0
dl 0
loc 431
rs 2
wmc 80

18 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 5 1
A recycle() 0 43 2
A courseRef() 0 4 1
A hardDeleteMany() 0 21 3
A autoCleanIfSupported() 0 5 2
A cleanupScormDirsForAllLp() 0 2 1
B fetchResourcesForCourse() 0 40 8
A deleteAllOfTypeForCourse() 0 5 2
A physicallyDeleteDocumentFiles() 0 14 6
D recycleGeneric() 0 60 20
B preUnlinkBeforeDelete() 0 20 7
A clearLpCategoriesForCourse() 0 15 6
A unplugCertificateDocsForCourse() 0 33 4
A deleteSelectedOfTypeForCourse() 0 8 3
A ids() 0 6 3
B clearLpCategoriesForIds() 0 15 7
A cleanupScormDirsForLpIds() 0 2 1
A recycleLpCategories() 0 18 3

How to fix   Complexity   

Complex Class

Complex classes like CourseRecycler often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use CourseRecycler, and based on these observations, apply Extract Interface, too.

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\Course as CoreCourse;
11
use Chamilo\CoreBundle\Entity\GradebookCategory;
12
use Chamilo\CourseBundle\Entity\CAnnouncement;
13
use Chamilo\CourseBundle\Entity\CAttendance;
14
use Chamilo\CourseBundle\Entity\CCalendarEvent;
15
use Chamilo\CourseBundle\Entity\CCourseDescription;
16
use Chamilo\CourseBundle\Entity\CDocument;
17
use Chamilo\CourseBundle\Entity\CForum;
18
use Chamilo\CourseBundle\Entity\CForumCategory;
19
use Chamilo\CourseBundle\Entity\CGlossary;
20
use Chamilo\CourseBundle\Entity\CLink;
21
use Chamilo\CourseBundle\Entity\CLinkCategory;
22
use Chamilo\CourseBundle\Entity\CLp;
23
use Chamilo\CourseBundle\Entity\CLpCategory;
24
use Chamilo\CourseBundle\Entity\CQuiz;
25
use Chamilo\CourseBundle\Entity\CQuizCategory;
26
use Chamilo\CourseBundle\Entity\CStudentPublication;
27
use Chamilo\CourseBundle\Entity\CSurvey;
28
use Chamilo\CourseBundle\Entity\CThematic;
29
use Chamilo\CourseBundle\Entity\CWiki;
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
    /**
41
     * $type: 'full_backup' | 'select_items' ; $selected: [type => [id => true]].
42
     */
43
    public function recycle(string $type, array $selected): void
44
    {
45
        $isFull = ('full_backup' === $type);
46
47
        // If your EM doesn't have wrapInTransaction(), replace by $this->em->transactional(fn() => { ... })
48
        $this->em->wrapInTransaction(function () use ($isFull, $selected): void {
49
            $this->unplugCertificateDocsForCourse();
50
51
            // Links & categories
52
            $this->recycleGeneric($isFull, CLink::class, $selected['link'] ?? []);
53
            $this->recycleGeneric($isFull, CLinkCategory::class, $selected['link_category'] ?? [], autoClean: true);
54
55
            // Calendar & announcements
56
            $this->recycleGeneric($isFull, CCalendarEvent::class, $selected['event'] ?? []);
57
            $this->recycleGeneric($isFull, CAnnouncement::class, $selected['announcement'] ?? []);
58
59
            // Documents
60
            $this->recycleGeneric($isFull, CDocument::class, $selected['document'] ?? [], deleteFiles: true);
61
62
            // Forums & forum categories
63
            $this->recycleGeneric($isFull, CForum::class, $selected['forum'] ?? [], cascadeHeavy: true);
64
            $this->recycleGeneric($isFull, CForumCategory::class, $selected['forum_category'] ?? [], autoClean: true);
65
66
            // Quizzes & categories
67
            $this->recycleGeneric($isFull, CQuiz::class, $selected['quiz'] ?? [], cascadeHeavy: true);
68
            $this->recycleGeneric($isFull, CQuizCategory::class, $selected['test_category'] ?? []);
69
70
            // Surveys
71
            $this->recycleGeneric($isFull, CSurvey::class, $selected['survey'] ?? [], cascadeHeavy: true);
72
73
            // Learning paths & categories
74
            $this->recycleGeneric($isFull, CLp::class, $selected['learnpath'] ?? [], cascadeHeavy: true, scormCleanup: true);
75
            $this->recycleLpCategories($isFull, $selected['learnpath_category'] ?? []);
76
77
            // Other resources
78
            $this->recycleGeneric($isFull, CCourseDescription::class, $selected['course_description'] ?? []);
79
            $this->recycleGeneric($isFull, CWiki::class, $selected['wiki'] ?? [], cascadeHeavy: true);
80
            $this->recycleGeneric($isFull, CGlossary::class, $selected['glossary'] ?? []);
81
            $this->recycleGeneric($isFull, CThematic::class, $selected['thematic'] ?? [], cascadeHeavy: true);
82
            $this->recycleGeneric($isFull, CAttendance::class, $selected['attendance'] ?? [], cascadeHeavy: true);
83
            $this->recycleGeneric($isFull, CStudentPublication::class, $selected['work'] ?? [], cascadeHeavy: true);
84
85
            if ($isFull) {
86
                // If you keep cleaning course picture:
87
                // CourseManager::deleteCoursePicture($this->courseCode);
88
            }
89
        });
90
    }
91
92
    /**
93
     * Generic recycler for any AbstractResource-based entity.
94
     * - If $isFull => deletes *all resources of that type* for the course.
95
     * - If partial => deletes only the provided $ids.
96
     * Options:
97
     *  - deleteFiles: physical files are already handled by hardDelete (if repo supports it).
98
     *  - cascadeHeavy: for heavy-relations types (forums, LPs). hardDelete should traverse.
99
     *  - autoClean: e.g. remove empty categories after deleting links/forums.
100
     *  - scormCleanup: if LP SCORM → hook storage service if needed.
101
     */
102
    private function recycleGeneric(
103
        bool $isFull,
104
        string $entityClass,
105
        array $idsMap,
106
        bool $deleteFiles = false,
107
        bool $cascadeHeavy = false,
108
        bool $autoClean = false,
109
        bool $scormCleanup = false
110
    ): void {
111
        $repo = $this->em->getRepository($entityClass);
112
        $hasHardDelete = method_exists($repo, 'hardDelete');
113
114
        if ($isFull) {
115
            $resources = $this->fetchResourcesForCourse($entityClass, null);
116
            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...
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
127
            if ($autoClean) {
128
                $this->autoCleanIfSupported($entityClass);
129
            }
130
            if ($scormCleanup && CLp::class === $entityClass) {
131
                $this->cleanupScormDirsForAllLp();
132
            }
133
134
            return;
135
        }
136
137
        $ids = $this->ids($idsMap);
138
        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...
139
            if ($autoClean) {
140
                $this->autoCleanIfSupported($entityClass);
141
            }
142
143
            return;
144
        }
145
146
        $resources = $this->fetchResourcesForCourse($entityClass, $ids);
147
        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...
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
        }
156
157
        if ($autoClean) {
158
            $this->autoCleanIfSupported($entityClass);
159
        }
160
        if ($scormCleanup && CLp::class === $entityClass) {
161
            $this->cleanupScormDirsForLpIds($ids);
162
        }
163
    }
164
165
    /**
166
     * LP categories: detach LPs and then delete selected/all categories.
167
     */
168
    private function recycleLpCategories(bool $isFull, array $idsMap): void
169
    {
170
        if ($isFull) {
171
            // Detach all categories from LPs in course
172
            $this->clearLpCategoriesForCourse();
173
            $this->deleteAllOfTypeForCourse(CLpCategory::class);
174
175
            return;
176
        }
177
178
        $ids = $this->ids($idsMap);
179
        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...
180
            return;
181
        }
182
183
        // Detach LPs from these categories
184
        $this->clearLpCategoriesForIds($ids);
185
        $this->deleteSelectedOfTypeForCourse(CLpCategory::class, $ids);
186
    }
187
188
    /**
189
     * Normalizes IDs from [id => true] maps into int/string scalars.
190
     */
191
    private function ids(array $map): array
192
    {
193
        return array_values(array_filter(array_map(
194
            static fn ($k) => is_numeric($k) ? (int) $k : (string) $k,
195
            array_keys($map)
196
        ), static fn ($v) => '' !== $v && null !== $v));
197
    }
198
199
    /**
200
     * Lightweight Course reference for query builders.
201
     */
202
    private function courseRef(): CoreCourse
203
    {
204
        /** @var CoreCourse $ref */
205
        return $this->em->getReference(CoreCourse::class, $this->courseId);
206
    }
207
208
    /**
209
     * Fetches resources by entity class within course, optionally filtering by resource iid.
210
     * If the repository doesn't extend ResourceRepository, falls back to a generic QB.
211
     *
212
     * @return array<int, AbstractResource>
213
     */
214
    private function fetchResourcesForCourse(string $entityClass, ?array $ids = null): array
215
    {
216
        $repo = $this->em->getRepository($entityClass);
217
218
        // Path A: repository exposes ResourceRepository API
219
        if (method_exists($repo, 'getResourcesByCourseIgnoreVisibility')) {
220
            $qb = $repo->getResourcesByCourseIgnoreVisibility($this->courseRef());
221
            if ($ids && \count($ids) > 0) {
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);
232
            }
233
234
            return $qb->getQuery()->getResult();
235
        }
236
237
        // Path B: generic fallback (join to ResourceNode/ResourceLinks and filter by course)
238
        $qb = $this->em->createQueryBuilder()
239
            ->select('resource')
240
            ->from($entityClass, 'resource')
241
            ->innerJoin('resource.resourceNode', 'node')
242
            ->innerJoin('node.resourceLinks', 'links')
243
            ->andWhere('links.course = :course')
244
            ->setParameter('course', $this->courseRef())
245
        ;
246
247
        if ($ids && \count($ids) > 0) {
248
            $meta = $this->em->getClassMetadata($entityClass);
249
            $field = $meta->hasField('iid') ? 'resource.iid' : 'resource.id';
250
            $qb->andWhere("$field IN (:ids)")->setParameter('ids', $ids);
251
        }
252
253
        return $qb->getQuery()->getResult();
254
    }
255
256
    /**
257
     * Force-unlink associations that can trigger cascade-persist on delete.
258
     * We always null GradebookCategory->document before removing the category.
259
     */
260
    private function preUnlinkBeforeDelete(array $entities): void
261
    {
262
        $changed = false;
263
264
        foreach ($entities as $e) {
265
            if ($e instanceof GradebookCategory
266
                && method_exists($e, 'getDocument')
267
                && method_exists($e, 'setDocument')
268
            ) {
269
                if (null !== $e->getDocument()) {
270
                    // Prevent "new entity found through relationship" on flush
271
                    $e->setDocument(null);
272
                    $this->em->persist($e);
273
                    $changed = true;
274
                }
275
            }
276
        }
277
278
        if ($changed) {
279
            $this->em->flush();
280
        }
281
    }
282
283
    /**
284
     * Hard-deletes a list of resources. If repository doesn't provide hardDelete(),
285
     * falls back to EM->remove() and a final flush (expect proper cascade mappings).
286
     */
287
    private function hardDeleteMany(string $entityClass, array $resources): void
288
    {
289
        $repo = $this->em->getRepository($entityClass);
290
291
        // Unlink problematic associations up front (prevents cascade-persist on flush)
292
        $this->preUnlinkBeforeDelete($resources);
293
294
        $usedFallback = false;
295
        foreach ($resources as $res) {
296
            if (method_exists($repo, 'hardDelete')) {
297
                // Repo handles full hard delete (nodes/links/files)
298
                $repo->hardDelete($res);
299
            } else {
300
                // Fallback: standard remove (expect proper cascades elsewhere)
301
                $this->em->remove($res);
302
                $usedFallback = true;
303
            }
304
        }
305
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();
311
    }
312
313
    /**
314
     * Deletes all resources of a type in the course.
315
     */
316
    private function deleteAllOfTypeForCourse(string $entityClass): void
317
    {
318
        $resources = $this->fetchResourcesForCourse($entityClass, null);
319
        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...
320
            $this->hardDeleteMany($entityClass, $resources);
321
        }
322
    }
323
324
    /**
325
     * Deletes selected resources (by iid) of a type in the course.
326
     */
327
    private function deleteSelectedOfTypeForCourse(string $entityClass, array $ids): void
328
    {
329
        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...
330
            return;
331
        }
332
        $resources = $this->fetchResourcesForCourse($entityClass, $ids);
333
        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...
334
            $this->hardDeleteMany($entityClass, $resources);
335
        }
336
    }
337
338
    /**
339
     * Optional post-clean for empty categories if repository supports it.
340
     */
341
    private function autoCleanIfSupported(string $entityClass): void
342
    {
343
        $repo = $this->em->getRepository($entityClass);
344
        if (method_exists($repo, 'deleteEmptyByCourse')) {
345
            $repo->deleteEmptyByCourse($this->courseId);
346
        }
347
    }
348
349
    /**
350
     * Detach categories from ALL LPs in course (repo-level bulk method preferred if available).
351
     */
352
    private function clearLpCategoriesForCourse(): void
353
    {
354
        $lps = $this->fetchResourcesForCourse(CLp::class, null);
355
        $changed = false;
356
        foreach ($lps as $lp) {
357
            if (method_exists($lp, 'getCategory') && method_exists($lp, 'setCategory')) {
358
                if ($lp->getCategory()) {
359
                    $lp->setCategory(null);
360
                    $this->em->persist($lp);
361
                    $changed = true;
362
                }
363
            }
364
        }
365
        if ($changed) {
366
            $this->em->flush();
367
        }
368
    }
369
370
    /**
371
     * Detach categories only for LPs that are linked to given category ids.
372
     */
373
    private function clearLpCategoriesForIds(array $catIds): void
374
    {
375
        $lps = $this->fetchResourcesForCourse(CLp::class, null);
376
        $changed = false;
377
        foreach ($lps as $lp) {
378
            $cat = method_exists($lp, 'getCategory') ? $lp->getCategory() : null;
379
            $catId = $cat?->getId();
380
            if (null !== $catId && \in_array($catId, $catIds, true) && method_exists($lp, 'setCategory')) {
381
                $lp->setCategory(null);
382
                $this->em->persist($lp);
383
                $changed = true;
384
            }
385
        }
386
        if ($changed) {
387
            $this->em->flush();
388
        }
389
    }
390
391
    private function unplugCertificateDocsForCourse(): void
392
    {
393
        // Detach any certificate-type document from gradebook categories of this course
394
        // Reason: avoid "A new entity was found through the relationship ... #document" on flush.
395
        $qb = $this->em->createQueryBuilder()
396
            ->select('c', 'd')
397
            ->from(GradebookCategory::class, 'c')
398
            ->innerJoin('c.course', 'course')
399
            ->leftJoin('c.document', 'd')
400
            ->where('course.id = :cid')
401
            ->andWhere('d IS NOT NULL')
402
            ->andWhere('d.filetype = :ft')
403
            ->setParameter('cid', $this->courseId)
404
            ->setParameter('ft', 'certificate')
405
        ;
406
407
        /** @var GradebookCategory[] $cats */
408
        $cats = $qb->getQuery()->getResult();
409
410
        $changed = false;
411
        foreach ($cats as $cat) {
412
            $doc = $cat->getDocument();
413
            if ($doc instanceof CDocument) {
414
                // Prevent transient Document from being cascaded/persisted during delete
415
                $cat->setDocument(null);
416
                $this->em->persist($cat);
417
                $changed = true;
418
            }
419
        }
420
421
        if ($changed) {
422
            // Materialize unlink before any deletion happens
423
            $this->em->flush();
424
        }
425
    }
426
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);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

441
                    /** @scrutinizer ignore-unhandled */ @unlink($path);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
442
                }
443
            }
444
            // If you use a storage service, call it here instead of unlink()
445
            // $this->storage->delete($rf->getStorageKey());
446
        }
447
    }
448
449
    /**
450
     * SCORM directory cleanup for ALL LPs (hook your storage service here if needed).
451
     */
452
    private function cleanupScormDirsForAllLp(): void
453
    {
454
        // If you have a storage/scorm service, invoke it here.
455
        // By default, nothing: hardDelete already deletes files linked to ResourceNode.
456
    }
457
458
    /**
459
     * SCORM directory cleanup for selected LPs.
460
     */
461
    private function cleanupScormDirsForLpIds(array $lpIds): void
462
    {
463
        // Same as above, but limited to provided LP ids.
464
    }
465
}
466