Passed
Push — dependabot/npm_and_yarn/tar-7.... ( ae31d2...265b94 )
by
unknown
21:55 queued 09:58
created

buildCertificateBlock()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 21
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 13
c 0
b 0
f 0
nc 5
nop 1
dl 0
loc 21
rs 9.5222
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, (int) $gbCat->getId());
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
 * Returns the user's skills panel HTML for the current final-item category only (empty if none).
197
 */
198
function generateBadgePanel(int $userId, int $courseId, int $sessionId = 0, int $gradebookCategoryId = 0): string
199
{
200
    $gradebookCategoryId = (int) $gradebookCategoryId;
201
    if ($gradebookCategoryId <= 0) {
202
        return '';
203
    }
204
205
    $allowedSkillIds = getSkillIdsForGradebookCategory($gradebookCategoryId);
206
    if (empty($allowedSkillIds)) {
207
        return '';
208
    }
209
210
    $em           = Database::getManager();
211
    $skillRelUser = new SkillRelUserModel();
212
    $userSkills   = $skillRelUser->getUserSkills($userId, $courseId, $sessionId);
213
214
    if (empty($userSkills)) {
215
        return '';
216
    }
217
218
    $items = '';
219
220
    foreach ($userSkills as $row) {
221
        $rowSkillId = (int) ($row['skill_id'] ?? 0);
222
        if ($rowSkillId <= 0) {
223
            continue;
224
        }
225
226
        if (!in_array($rowSkillId, $allowedSkillIds, true)) {
227
            continue;
228
        }
229
230
        $skill = $em->find(Skill::class, $rowSkillId);
231
        if (!$skill) {
232
            continue;
233
        }
234
235
        $skillId = (int) $skill->getId();
236
        $title   = (string) $skill->getTitle();
237
        $desc    = (string) $skill->getDescription();
238
        $iconUrl = (string) SkillModel::getWebIconPath($skill);
239
240
        $shareUrl = api_get_path(WEB_PATH)."badge/$skillId/user/$userId";
241
242
        // Facebook sharer (https)
243
        $fbUrl = 'https://www.facebook.com/sharer/sharer.php?u='.rawurlencode($shareUrl);
244
245
        // Twitter/X intent
246
        $tweetText = sprintf(
247
            get_lang('I have achieved skill %s on %s'),
248
            $title,
249
            api_get_setting('siteName')
250
        );
251
        $twUrl = 'https://twitter.com/intent/tweet?text='.rawurlencode($tweetText).'&url='.rawurlencode($shareUrl);
252
253
        $safeTitle = Security::remove_XSS($title);
254
        $safeDesc  = Security::remove_XSS($desc);
255
256
        $items .= "
257
            <div class='py-6 border-b border-gray-20 last:border-b-0'>
258
                <div class='grid grid-cols-1 sm:grid-cols-12 gap-5 items-start'>
259
260
                    <div class='sm:col-span-3 flex justify-center sm:justify-start'>
261
                        <img
262
                            src='".htmlspecialchars($iconUrl, ENT_QUOTES)."'
263
                            alt='".htmlspecialchars($title, ENT_QUOTES)."'
264
                            loading='lazy'
265
                            width='140'
266
                            height='140'
267
                            style='max-width:140px;height:auto;'
268
                            class='h-24 w-24 sm:h-28 sm:w-28 object-contain rounded-xl bg-white ring-1 ring-gray-25 shadow-sm'
269
                        >
270
                    </div>
271
272
                    <div class='sm:col-span-6'>
273
                        <div class='text-lg font-semibold text-gray-90'>$safeTitle</div>
274
                        <div class='mt-1 text-sm text-gray-50'>$safeDesc</div>
275
                    </div>
276
277
                    <div class='sm:col-span-3'>
278
                        <div class='text-sm font-semibold text-gray-90'>".get_lang('Share with your friends')."</div>
279
280
                        <div class='mt-3 flex items-center gap-3'>
281
                            <a
282
                                href='".htmlspecialchars($fbUrl, ENT_QUOTES)."'
283
                                target='_blank'
284
                                rel='noopener noreferrer'
285
                                class='inline-flex h-10 w-10 items-center justify-center rounded-full ring-1 ring-gray-25 bg-white hover:bg-gray-15'
286
                                aria-label='Facebook'
287
                                title='Facebook'
288
                            >
289
                                <svg viewBox='0 0 24 24' class='h-5 w-5 text-gray-90' aria-hidden='true'>
290
                                    <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'/>
291
                                </svg>
292
                            </a>
293
294
                            <a
295
                                href='".htmlspecialchars($twUrl, ENT_QUOTES)."'
296
                                target='_blank'
297
                                rel='noopener noreferrer'
298
                                class='inline-flex h-10 w-10 items-center justify-center rounded-full ring-1 ring-gray-25 bg-white hover:bg-gray-15'
299
                                aria-label='X'
300
                                title='X'
301
                            >
302
                                <svg viewBox='0 0 24 24' class='h-5 w-5 text-gray-90' aria-hidden='true'>
303
                                    <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'/>
304
                                </svg>
305
                            </a>
306
                        </div>
307
                    </div>
308
309
                </div>
310
            </div>
311
        ";
312
    }
313
314
    if ($items === '') {
315
        return '';
316
    }
317
318
    return "
319
        <section class='mx-auto max-w-5xl p-4'>
320
            <div class='rounded-2xl bg-white ring-1 ring-gray-25 shadow-sm'>
321
                <div class='px-6 pt-6'>
322
                    <h3 class='text-center text-xl font-semibold text-gray-90'>
323
                        ".get_lang('Additionally, you have achieved the following skills')."
324
                    </h3>
325
                </div>
326
                <div class='px-6 pb-6'>
327
                    $items
328
                </div>
329
            </div>
330
        </section>
331
    ";
332
}
333
334
/**
335
 * Returns the skill IDs linked to a gradebook category.
336
 */
