Passed
Pull Request — master (#6922)
by
unknown
08:45
created

Version20251020121000::getDescription()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 1
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/* For licensing terms, see /license.txt */
6
7
namespace Chamilo\CoreBundle\Migrations\Schema\V200;
8
9
use Chamilo\CoreBundle\Entity\GradebookCertificate;
10
use Chamilo\CoreBundle\Entity\PersonalFile;
11
use Chamilo\CoreBundle\Entity\ResourceNode;
12
use Chamilo\CoreBundle\Migrations\AbstractMigrationChamilo;
13
use Chamilo\CoreBundle\Repository\GradebookCertificateRepository;
14
use Chamilo\CoreBundle\Repository\Node\PersonalFileRepository;
15
use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
16
use Doctrine\DBAL\Schema\Schema;
17
18
final class Version20251020121000 extends AbstractMigrationChamilo
19
{
20
    private const DEBUG = true;
21
22
    public function getDescription(): string
23
    {
24
        return 'Migrate gradebook certificates from PersonalFile into resource-based storage; delete PersonalFile only after a successful migration.';
25
    }
26
27
    public function up(Schema $schema): void
28
    {
29
        /** @var GradebookCertificateRepository $certRepo */
30
        $certRepo = $this->container->get(GradebookCertificateRepository::class);
0 ignored issues
show
Bug introduced by
The method get() does not exist on null. ( Ignorable by Annotation )

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

30
        /** @scrutinizer ignore-call */ 
31
        $certRepo = $this->container->get(GradebookCertificateRepository::class);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
31
        /** @var PersonalFileRepository $personalRepo */
32
        $personalRepo = $this->container->get(PersonalFileRepository::class);
33
        /** @var ResourceNodeRepository $rnRepo */
34
        $rnRepo = $this->container->get(ResourceNodeRepository::class);
35
36
        $em = $this->entityManager;
37
38
        // missing resource node but having a legacy path.
39
        $dql = 'SELECT gc
40
                FROM Chamilo\CoreBundle\Entity\GradebookCertificate gc
41
                WHERE gc.resourceNode IS NULL
42
                  AND gc.pathCertificate IS NOT NULL
43
                  AND gc.pathCertificate <> :empty
44
                ORDER BY gc.id ASC';
45
46
        $q = $em->createQuery($dql)->setParameter('empty', '');
0 ignored issues
show
Bug introduced by
The method createQuery() does not exist on null. ( Ignorable by Annotation )

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

46
        $q = $em->/** @scrutinizer ignore-call */ createQuery($dql)->setParameter('empty', '');

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
47
48
        $migrated = 0;
49
        $skipped  = 0;
50
        $errors   = 0;
51
52
        foreach ($q->toIterable() as $gc) {
53
            \assert($gc instanceof GradebookCertificate);
54
55
            $user   = $gc->getUser();
56
            $userId = (int) $user->getId();
57
            $catId  = $gc->getCategory() ? (int) $gc->getCategory()->getId() : 0;
58
            $score  = (float) $gc->getScoreCertificate();
59
            $path   = (string) ($gc->getPathCertificate() ?? '');
60
61
            // Find the legacy PersonalFile (robust title search + optional creator-scope search).
62
            $pf = $this->findLegacyPersonalFile($personalRepo, $gc);
63
            if (!$pf) {
64
                $this->dbg(sprintf(
65
                    '[skip] gc#%d user=%d cat=%d -> legacy PersonalFile not found for "%s"',
66
                    (int) $gc->getId(), $userId, $catId, $path
67
                ));
68
                $skipped++;
69
                continue;
70
            }
71
72
            // Read the legacy HTML (robust strategy).
73
            $html = $this->readLegacyHtml($personalRepo, $rnRepo, $pf);
74
            if (!is_string($html) || $html === '') {
75
                $this->dbg(sprintf(
76
                    '[error] gc#%d user=%d cat=%d -> failed to read legacy HTML content from PF (title="%s")',
77
                    (int) $gc->getId(), $userId, $catId, (string) $pf->getTitle()
78
                ));
79
                $errors++;
80
                continue;
81
            }
82
83
            try {
84
                // Move to Resource (resource type "files")
85
                $cert = $certRepo->upsertCertificateResource($catId, $userId, $score, $html, null);
86
87
                // Remove legacy PF only after a successful migration
88
                $em->remove($pf);
89
                $em->flush();
90
                $em->clear();
91
92
                $migrated++;
93
                $this->dbg(sprintf(
94
                    '[ok] gc#%d user=%d cat=%d -> migrated to resource (cert id=%d) and removed PF "%s"',
95
                    (int) $gc->getId(), $userId, $catId, (int) $cert->getId(), (string) $pf->getTitle()
96
                ));
97
            } catch (\Throwable $e) {
98
                $this->dbg(sprintf(
99
                    '[error] gc#%d user=%d cat=%d -> upsert failed: %s',
100
                    (int) $gc->getId(), $userId, $catId, $e->getMessage()
101
                ));
102
                $errors++;
103
                // Do not remove PF on failure
104
            }
105
        }
106
107
        $summary = sprintf('Summary: migrated=%d skipped=%d errors=%d', $migrated, $skipped, $errors);
108
        $this->write("\n".$summary."\n");
109
        $this->dbg($summary);
110
    }
111
112
    /**
113
     * Run outside a single big transaction so we can flush/clear per item.
114
     */
115
    public function isTransactional(): bool
116
    {
117
        return false;
118
    }
119
120
    /**
121
     * Debug helper: send messages to PHP error_log only when DEBUG is enabled.
122
     */
123
    private function dbg(string $message): void
124
    {
125
        if (self::DEBUG) {
126
            error_log('[CERT MIGRATION] '.$message);
127
        }
128
    }
129
130
    /**
131
     * Try to locate the PersonalFile using common title variants and (if available)
132
     * a creator-scoped lookup to reduce ambiguity.
133
     */
134
    private function findLegacyPersonalFile(
135
        PersonalFileRepository $personalRepo,
136
        GradebookCertificate $gc
137
    ): ?PersonalFile {
138
        $title = (string) ($gc->getPathCertificate() ?? '');
139
        if ($title === '') {
140
            return null;
141
        }
142
143
        $variants = [$title];
144
145
        // With and without a leading slash
146
        if ($title[0] !== '/') {
147
            $variants[] = '/'.$title;
148
        }
149
150
        // Also try by basename (in case a full path was stored)
151
        $base = \basename($title);
152
        if ($base !== $title) {
153
            $variants[] = $base;
154
        }
155
156
        // Also try with/without ".html"
157
        $noExt = preg_replace('/\.html$/i', '', $base);
158
        if ($noExt && $noExt !== $base) {
159
            $variants[] = $noExt;
160
        } elseif ($base !== '' && stripos($base, '.html') === false) {
161
            $variants[] = $base.'.html';
162
        }
163
164
        foreach (array_unique($variants) as $v) {
165
            $found = $personalRepo->findOneBy(['title' => $v]);
166
            if ($found instanceof PersonalFile) {
167
                return $found;
168
            }
169
        }
170
171
        // search by creator scope if the helper exists
172
        $user = $gc->getUser();
173
        if (\method_exists($personalRepo, 'getResourceByCreatorFromTitle')) {
174
            try {
175
                $candidate = $personalRepo->getResourceByCreatorFromTitle(
176
                    $base !== '' ? $base : $title,
177
                    $user,
178
                    $user->getResourceNode()
179
                );
180
                if ($candidate instanceof PersonalFile) {
181
                    return $candidate;
182
                }
183
            } catch (\Throwable $e) {
184
                $this->dbg('Creator-scoped PF lookup failed: '.$e->getMessage());
185
            }
186
        }
187
188
        return null;
189
    }
190
191
    /**
192
     * Read HTML from PersonalFile
193
     */
194
    private function readLegacyHtml(
195
        PersonalFileRepository $personalRepo,
196
        ResourceNodeRepository $rnRepo,
197
        PersonalFile $pf
198
    ): ?string {
199
        // Repository helper
200
        try {
201
            $content = $personalRepo->getResourceFileContent($pf);
202
            if (is_string($content) && $content !== '') {
203
                return $content;
204
            }
205
        } catch (\Throwable $e) {
206
            $this->dbg('[info] PF read via repository failed: '.$e->getMessage());
207
        }
208
209
        // FileSystem paths
210
        try {
211
            /** @var ResourceNode|null $node */
212
            $node = $pf->getResourceNode();
213
            if ($node) {
214
                $fs = $rnRepo->getFileSystem();
215
                if ($fs) {
0 ignored issues
show
introduced by
$fs is of type League\Flysystem\FilesystemOperator, thus it always evaluated to true.
Loading history...
216
                    $basePath = rtrim((string) $node->getPath(), '/');
217
218
                    $sharded = static function (string $filename): string {
219
                        $a = $filename[0] ?? '_';
220
                        $b = $filename[1] ?? '_';
221
                        $c = $filename[2] ?? '_';
222
                        return sprintf('resource/%s/%s/%s/%s', $a, $b, $c, $filename);
223
                    };
224
225
                    foreach ($node->getResourceFiles() as $rf) {
226
                        $candidates = [];
227
228
                        $t = (string) $rf->getTitle();
229
                        $o = (string) $rf->getOriginalName();
230
231
                        if ($t !== '') {
232
                            if ($basePath !== '') {
233
                                $candidates[] = $basePath.'/'.$t;
234
                            }
235
                            $candidates[] = $sharded($t);
236
                        }
237
                        if ($o !== '') {
238
                            if ($basePath !== '') {
239
                                $candidates[] = $basePath.'/'.$o;
240
                            }
241
                            $candidates[] = $sharded($o);
242
                        }
243
244
                        foreach ($candidates as $p) {
245
                            if ($fs->fileExists($p)) {
246
                                $data = $fs->read($p);
247
                                if (is_string($data) && $data !== '') {
248
                                    return $data;
249
                                }
250
                            }
251
                        }
252
                    }
253
                }
254
            }
255
        } catch (\Throwable $e) {
256
            $this->dbg('[info] PF read via filesystem failed: '.$e->getMessage());
257
        }
258
259
        // Final fallback: generic node default content (may still fail)
260
        try {
261
            $node = $pf->getResourceNode();
262
            if ($node) {
263
                return $rnRepo->getResourceNodeFileContent($node);
264
            }
265
        } catch (\Throwable $e) {
266
            $this->dbg('[info] PF read via node fallback failed: '.$e->getMessage());
267
        }
268
269
        return null;
270
    }
271
}
272