Passed
Push — master ( 9df631...0f56b8 )
by
unknown
10:44
created

CourseRecycler::clearLpCategoriesForCourse()   A

Complexity

Conditions 6
Paths 8

Size

Total Lines 15
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 10
nc 8
nop 0
dl 0
loc 15
rs 9.2222
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\CourseBundle\Entity\CAnnouncement;
11
use Chamilo\CourseBundle\Entity\CAttendance;
12
use Chamilo\CourseBundle\Entity\CCalendarEvent;
13
use Chamilo\CourseBundle\Entity\CCourseDescription;
14
use Chamilo\CourseBundle\Entity\CDocument;
15
use Chamilo\CourseBundle\Entity\CForum;
16
use Chamilo\CourseBundle\Entity\CForumCategory;
17
use Chamilo\CourseBundle\Entity\CGlossary;
18
use Chamilo\CourseBundle\Entity\CLink;
19
use Chamilo\CourseBundle\Entity\CLinkCategory;
20
use Chamilo\CourseBundle\Entity\CLp;
21
use Chamilo\CourseBundle\Entity\CLpCategory;
22
use Chamilo\CourseBundle\Entity\CQuiz;
23
use Chamilo\CourseBundle\Entity\CQuizCategory;
24
use Chamilo\CourseBundle\Entity\CStudentPublication;
25
use Chamilo\CourseBundle\Entity\CSurvey;
26
use Chamilo\CourseBundle\Entity\CThematic;
27
use Chamilo\CourseBundle\Entity\CWiki;
28
use Chamilo\CoreBundle\Entity\Course as CoreCourse;
29
use Doctrine\ORM\EntityManagerInterface;
30
31
final class CourseRecycler
32
{
33
    public function __construct(
34
        private readonly EntityManagerInterface $em,
35
        private readonly string $courseCode,
36
        private readonly int $courseId
37
    ) {}
38
39
    /** $type: 'full_backup' | 'select_items' ; $selected: [type => [id => true]] */
40
    public function recycle(string $type, array $selected): void
41
    {
42
        $isFull = ($type === 'full_backup');
43
44
        // If your EM doesn't have wrapInTransaction(), replace by $this->em->transactional(fn() => { ... })
45
        $this->em->wrapInTransaction(function () use ($isFull, $selected) {
46
            // Links & categories
47
            $this->recycleGeneric($isFull, CLink::class, $selected['link'] ?? []);
48
            $this->recycleGeneric($isFull, CLinkCategory::class, $selected['link_category'] ?? [], autoClean: true);
49
50
            // Calendar & announcements
51
            $this->recycleGeneric($isFull, CCalendarEvent::class, $selected['event'] ?? []);
52
            $this->recycleGeneric($isFull, CAnnouncement::class, $selected['announcement'] ?? []);
53
54
            // Documents
55
            $this->recycleGeneric($isFull, CDocument::class, $selected['document'] ?? [], deleteFiles: true);
56
57
            // Forums & forum categories
58
            $this->recycleGeneric($isFull, CForum::class, $selected['forum'] ?? [], cascadeHeavy: true);
59
            $this->recycleGeneric($isFull, CForumCategory::class, $selected['forum_category'] ?? [], autoClean: true);
60
61
            // Quizzes & categories
62
            $this->recycleGeneric($isFull, CQuiz::class, $selected['quiz'] ?? [], cascadeHeavy: true);
63
            $this->recycleGeneric($isFull, CQuizCategory::class, $selected['test_category'] ?? []);
64
65
            // Surveys
66
            $this->recycleGeneric($isFull, CSurvey::class, $selected['survey'] ?? [], cascadeHeavy: true);
67
68
            // Learning paths & categories
69
            $this->recycleGeneric($isFull, CLp::class, $selected['learnpath'] ?? [], cascadeHeavy: true, scormCleanup: true);
70
            $this->recycleLpCategories($isFull, $selected['learnpath_category'] ?? []);
71
72
            // Other resources
73
            $this->recycleGeneric($isFull, CCourseDescription::class, $selected['course_description'] ?? []);
74
            $this->recycleGeneric($isFull, CWiki::class, $selected['wiki'] ?? [], cascadeHeavy: true);
75
            $this->recycleGeneric($isFull, CGlossary::class, $selected['glossary'] ?? []);
76
            $this->recycleGeneric($isFull, CThematic::class, $selected['thematic'] ?? [], cascadeHeavy: true);
77
            $this->recycleGeneric($isFull, CAttendance::class, $selected['attendance'] ?? [], cascadeHeavy: true);
78
            $this->recycleGeneric($isFull, CStudentPublication::class, $selected['work'] ?? [], cascadeHeavy: true);
79
80
            if ($isFull) {
81
                // If you keep cleaning course picture:
82
                // CourseManager::deleteCoursePicture($this->courseCode);
83
            }
84
        });
85
    }
86
87
    /**
88
     * Generic recycler for any AbstractResource-based entity.
89
     * - If $isFull => deletes *all resources of that type* for the course.
90
     * - If partial => deletes only the provided $ids.
91
     * Options:
92
     *  - deleteFiles: physical files are already handled by hardDelete (if repo supports it).
93
     *  - cascadeHeavy: for heavy-relations types (forums, LPs). hardDelete should traverse.
94
     *  - autoClean: e.g. remove empty categories after deleting links/forums.
95
     *  - scormCleanup: if LP SCORM → hook storage service if needed.
96
     */
97
    private function recycleGeneric(
98
        bool $isFull,
99
        string $entityClass,
100
        array $idsMap,
101
        bool $deleteFiles = false,
102
        bool $cascadeHeavy = false,
103
        bool $autoClean = false,
104
        bool $scormCleanup = false
105
    ): void {
106
        if ($isFull) {
107
            $this->deleteAllOfTypeForCourse($entityClass);
108
            if ($autoClean) {
109
                $this->autoCleanIfSupported($entityClass);
110
            }
111
            if ($scormCleanup && $entityClass === CLp::class) {
112
                $this->cleanupScormDirsForAllLp();
113
            }
114
            return;
115
        }
116
117
        $ids = $this->ids($idsMap);
118
        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...
119
            if ($autoClean) {
120
                $this->autoCleanIfSupported($entityClass);
121
            }
122
            return;
123
        }
124
125
        $this->deleteSelectedOfTypeForCourse($entityClass, $ids);
126
127
        if ($autoClean) {
128
            $this->autoCleanIfSupported($entityClass);
129
        }
130
        if ($scormCleanup && $entityClass === CLp::class) {
131
            $this->cleanupScormDirsForLpIds($ids);
132
        }
133
    }