337
function getSkillIdsForGradebookCategory(int $categoryId): array
338
{
339
    if ($categoryId <= 0) {
340
        return [];
341
    }
342
343
    $ids = [];
344
345
    try {
346
        $gradebook = new Gradebook();
347
        $rows = $gradebook->getSkillsByGradebook($categoryId);
348
349
        if (is_array($rows)) {
350
            foreach ($rows as $row) {
351
                if (is_array($row) && isset($row['id'])) {
352
                    $ids[] = (int) $row['id'];
353
                } elseif (is_scalar($row)) {
354
                    $ids[] = (int) $row;
355
                }
356
            }
357
        } elseif (is_string($rows) && trim($rows) !== '') {
358
            $parts = preg_split('/\s*,\s*/', trim($rows)) ?: [];
359
            foreach ($parts as $p) {
360
                $ids[] = (int) $p;
361
            }
362
        }
363
    } catch (\Throwable $e) {
364
        return [];
365
    }
366
367
    return array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $v) => $v > 0)));
368
}
369
370
/**
371
 * Render the Learning Path final-item document.
372
 */
373
function renderFinalItemDocument(int $lpItemOrDocId, string $certificateBlock, string $badgeBlock): string
374
{
375
    $docRepo    = Container::getDocumentRepository();
376
    $lpItemRepo = Container::getLpItemRepository();
377
378
    $document = null;
379
380
    // First, try to use the id directly as a document iid.
381
    try {
382
        $document = $docRepo->find($lpItemOrDocId);
383
    } catch (\Throwable $e) {
384
        // Silence here, we will try the LP item fallback below.
385
    }
386
387
    // If not a document iid, try resolving from the LP item path.
388
    if (!$document) {
389
        try {
390
            $lpItem = $lpItemRepo->find($lpItemOrDocId);
391
            if ($lpItem) {
392
                // In our case, lp_item.path stores the document iid as string.
393
                $document = $docRepo->find((int) $lpItem->getPath());
394
            }
395
        } catch (\Throwable $e) {
396
            // As a last resort, fail quietly and return empty content.
397
        }
398
    }
399
400
    if (!$document) {
401
        return '';
402
    }
403
404
    try {
405
        $content = $docRepo->getResourceFileContent($document);
406
    } catch (\Throwable $e) {
407
        error_log('[LP_FINAL] read doc error: '.$e->getMessage());
408
        return '';
409
    }
410
411
    $hasCert  = str_contains($content, '((certificate))');
412
    $hasSkill = str_contains($content, '((skill))');
413
414
    if ($hasCert) {
415
        $content = str_replace('((certificate))', $certificateBlock, $content);
416
    }
417
    if ($hasSkill) {
418
        $content = str_replace('((skill))', $badgeBlock, $content);
419
    }
420
421
    return $content;
422
}
423