Passed
Pull Request — master (#6894)
by
unknown
09:02
created

CourseRecycler   F

Complexity

Total Complexity 63

Size/Duplication

Total Lines 373
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 144
c 0
b 0
f 0
dl 0
loc 373
rs 3.36
wmc 63

17 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 5 1
A courseRef() 0 4 1
A hardDeleteMany() 0 21 4
A autoCleanIfSupported() 0 5 2
A cleanupScormDirsForAllLp() 0 2 1
A fetchResourcesForCourse() 0 29 6
A deleteAllOfTypeForCourse() 0 5 2
B recycleGeneric() 0 37 10
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 recycle() 0 43 2
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
        if ($isFull) {
112
            $this->deleteAllOfTypeForCourse($entityClass);
113
            if ($autoClean) {
114
                $this->autoCleanIfSupported($entityClass);
115
            }
116
            if ($scormCleanup && CLp::class === $entityClass) {
117
                $this->cleanupScormDirsForAllLp();
118
            }
119
120
            return;
121
        }
122
123
        $ids = $this->ids($idsMap);
124
        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...
125
            if ($autoClean) {
126
                $this->autoCleanIfSupported($entityClass);
127
            }
128
129
            return;
130
        }
131
132
        $this->deleteSelectedOfTypeForCourse($entityClass, $ids);
133
134
        if ($autoClean) {
135
            $this->autoCleanIfSupported($entityClass);
136
        }
137
        if ($scormCleanup && CLp::class === $entityClass) {
138
            $this->cleanupScormDirsForLpIds($ids);
139
        }
140
    }
141
142
    /**
143
     * LP categories: detach LPs and then delete selected/all categories.
144
     */
145
    private function recycleLpCategories(bool $isFull, array $idsMap): void
146
    {
147
        if ($isFull) {
148
            // Detach all categories from LPs in course
149
            $this->clearLpCategoriesForCourse();
150
            $this->deleteAllOfTypeForCourse(CLpCategory::class);
151
152
            return;
153
        }
154
155
        $ids = $this->ids($idsMap);
156
        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...
157
            return;
158
        }
159
160
        // Detach LPs from these categories
161
        $this->clearLpCategoriesForIds($ids);
162
        $this->deleteSelectedOfTypeForCourse(CLpCategory::class, $ids);
163
    }
164
165
    /**
166
     * Normalizes IDs from [id => true] maps into int/string scalars.
167
     */
168
    private function ids(array $map): array
169
    {
170
        return array_values(array_filter(array_map(
171
            static fn ($k) => is_numeric($k) ? (int) $k : (string) $k,
172
            array_keys($map)
173
        ), static fn ($v) => '' !== $v && null !== $v));
174
    }
175
176
    /**
177
     * Lightweight Course reference for query builders.
178
     */
179
    private function courseRef(): CoreCourse
180
    {
181
        /** @var CoreCourse $ref */
182
        return $this->em->getReference(CoreCourse::class, $this->courseId);
183
    }
184
185
    /**
186
     * Fetches resources by entity class within course, optionally filtering by resource iid.
187
     * If the repository doesn't extend ResourceRepository, falls back to a generic QB.
188
     *
189
     * @return array<int, AbstractResource>
190
     */
191
    private function fetchResourcesForCourse(string $entityClass, ?array $ids = null): array
192
    {
193
        $repo = $this->em->getRepository($entityClass);
194
195
        // Path A: repository exposes ResourceRepository API
196
        if (method_exists($repo, 'getResourcesByCourseIgnoreVisibility')) {
197
            $qb = $repo->getResourcesByCourseIgnoreVisibility($this->courseRef());
198
            if ($ids && \count($ids) > 0) {
199
                $qb->andWhere('resource.iid IN (:ids)')->setParameter('ids', $ids);
200
            }
201
202
            return $qb->getQuery()->getResult();
203
        }
204
205
        // Path B: generic fallback (join to ResourceNode/ResourceLinks and filter by course)
206
        $qb = $this->em->createQueryBuilder()
207
            ->select('resource')
208
            ->from($entityClass, 'resource')
209
            ->innerJoin('resource.resourceNode', 'node')
210
            ->innerJoin('node.resourceLinks', 'links')
211
            ->andWhere('links.course = :course')
212
            ->setParameter('course', $this->courseRef())
213
        ;
214
215
        if ($ids && \count($ids) > 0) {
216
            $qb->andWhere('resource.iid IN (:ids)')->setParameter('ids', $ids);
217
        }
218
219
        return $qb->getQuery()->getResult();
220
    }
221
222
    /**
223
     * Force-unlink associations that can trigger cascade-persist on delete.
224
     * We always null GradebookCategory->document before removing the category.
225
     */
226
    private function preUnlinkBeforeDelete(array $entities): void
227
    {
228
        $changed = false;
229
230
        foreach ($entities as $e) {
231
            if ($e instanceof GradebookCategory
232
                && method_exists($e, 'getDocument')
233
                && method_exists($e, 'setDocument')
234
            ) {
235
                if (null !== $e->getDocument()) {
236
                    // Prevent "new entity found through relationship" on flush
237
                    $e->setDocument(null);
238
                    $this->em->persist($e);
239
                    $changed = true;
240
                }
241
            }
242
        }
243
244
        if ($changed) {
245
            $this->em->flush();
246
        }
247
    }
248
249
    /**
250
     * Hard-deletes a list of resources. If repository doesn't provide hardDelete(),
251
     * falls back to EM->remove() and a final flush (expect proper cascade mappings).
252
     */