134
135
    /** LP categories: detach LPs and then delete selected/all categories */
136
    private function recycleLpCategories(bool $isFull, array $idsMap): void
137
    {
138
        if ($isFull) {
139
            // Detach all categories from LPs in course
140
            $this->clearLpCategoriesForCourse();
141
            $this->deleteAllOfTypeForCourse(CLpCategory::class);
142
            return;
143
        }
144
145
        $ids = $this->ids($idsMap);
146
        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...
147
            return;
148
        }
149
150
        // Detach LPs from these categories
151
        $this->clearLpCategoriesForIds($ids);
152
        $this->deleteSelectedOfTypeForCourse(CLpCategory::class, $ids);
153
    }
154
155
    /** Normalizes IDs from [id => true] maps into int/string scalars */
156
    private function ids(array $map): array
157
    {
158
        return array_values(array_filter(array_map(
159
            static fn($k) => is_numeric($k) ? (int) $k : (string) $k,
160
            array_keys($map)
161
        ), static fn($v) => $v !== '' && $v !== null));
162
    }
163
164
    /** Lightweight Course reference for query builders */
165
    private function courseRef(): CoreCourse
166
    {
167
        /** @var CoreCourse $ref */
168
        $ref = $this->em->getReference(CoreCourse::class, $this->courseId);
169
        return $ref;
170
    }
171
172
    /**
173
     * Fetches resources by entity class within course, optionally filtering by resource iid.
174
     * If the repository doesn't extend ResourceRepository, falls back to a generic QB.
175
     *
176
     * @return array<int, AbstractResource>
177
     */
178
    private function fetchResourcesForCourse(string $entityClass, ?array $ids = null): array
179
    {
180
        $repo = $this->em->getRepository($entityClass);
181
182
        // Path A: repository exposes ResourceRepository API
183
        if (method_exists($repo, 'getResourcesByCourseIgnoreVisibility')) {
184
            $qb = $repo->getResourcesByCourseIgnoreVisibility($this->courseRef());
185
            if ($ids && \count($ids) > 0) {
186
                $qb->andWhere('resource.iid IN (:ids)')->setParameter('ids', $ids);
187
            }
188
            return $qb->getQuery()->getResult();
189
        }
190
191
        // Path B: generic fallback (join to ResourceNode/ResourceLinks and filter by course)
192
        $qb = $this->em->createQueryBuilder()
193
            ->select('resource')
194
            ->from($entityClass, 'resource')
195
            ->innerJoin('resource.resourceNode', 'node')
196
            ->innerJoin('node.resourceLinks', 'links')
197
            ->andWhere('links.course = :course')
198
            ->setParameter('course', $this->courseRef());
199
200
        if ($ids && \count($ids) > 0) {
201
            $qb->andWhere('resource.iid IN (:ids)')->setParameter('ids', $ids);
202
        }
203
204
        return $qb->getQuery()->getResult();
205
    }
