Passed
Pull Request — master (#7119)
by
unknown
09:22
created

safeGenerateCertificateForCategory()   C

Complexity

Conditions 8
Paths 256

Size

Total Lines 36
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 8
eloc 22
nc 256
nop 2
dl 0
loc 36
rs 6.9111
c 1
b 0
f 0
1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
use Chamilo\CoreBundle\Entity\GradebookCategory;
6
use Chamilo\CoreBundle\Entity\Skill;
7
use Chamilo\CoreBundle\Framework\Container;
8
9
/**
10
 * LP Final Item: muestra certificado y skills al completar el LP.
11
 */
12
$_in_course = true;
13
14
require_once __DIR__.'/../inc/global.inc.php';
15
16
$current_course_tool = TOOL_GRADEBOOK;
17
api_protect_course_script(true);
18
19
$courseCode = api_get_course_id();
20
$courseId   = api_get_course_int_id();
21
$userId     = api_get_user_id();
22
$sessionId  = api_get_session_id();
23
$id         = isset($_GET['id']) ? (int) $_GET['id'] : 0;
24
$lpId       = isset($_GET['lp_id']) ? (int) $_GET['lp_id'] : 0;
25
26
// This page can only be shown from inside a learning path
27
if (!$id && !$lpId) {
28
    echo Display::return_message(get_lang('The file was not found'));
29
    exit;
30
}
31
32
// Certificate and Skills Premium with Service check
33
$plugin  = BuyCoursesPlugin::create();
34
$checker = $plugin->isEnabled() && $plugin->get('include_services');
35
36
if ($checker) {
37
    $userServiceSale = $plugin->getServiceSales(
38
        $userId,
39
        BuyCoursesPlugin::SERVICE_STATUS_COMPLETED,
40
        BuyCoursesPlugin::SERVICE_TYPE_LP_FINAL_ITEM,
41
        $lpId
42
    );
43
44
    if (empty($userServiceSale)) {
45
        // Instance a new template : No page tittle, No header, No footer
46
        $tpl = new Template(null, false, false);
47
        $url = api_get_path(WEB_PLUGIN_PATH).'BuyCourses/src/service_catalog.php';
48
        $content = sprintf(
49
            Display::return_message(
50
                get_lang('If you want to get the certificate and/or skills associated with this course, you need to buy the certificate service. You can go to the services catalog by clicking this link: %s'),
51
                'normal',
52
                false
53
            ),
54
            '<a href="'.$url.'">'.$url.'</a>'
55
        );
56
        $tpl->assign('content', $content);
57
        $tpl->display_blank_template();
58
        exit;
59
    }
60
}
61
62
$lpEntity = api_get_lp_entity($lpId);
63
$lp       = new Learnpath($lpEntity, [], $userId);
64
65
$count              = $lp->getTotalItemsCountWithoutDirs();
66
$completed          = $lp->get_complete_items_count(true);
67
$currentItemId      = $lp->get_current_item_id();
68
$currentItem        = $lp->items[$currentItemId] ?? null;
69
$currentItemStatus  = $currentItem ? $currentItem->get_status() : 'not attempted';
70
71
$lpItemRepo   = Container::getLpItemRepository();
72
$isFinalThere = false;
73
$isFinalDone  = false;
74
$finalItem    = null;
75
76
try {
77
    $finalItem = $lpItemRepo->findOneBy(['lp' => $lpEntity, 'itemType' => TOOL_LP_FINAL_ITEM]);
78
    if ($finalItem) {
79
        $isFinalThere = true;
80
        $fid = $finalItem->getIid();
81
        if (isset($lp->items[$fid])) {
82
            $st = $lp->items[$fid]->get_status();
83
            $isFinalDone = in_array($st, ['completed','passed','succeeded'], true);
84
        }
85
    }
86
} catch (\Throwable $e) {
87
    error_log('[LP_FINAL] final_item lookup error: '.$e->getMessage());
88
}
89
$countAdj     = max(0, $count    - ($isFinalThere ? 1 : 0));
90
$completedAdj = max(0, $completed - ($isFinalDone  ? 1 : 0));
91
$diff         = $countAdj - $completedAdj;
92
$accessGranted = false;
93
if ($diff === 0 || ($diff === 1 && (('incomplete' === $currentItemStatus) || ('not attempted' === $currentItemStatus)))) {
94
    if ($lp->prerequisites_match($currentItemId)) {
95
        $accessGranted = true;
96
    }
97
}
98
$lp->save_last();
99
unset($lp, $currentItem);
100
101
if (!$accessGranted) {
102
    echo Display::return_message(
103
        get_lang('This learning object cannot display because the course prerequisites are not completed. This happens when a course imposes that you follow it step by step or get a minimum score in tests before you reach the next steps.'),
104
        'warning'
105
    );
106
    $finalHtml = '';
107
} else {
108
    $downloadBlock = '';
109
    $badgeBlock    = '';
110
    $gbRepo        = Container::getGradeBookCategoryRepository();
111
    $courseEntity  = api_get_course_entity();
112
    $sessionEntity = api_get_session_entity();
113
114
    // Resolve GradebookCategory using lp_item.ref when item_type = final_item.
115
    // We store the gradebook category id in c_lp_item.ref (string).
116
    $categoryIdFromRef = 0;
117
118
    if (!empty($finalItem) && method_exists($finalItem, 'getRef')) {
119
        try {
120
            $refRaw = trim((string) $finalItem->getRef());
121
            if ($refRaw !== '' && $refRaw !== '0') {
122
                $categoryIdFromRef = (int) $refRaw;
123
            }
124
        } catch (\Throwable $e) {
125
            error_log('[LP_FINAL] Unable to read lp_item.ref for final_item: '.$e->getMessage());
126
        }
127
    }
128
129
    /** @var GradebookCategory|null $gbCat */
130
    $gbCat = null;
131
132
    // 1) First, try the explicit category id stored in c_lp_item.ref.
133
    if ($categoryIdFromRef > 0) {
134
        $gbCat = $gbRepo->find($categoryIdFromRef);
135
136
        // Safety check: ensure the referenced category belongs to the same course/session context.
137
        if ($gbCat && $courseEntity) {
138
            $catCourse  = $gbCat->getCourse();
139
            $catSession = $gbCat->getSession();
140
141
            // If course does not match, discard this category and let the fallback logic handle it.
142
            if (!$catCourse || $catCourse->getId() !== $courseEntity->getId()) {
143
                $gbCat = null;
144
            } elseif ($sessionEntity) {
145
                // If we are in a session context, ensure the category session matches.
146
                if ($catSession && $catSession->getId() !== $sessionEntity->getId()) {
147
                    $gbCat = null;
148
                }
149
            }
150
        }
151
    }
152
153
    // 2) Fallback: keep legacy behaviour (root course/session category).
154
    if (!$gbCat && $courseEntity) {
155
        if ($sessionEntity) {
156
            $gbCat = $gbRepo->findOneBy([
157
                'course'  => $courseEntity,
158
                'session' => $sessionEntity,
159
            ]);
160
        }
161
162
        if (!$gbCat) {
163
            $gbCat = $gbRepo->findOneBy([
164
                'course'  => $courseEntity,
165
                'session' => null,
166
            ]);
167
        }
168
    }
169
170
    if ($gbCat && !api_is_allowed_to_edit() && !api_is_excluded_user_type()) {
171
        // Use legacy Category business object to generate certificate + skills
172
        // for this specific gradebook category.
173
        // NOTE: Category::generateUserCertificate() is expected to know how to
174
        // work with the Doctrine GradebookCategory entity.
175
        $certificate = Category::generateUserCertificate($gbCat, $userId);
176
        if (!empty($certificate)) {
177
            // Build the HTML panel to replace ((certificate)).
178
            $downloadBlock = Category::getDownloadCertificateBlock($certificate);
179
        }
180
181
        // Skills: Category::generateUserCertificate() already assigns skills
182
        // to the user for this course/session/category when enabled.
183
        // Here we just render the user's skills panel.
184
        $badgeBlock = generateBadgePanel($userId, $courseId, $sessionId);
185
    }
186
187
    // Replace ((certificate)) and ((skill)) tokens in the final-item document.
188
    $finalHtml = renderFinalItemDocument($id, $downloadBlock, $badgeBlock);
189
}
190
191
$tpl = new Template(null, false, false);
192
$tpl->assign('content', $finalHtml);
193
$tpl->display_blank_template();
194
195
/**
196
 * Generates/ensures the certificate via Doctrine repositories and returns minimal link data.
197
 */
198
function safeGenerateCertificateForCategory(GradebookCategory $category, int $userId): array
199
{
200
    $course   = $category->getCourse();
201
    $session  = $category->getSession();
202
    $courseId = $course ? $course->getId() : 0;
0 ignored issues
show
introduced by
$course is of type Chamilo\CoreBundle\Entity\Course, thus it always evaluated to true.
Loading history...
203
    $sessId   = $session ? $session->getId() : 0;
204
    $catId    = (int) $category->getId();
205
206
    // Build certificate content & score.
207
    $gb    = GradebookUtils::get_user_certificate_content($userId, $courseId, $sessId);
208
    $html  = (is_array($gb) && isset($gb['content'])) ? $gb['content'] : '';
209
    $score = isset($gb['score']) ? (float) $gb['score'] : 100.0;
210
211
    $certRepo = Container::getGradeBookCertificateRepository();
212
213
    $htmlUrl = '';
214
    $pdfUrl  = '';
215
    $cert    = null;
216
217
    try {
218
        // Store/refresh as Resource (controlled access; not shown in "My personal files").
219
        $cert = $certRepo->upsertCertificateResource($catId, $userId, $score, $html);
220
221
        // (Optional) keep metadata (created_at/score). Filename is not required anymore.
222
        $certRepo->registerUserInfoAboutCertificate($catId, $userId, $score);
223
224
        // Build URLs from the Resource layer.
225
        $htmlUrl = $certRepo->getResourceFileUrl($cert);
226
    } catch (\Throwable $e) {
227
        error_log('[LP_FINAL] register cert error: '.$e->getMessage());
228
    }
229
230
    return [
231
        'path_certificate' => $cert ? (string) ($cert->getPathCertificate() ?? '') : '',
0 ignored issues
show
introduced by
$cert is of type Chamilo\CoreBundle\Entity\GradebookCertificate, thus it always evaluated to true.
Loading history...
232
        'html_url'         => $htmlUrl,
233
        'pdf_url'          => $pdfUrl,
234
    ];
235
}
236
237
/**
238
 * Builds the certificate download/view HTML block (if available).
239
 */
240
function buildCertificateBlock(array $cert): string
241
{
242
    $htmlUrl = $cert['html_url'] ?? '';
243
    $pdfUrl  = $cert['pdf_url']  ?? '';
244
    if (!$htmlUrl && !$pdfUrl) {
245
        return '';
246
    }
247
248
    $downloadBtn = $pdfUrl
249
        ? Display::toolbarButton(get_lang('Download certificate in PDF'), $pdfUrl, 'file-pdf-box')
250
        : '';
251
252
    $viewBtn = $htmlUrl
253
        ? Display::url(get_lang('View certificate'), $htmlUrl, ['class' => 'btn btn-default'])
254
        : '';
255
256
    return "
257
        <div class='panel panel-default'>
258
            <div class='panel-body'>
259
                <h3 class='text-center'>".get_lang('You can now download your certificate by clicking here')."</h3>
260
                <div class='text-center'>{$downloadBtn} {$viewBtn}</div>
261
            </div>
262
        </div>
263
    ";
264
}
265
266
/**
267
 * Returns the user's skills panel HTML (empty if none).
268
 */
269
function generateBadgePanel(int $userId, int $courseId, int $sessionId = 0): string
270
{
271
    $em           = Database::getManager();
272
    $skillRelUser = new SkillRelUserModel();
273
    $userSkills   = $skillRelUser->getUserSkills($userId, $courseId, $sessionId);
274
275
    if (empty($userSkills)) {
276
        return '';
277
    }
278
279
    $items = '';
280
281
    foreach ($userSkills as $row) {
282
        $skill = $em->find(Skill::class, (int) $row['skill_id']);
283
        if (!$skill) {
284
            continue;
285
        }
286
287
        $skillId = (int) $skill->getId();
288
        $title   = (string) $skill->getTitle();
289
        $desc    = (string) $skill->getDescription();
290
        $iconUrl = (string) SkillModel::getWebIconPath($skill);
291
292
        $shareUrl = api_get_path(WEB_PATH)."badge/$skillId/user/$userId";
293
294
        // Facebook sharer (https)
295
        $fbUrl = 'https://www.facebook.com/sharer/sharer.php?u='.rawurlencode($shareUrl);
296
297
        // Twitter/X intent
298
        $tweetText = sprintf(
299
            get_lang('I have achieved skill %s on %s'),
300
            $title,
301
            api_get_setting('siteName')
302
        );
303
        $twUrl = 'https://twitter.com/intent/tweet?text='.rawurlencode($tweetText).'&url='.rawurlencode($shareUrl);
304
305
        $safeTitle = Security::remove_XSS($title);
306
        $safeDesc  = Security::remove_XSS($desc);
307
308
        $items .= "
309
            <div class='py-6 border-b border-gray-20 last:border-b-0'>
310
                <div class='grid grid-cols-1 sm:grid-cols-12 gap-5 items-start'>
311
312
                    <div class='sm:col-span-3 flex justify-center sm:justify-start'>
313
                        <img
314
                            src='".htmlspecialchars($iconUrl, ENT_QUOTES)."'
315
                            alt='".htmlspecialchars($title, ENT_QUOTES)."'
316
                            loading='lazy'
317
                            width='140'
318
                            height='140'
319
                            style='max-width:140px;height:auto;'
320
                            class='h-24 w-24 sm:h-28 sm:w-28 object-contain rounded-xl bg-white ring-1 ring-gray-25 shadow-sm'
321
                        >
322
                    </div>
323
324
                    <div class='sm:col-span-6'>
325
                        <div class='text-lg font-semibold text-gray-90'>$safeTitle</div>
326
                        <div class='mt-1 text-sm text-gray-50'>$safeDesc</div>
327
                    </div>
328
329
                    <div class='sm:col-span-3'>
330
                        <div class='text-sm font-semibold text-gray-90'>".get_lang('Share with your friends')."</div>
331
332
                        <div class='mt-3 flex items-center gap-3'>
333
                            <a
334
                                href='".htmlspecialchars($fbUrl, ENT_QUOTES)."'
335
                                target='_blank'
336
                                rel='noopener noreferrer'
337
                                class='inline-flex h-10 w-10 items-center justify-center rounded-full ring-1 ring-gray-25 bg-white hover:bg-gray-15'
338
                                aria-label='Facebook'
339
                                title='Facebook'
340
                            >
341
                                <svg viewBox='0 0 24 24' class='h-5 w-5 text-gray-90' aria-hidden='true'>
342
                                    <path fill='currentColor' d='M22 12a10 10 0 1 0-11.56 9.87v-6.99H7.9V12h2.54V9.8c0-2.5 1.49-3.89 3.77-3.89 1.09 0 2.24.2 2.24.2v2.46h-1.26c-1.24 0-1.63.77-1.63 1.56V12h2.78l-.44 2.88h-2.34v6.99A10 10 0 0 0 22 12Z'/>
343
                                </svg>
344
                            </a>
345
346
                            <a
347
                                href='".htmlspecialchars($twUrl, ENT_QUOTES)."'
348
                                target='_blank'
349
                                rel='noopener noreferrer'
350
                                class='inline-flex h-10 w-10 items-center justify-center rounded-full ring-1 ring-gray-25 bg-white hover:bg-gray-15'
351
                                aria-label='X'
352
                                title='X'
353
                            >
354
                                <svg viewBox='0 0 24 24' class='h-5 w-5 text-gray-90' aria-hidden='true'>
355
                                    <path fill='currentColor' d='M18.9 2H22l-6.76 7.73L23 22h-6.2l-4.86-6.4L6.34 22H3.2l7.23-8.26L1 2h6.36l4.4 5.83L18.9 2Zm-1.09 18h1.72L6.42 3.9H4.58L17.81 20Z'/>
356
                                </svg>
357
                            </a>
358
                        </div>
359
                    </div>
360
361
                </div>
362
            </div>
363
        ";
364
    }
365
366
    if ($items === '') {
367
        return '';
368
    }
369
370
    return "
371
        <section class='mx-auto max-w-5xl p-4'>
372
            <div class='rounded-2xl bg-white ring-1 ring-gray-25 shadow-sm'>
373
                <div class='px-6 pt-6'>
374
                    <h3 class='text-center text-xl font-semibold text-gray-90'>
375
                        ".get_lang('Additionally, you have achieved the following skills')."
376
                    </h3>
377
                </div>
378
                <div class='px-6 pb-6'>
379
                    $items
380
                </div>
381
            </div>
382
        </section>
383
    ";
384
}
385
386
/**
387
 * Render the Learning Path final-item document.
388
 */
389
function renderFinalItemDocument(int $lpItemOrDocId, string $certificateBlock, string $badgeBlock): string
390
{
391
    $docRepo    = Container::getDocumentRepository();
392
    $lpItemRepo = Container::getLpItemRepository();
393
394
    $document = null;
395
396
    // First, try to use the id directly as a document iid.
397
    try {
398
        $document = $docRepo->find($lpItemOrDocId);
399
    } catch (\Throwable $e) {
400
        // Silence here, we will try the LP item fallback below.
401
    }
402
403
    // If not a document iid, try resolving from the LP item path.
404
    if (!$document) {
405
        try {
406
            $lpItem = $lpItemRepo->find($lpItemOrDocId);
407
            if ($lpItem) {
408
                // In our case, lp_item.path stores the document iid as string.
409
                $document = $docRepo->find((int) $lpItem->getPath());
410
            }
411
        } catch (\Throwable $e) {
412
            // As a last resort, fail quietly and return empty content.
413
        }
414
    }
415
416
    if (!$document) {
417
        return '';
418
    }
419
420
    try {
421
        $content = $docRepo->getResourceFileContent($document);
422
    } catch (\Throwable $e) {
423
        error_log('[LP_FINAL] read doc error: '.$e->getMessage());
424
        return '';
425
    }
426
427
    $hasCert  = str_contains($content, '((certificate))');
428
    $hasSkill = str_contains($content, '((skill))');
429
430
    if ($hasCert) {
431
        $content = str_replace('((certificate))', $certificateBlock, $content);
432
    }
433
    if ($hasSkill) {
434
        $content = str_replace('((skill))', $badgeBlock, $content);
435
    }
436
437
    return $content;
438
}
439