253
    private function hardDeleteMany(string $entityClass, array $resources): void
254
    {
255
        $repo = $this->em->getRepository($entityClass);
256
257
        // Unlink problematic associations up front (prevents cascade-persist on flush)
258
        $this->preUnlinkBeforeDelete($resources);
259
260
        $usedFallback = false;
261
        foreach ($resources as $res) {
262
            if (method_exists($repo, 'hardDelete')) {
263
                // Repo handles full hard delete (nodes/links/files)
264
                $repo->hardDelete($res);
265
            } else {
266
                // Fallback: standard remove (expect proper cascades elsewhere)
267
                $this->em->remove($res);
268
                $usedFallback = true;
269
            }
270
        }
271
272
        if ($usedFallback) {
273
            $this->em->flush();
274
        }
275
    }
276
277
    /**
278
     * Deletes all resources of a type in the course.
279
     */
280
    private function deleteAllOfTypeForCourse(string $entityClass): void
281
    {
282
        $resources = $this->fetchResourcesForCourse($entityClass, null);
283
        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...
284
            $this->hardDeleteMany($entityClass, $resources);
285
        }
286
    }
287
288
    /**
289
     * Deletes selected resources (by iid) of a type in the course.
290
     */
291
    private function deleteSelectedOfTypeForCourse(string $entityClass, array $ids): void
292
    {
293
        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...
294
            return;
295
        }
296
        $resources = $this->fetchResourcesForCourse($entityClass, $ids);
297
        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...
298
            $this->hardDeleteMany($entityClass, $resources);
299
        }
300
    }
301
302
    /**
303
     * Optional post-clean for empty categories if repository supports it.
304
     */
305
    private function autoCleanIfSupported(string $entityClass): void
306
    {
307
        $repo = $this->em->getRepository($entityClass);
308
        if (method_exists($repo, 'deleteEmptyByCourse')) {
309
            $repo->deleteEmptyByCourse($this->courseId);
310
        }
311
    }
312
313
    /**
314
     * Detach categories from ALL LPs in course (repo-level bulk method preferred if available).
315
     */
316
    private function clearLpCategoriesForCourse(): void
317
    {
318
        $lps = $this->fetchResourcesForCourse(CLp::class, null);
319
        $changed = false;
320
        foreach ($lps as $lp) {
321
            if (method_exists($lp, 'getCategory') && method_exists($lp, 'setCategory')) {
322
                if ($lp->getCategory()) {
323
                    $lp->setCategory(null);
324
                    $this->em->persist($lp);
325
                    $changed = true;
326
                }
327
            }
328
        }
329
        if ($changed) {
330
            $this->em->flush();
331
        }
332
    }
333
334
    /**
335
     * Detach categories only for LPs that are linked to given category ids.
336
     */
337
    private function clearLpCategoriesForIds(array $catIds): void
338
    {
339
        $lps = $this->fetchResourcesForCourse(CLp::class, null);
340
        $changed = false;
341
        foreach ($lps as $lp) {
342
            $cat = method_exists($lp, 'getCategory') ? $lp->getCategory() : null;
343
            $catId = $cat?->getId();
344
            if (null !== $catId && \in_array($catId, $catIds, true) && method_exists($lp, 'setCategory')) {
345
                $lp->setCategory(null);
346
                $this->em->persist($lp);
347
                $changed = true;
348
            }
349
        }
350
        if ($changed) {
351
            $this->em->flush();
352
        }
353
    }
354
355
    private function unplugCertificateDocsForCourse(): void
356
    {
357
        // Detach any certificate-type document from gradebook categories of this course
358
        // Reason: avoid "A new entity was found through the relationship ... #document" on flush.
359
        $qb = $this->em->createQueryBuilder()
360
            ->select('c', 'd')
361
            ->from(GradebookCategory::class, 'c')
362
            ->innerJoin('c.course', 'course')
363
            ->leftJoin('c.document', 'd')
364
            ->where('course.id = :cid')
365
            ->andWhere('d IS NOT NULL')
366
            ->andWhere('d.filetype = :ft')
367
            ->setParameter('cid', $this->courseId)
368
            ->setParameter('ft', 'certificate')
369
        ;
370
371
        /** @var GradebookCategory[] $cats */
372
        $cats = $qb->getQuery()->getResult();
373
374
        $changed = false;
375
        foreach ($cats as $cat) {
376
            $doc = $cat->getDocument();
377
            if ($doc instanceof CDocument) {
378
                // Prevent transient Document from being cascaded/persisted during delete
379
                $cat->setDocument(null);
380
                $this->em->persist($cat);
381
                $changed = true;
382
            }
383
        }
384
385
        if ($changed) {
386
            // Materialize unlink before any deletion happens
387
            $this->em->flush();
388
        }
389
    }
390
391
    /**
392
     * SCORM directory cleanup for ALL LPs (hook your storage service here if needed).
393
     */
394
    private function cleanupScormDirsForAllLp(): void
395
    {
396
        // If you have a storage/scorm service, invoke it here.
397
        // By default, nothing: hardDelete already deletes files linked to ResourceNode.
398
    }
399
400
    /**
401
     * SCORM directory cleanup for selected LPs.
402
     */
403
    private function cleanupScormDirsForLpIds(array $lpIds): void
404
    {
405
        // Same as above, but limited to provided LP ids.
406
    }
407
}
408