206
207
    /**
208
     * Hard-deletes a list of resources. If repository doesn't provide hardDelete(),
209
     * falls back to EM->remove() and a final flush (expect proper cascade mappings).
210
     */
211
    private function hardDeleteMany(string $entityClass, array $resources): void
212
    {
213
        $repo = $this->em->getRepository($entityClass);
214
215
        $usedFallback = false;
216
        foreach ($resources as $res) {
217
            if (method_exists($repo, 'hardDelete')) {
218
                // hardDelete takes care of Resource, ResourceNode, Links and Files (Flysystem)
219
                $repo->hardDelete($res);
220
            } else {
221
                // Fallback: standard remove. Ensure your mappings cascade what you need.
222
                $this->em->remove($res);
223
                $usedFallback = true;
224
            }
225
        }
226
227
        // One flush at the end. If hardDelete() already flushed internally, this is harmless.
228
        if ($usedFallback) {
229
            $this->em->flush();
230
        }
231
    }
232
233
    /** Deletes all resources of a type in the course */
234
    private function deleteAllOfTypeForCourse(string $entityClass): void
235
    {
236
        $resources = $this->fetchResourcesForCourse($entityClass, null);
237
        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...
238
            $this->hardDeleteMany($entityClass, $resources);
239
        }
240
    }
241
242
    /** Deletes selected resources (by iid) of a type in the course */
243
    private function deleteSelectedOfTypeForCourse(string $entityClass, array $ids): void
244
    {
245
        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...
246
            return;
247
        }
248
        $resources = $this->fetchResourcesForCourse($entityClass, $ids);
249
        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...
250
            $this->hardDeleteMany($entityClass, $resources);
251
        }
252
    }
253
254
    /** Optional post-clean for empty categories if repository supports it */
255
    private function autoCleanIfSupported(string $entityClass): void
256
    {
257
        $repo = $this->em->getRepository($entityClass);
258
        if (method_exists($repo, 'deleteEmptyByCourse')) {
259
            $repo->deleteEmptyByCourse($this->courseId);
260
        }
261
    }
262
263
    /** Detach categories from ALL LPs in course (repo-level bulk method preferred if available) */
264
    private function clearLpCategoriesForCourse(): void
265
    {
266
        $lps = $this->fetchResourcesForCourse(CLp::class, null);
267
        $changed = false;
268
        foreach ($lps as $lp) {
269
            if (method_exists($lp, 'getCategory') && method_exists($lp, 'setCategory')) {
270
                if ($lp->getCategory()) {
271
                    $lp->setCategory(null);
272
                    $this->em->persist($lp);
273
                    $changed = true;
274
                }
275
            }
276
        }
277
        if ($changed) {
278
            $this->em->flush();
279
        }
280
    }
281
282
    /** Detach categories only for LPs that are linked to given category ids */
283
    private function clearLpCategoriesForIds(array $catIds): void
284
    {
285
        $lps = $this->fetchResourcesForCourse(CLp::class, null);
286
        $changed = false;
287
        foreach ($lps as $lp) {
288
            $cat = method_exists($lp, 'getCategory') ? $lp->getCategory() : null;
289
            $catId = $cat?->getId();
290
            if ($catId !== null && \in_array($catId, $catIds, true) && method_exists($lp, 'setCategory')) {
291
                $lp->setCategory(null);
292
                $this->em->persist($lp);
293
                $changed = true;
294
            }
295
        }
296
        if ($changed) {
297
            $this->em->flush();
298
        }
299
    }
300
301
    /** SCORM directory cleanup for ALL LPs (hook your storage service here if needed) */
302
    private function cleanupScormDirsForAllLp(): void
303
    {
304
        // If you have a storage/scorm service, invoke it here.
305
        // By default, nothing: hardDelete already deletes files linked to ResourceNode.
306
    }
307
308
    /** SCORM directory cleanup for selected LPs */
309
    private function cleanupScormDirsForLpIds(array $lpIds): void
310
    {
311
        // Same as above, but limited to provided LP ids.
312
    }
313
}
314