diff --git a/CHANGES.md b/CHANGES.md index 5d7b9b84..7e7ad9be 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -43,7 +43,7 @@ Note - All hash comments refer to the issue number. Eg. #169 refers to https://g ### Added -- Only fetch teachers during the email process when necessary, reducing the number of SQL queries if they are not included (#531). +- Only fetch teachers during the email process when necessary, reducing the number of SQL queries if they are not included (#531). - Filter users before process to speed up certificate task (#634). ## [4.4.2] - 2024-09-28 @@ -62,7 +62,7 @@ Note - All hash comments refer to the issue number. Eg. #169 refers to https://g - Optimise email certificate task by reducing database reads/writes and introducing configurable settings for task efficiency (#531). -- New element `expiry` which when used will display the expiry date on the list of issued certificates +- New element `expiry` which when used will display the expiry date on the list of issued certificates and the verification pages.
Any Custom Certificates that are using the `date` element and selected the expiry dates will automatically be upgraded to use this new element (#499). @@ -144,7 +144,7 @@ Note - All hash comments refer to the issue number. Eg. #169 refers to https://g - An event for when a page is created. - An event for when a page is updated. - An event for when a page is deleted. - - An event for when a template is created. + - An event for when a template is created. - An event for when a template is updated. - An event for when a template is deleted. diff --git a/classes/task/email_certificate_task.php b/classes/task/email_certificate_task.php index 92557fa8..0b904e10 100644 --- a/classes/task/email_certificate_task.php +++ b/classes/task/email_certificate_task.php @@ -53,6 +53,14 @@ public function execute() { $issueid = $customdata->issueid; $customcertid = $customdata->customcertid; + + // Check if already emailed to prevent duplicates on retry. + $issue = $DB->get_record('customcert_issues', ['id' => $issueid], 'emailed'); + if ($issue && $issue->emailed) { + mtrace("Certificate issue ID $issueid already emailed, skipping."); + return; // Already processed, skip to prevent duplicate emails. + } + $sql = "SELECT c.*, ct.id as templateid, ct.name as templatename, ct.contextid, co.id as courseid, co.fullname as coursefullname, co.shortname as courseshortname FROM {customcert} c @@ -62,6 +70,11 @@ public function execute() { $customcert = $DB->get_record_sql($sql, ['id' => $customcertid]); + if (!$customcert) { + mtrace("Certificate with ID $customcertid not found."); + return; + } + // The renderers used for sending emails. $page = new \moodle_page(); $htmlrenderer = $page->get_renderer('mod_customcert', 'email', 'htmlemail'); @@ -94,10 +107,15 @@ public function execute() { AND ci.id = :issueid"; $user = $DB->get_record_sql($sql, ['customcertid' => $customcertid, 'issueid' => $issueid]); + if (!$user) { + mtrace("User or certificate issue not found for issue ID $issueid."); + return; + } + // Create a directory to store the PDF we will be sending. $tempdir = make_temp_directory('certificate/attachment'); if (!$tempdir) { - return; + throw new \moodle_exception('Failed to create temporary directory for certificate attachment'); } // Setup the user for the cron. @@ -112,7 +130,15 @@ public function execute() { $template->name = $customcert->templatename; $template->contextid = $customcert->contextid; $template = new \mod_customcert\template($template); - $filecontents = $template->generate_pdf(false, $user->id, true); + + try { + $filecontents = $template->generate_pdf(false, $user->id, true); + } catch (\Exception $e) { + // Log PDF generation failure and allow retry by throwing exception. + mtrace('Certificate PDF generation failed for issue ID ' . $issueid . ': ' . $e->getMessage()); + debugging('Certificate PDF generation failed: ' . $e->getMessage(), DEBUG_DEVELOPER); + throw new \moodle_exception('PDF generation failed: ' . $e->getMessage()); + } // Set the name of the file we are going to send. $filename = $courseshortname . '_' . $certificatename; @@ -122,57 +148,144 @@ public function execute() { $filename = str_replace('&', '_', $filename) . '.pdf'; // Create the file we will be sending. - $tempfile = $tempdir . '/' . md5(microtime() . $user->id) . '.pdf'; - file_put_contents($tempfile, $filecontents); + $tempfile = $tempdir . '/' . md5(microtime() . $user->id . random_int(1000, 9999)) . '.pdf'; + if (file_put_contents($tempfile, $filecontents) === false) { + mtrace('Certificate PDF could not be written to temp file for issue ID ' . $issueid); + debugging('Certificate PDF write failed for issue ID ' . $issueid, DEBUG_DEVELOPER); + throw new \moodle_exception('Failed to write PDF to temporary file'); + } + + $transaction = $DB->start_delegated_transaction(); + try { + // Note: emailed flag is set before email sending. + // This is intentional to prevent infinite retries if emails fail. + $DB->set_field('customcert_issues', 'emailed', 1, ['id' => $issueid]); + mtrace("Marked certificate issue ID $issueid as emailed to prevent retries."); + // Track email sending results for logging + $emailresults = []; + $emailfailures = []; + + // Now try to send emails; log any failures but DO NOT retry. if ($customcert->emailstudents) { - $renderable = new \mod_customcert\output\email_certificate(true, $userfullname, $courseshortname, - $coursefullname, $certificatename, $context->instanceid); - - $subject = get_string('emailstudentsubject', 'customcert', $info); - $message = $textrenderer->render($renderable); - $messagehtml = $htmlrenderer->render($renderable); - email_to_user($user, $userfrom, html_entity_decode($subject, ENT_COMPAT), $message, - $messagehtml, $tempfile, $filename); + try { + $renderable = new \mod_customcert\output\email_certificate( + true, + $userfullname, + $courseshortname, + $coursefullname, + $certificatename, + $context->instanceid + ); + + $subject = get_string('emailstudentsubject', 'customcert', $info); + $message = $textrenderer->render($renderable); + $messagehtml = $htmlrenderer->render($renderable); + + $result = email_to_user($user, $userfrom, html_entity_decode($subject, ENT_COMPAT), $message, + $messagehtml, $tempfile, $filename); + + if ($result) { + $emailresults[] = "Student email sent to {$user->email}"; + } else { + $emailfailures[] = "Failed to send student email to {$user->email}"; + } + } catch (\Exception $e) { + $emailfailures[] = "Exception sending student email: " . $e->getMessage(); + } } if ($customcert->emailteachers) { - $teachers = get_enrolled_users($context, 'moodle/course:update'); + try { + $teachers = get_enrolled_users($context, 'moodle/course:update'); - $renderable = new \mod_customcert\output\email_certificate(false, $userfullname, $courseshortname, - $coursefullname, $certificatename, $context->instanceid); + $renderable = new \mod_customcert\output\email_certificate(false, $userfullname, $courseshortname, + $coursefullname, $certificatename, $context->instanceid); - $subject = get_string('emailnonstudentsubject', 'customcert', $info); - $message = $textrenderer->render($renderable); - $messagehtml = $htmlrenderer->render($renderable); - foreach ($teachers as $teacher) { - email_to_user($teacher, $userfrom, html_entity_decode($subject, ENT_COMPAT), - $message, $messagehtml, $tempfile, $filename); + $subject = get_string('emailnonstudentsubject', 'customcert', $info); + $message = $textrenderer->render($renderable); + $messagehtml = $htmlrenderer->render($renderable); + + foreach ($teachers as $teacher) { + try { + $result = email_to_user($teacher, $userfrom, html_entity_decode($subject, ENT_COMPAT), + $message, $messagehtml, $tempfile, $filename); + + if ($result) { + $emailresults[] = "Teacher email sent to {$teacher->email}"; + } else { + $emailfailures[] = "Failed to send teacher email to {$teacher->email}"; + } + } catch (\Exception $e) { + $emailfailures[] = "Exception sending teacher email to {$teacher->email}: " . $e->getMessage(); + } + } + } catch (\Exception $e) { + $emailfailures[] = "Exception getting teachers or sending teacher emails: " . $e->getMessage(); } } if (!empty($customcert->emailothers)) { - $others = explode(',', $customcert->emailothers); - foreach ($others as $email) { - $email = trim($email); - if (validate_email($email)) { - $renderable = new \mod_customcert\output\email_certificate(false, $userfullname, - $courseshortname, $coursefullname, $certificatename, $context->instanceid); + try { + $others = explode(',', $customcert->emailothers); + $renderable = new \mod_customcert\output\email_certificate(false, $userfullname, + $courseshortname, $coursefullname, $certificatename, $context->instanceid); $subject = get_string('emailnonstudentsubject', 'customcert', $info); $message = $textrenderer->render($renderable); $messagehtml = $htmlrenderer->render($renderable); - $emailuser = new \stdClass(); - $emailuser->id = -1; - $emailuser->email = $email; - email_to_user($emailuser, $userfrom, html_entity_decode($subject, ENT_COMPAT), $message, - $messagehtml, $tempfile, $filename); + foreach ($others as $email) { + $email = trim($email); + if (validate_email($email)) { + try { + $emailuser = new \stdClass(); + $emailuser->id = -1; + $emailuser->email = $email; + + $result = email_to_user($emailuser, $userfrom, html_entity_decode($subject, ENT_COMPAT), $message, + $messagehtml, $tempfile, $filename); + + if ($result) { + $emailresults[] = "Other email sent to {$email}"; + } else { + $emailfailures[] = "Failed to send other email to {$email}"; + } + } catch (\Exception $e) { + $emailfailures[] = "Exception sending other email to {$email}: " . $e->getMessage(); + } + } else { + $emailfailures[] = "Invalid email address in others list: {$email}"; + } } + } catch (\Exception $e) { + $emailfailures[] = "Exception processing other email addresses: " . $e->getMessage(); } } - // Set the field so that it is emailed. - $DB->set_field('customcert_issues', 'emailed', 1, ['id' => $issueid]); + // Log results + if (!empty($emailresults)) { + mtrace("Email successes for issue ID $issueid: " . implode(', ', $emailresults)); + } + + if (!empty($emailfailures)) { + mtrace("Email failures for issue ID $issueid: " . implode(', ', $emailfailures)); + debugging("Certificate email failures for issue ID $issueid: " . implode('; ', $emailfailures), DEBUG_DEVELOPER); + } + + if (empty($emailresults)) { + throw new \moodle_exception("No emails sent successfully for issue ID $issueid; retrying later."); + } + $transaction->allow_commit(); + // Clean up temporary file + if (file_exists($tempfile)) { + unlink($tempfile); + } + } catch (\Exception $e) { + $emailfailures[] = "Email sending failed: " . $e->getMessage(); + $transaction->rollback($e); + throw $e; + } + mtrace("Certificate email task completed for issue ID $issueid."); } } diff --git a/classes/template.php b/classes/template.php index 870ffc44..d5645885 100644 --- a/classes/template.php +++ b/classes/template.php @@ -320,15 +320,23 @@ public function generate_pdf(bool $preview = false, ?int $userid = null, bool $r $pdf->SetAutoPageBreak(true, 0); // Get filename pattern from global settings. - if (empty($customcert->usecustomfilename) || empty($customcert->customfilenamepattern)) { + if (!empty($customcert->usecustomfilename) && !empty($customcert->customfilenamepattern)) { + $filenamepattern = $customcert->customfilenamepattern; + } else { + $filenamepattern = '{DEFAULT}'; + } + + if (empty($filenamepattern) || $filenamepattern === '{DEFAULT}') { // Use the custom cert name as the base filename (strip any trailing dot). $filename = rtrim(format_string($this->name, true, ['context' => $this->get_context()]), '.'); } else { + // Build filename from pattern substitutions. + // Get issue record for date (if issued); fallback to current date if not found. $issue = $DB->get_record('customcert_issues', [ 'userid' => $user->id, 'customcertid' => $customcert->id, - ]); + ], '*', IGNORE_MISSING); if ($issue && !empty($issue->timecreated)) { $issuedate = date('Y-m-d', $issue->timecreated); @@ -336,14 +344,14 @@ public function generate_pdf(bool $preview = false, ?int $userid = null, bool $r $issuedate = date('Y-m-d'); } - $course = $DB->get_record('course', ['id' => $customcert->course]); + $course = $DB->get_record('course', ['id' => $customcert->course], '*', IGNORE_MISSING); $values = [ - '{FIRST_NAME}' => $user->firstname ?? '', - '{LAST_NAME}' => $user->lastname ?? '', - '{COURSE_SHORT_NAME}' => $course ? $course->shortname : '', - '{COURSE_FULL_NAME}' => $course ? $course->fullname : '', - '{ISSUE_DATE}' => $issuedate, + '{FIRST NAME}' => $user->firstname ?? '', + '{LAST NAME}' => $user->lastname ?? '', + '{COURSE SHORT NAME}' => $course ? $course->shortname : '', + '{COURSE FULL NAME}' => $course ? $course->fullname : '', + '{DATE}' => $issuedate, ]; // Handle group if needed. @@ -352,13 +360,13 @@ public function generate_pdf(bool $preview = false, ?int $userid = null, bool $r $groupnames = array_map(function($g) { return $g->name; }, $groups); - $values['{GROUP_NAME}'] = implode(', ', $groupnames); + $values['{GROUP}'] = implode(', ', $groupnames); } else { - $values['{GROUP_NAME}'] = ''; + $values['{GROUP}'] = ''; } // Replace placeholders with actual values. - $filename = strtr($customcert->customfilenamepattern, $values); + $filename = strtr($filenamepattern, $values); // Remove trailing dot to avoid "..pdf" issues. $filename = rtrim($filename, '.'); diff --git a/db/install.xml b/db/install.xml index 9dff73d2..c685a809 100644 --- a/db/install.xml +++ b/db/install.xml @@ -59,7 +59,7 @@ - + diff --git a/db/upgrade.php b/db/upgrade.php index ad60bb29..e9ce8e7d 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -298,25 +298,27 @@ function xmldb_customcert_upgrade($oldversion) { upgrade_mod_savepoint(true, 2024042205, 'customcert'); } + // Drop the unique index on 'code' and add a non-unique one. if ($oldversion < 2024042210) { $table = new xmldb_table('customcert_issues'); - $index = new xmldb_index('code', XMLDB_INDEX_UNIQUE, ['code']); + // Drop existing unique index if it exists. + $index = new xmldb_index('code', XMLDB_INDEX_UNIQUE, ['code']); if ($dbman->index_exists($table, $index)) { $dbman->drop_index($table, $index); } + // Add non-unique index. $index = new xmldb_index('code', XMLDB_INDEX_NOTUNIQUE, ['code']); - if (!$dbman->index_exists($table, $index)) { $dbman->add_index($table, $index); } - // Update the plugin version in the database. + // Save the upgrade step. upgrade_plugin_savepoint(true, 2024042210, 'mod', 'customcert'); } - if ($oldversion < 2024042213) { + if ($oldversion < 2025062100) { $table = new xmldb_table('customcert'); // Add 'usecustomfilename' field. @@ -332,8 +334,7 @@ function xmldb_customcert_upgrade($oldversion) { } // Savepoint reached. - upgrade_mod_savepoint(true, 2024042213, 'customcert'); + upgrade_mod_savepoint(true, 2025062100, 'customcert'); } - return true; } diff --git a/element/qrcode/classes/element.php b/element/qrcode/classes/element.php index 4cd88977..2a78e1b6 100644 --- a/element/qrcode/classes/element.php +++ b/element/qrcode/classes/element.php @@ -158,8 +158,7 @@ public function render($pdf, $preview, $user) { $context = \context::instance_by_id($issue->contextid); $urlparams = [ - 'code' => $code, - 'qrcode' => 1, + 'certId' => $code, ]; // We only add the 'contextid' to the link if the site setting for verifying all certificates is off, @@ -173,7 +172,7 @@ public function render($pdf, $preview, $user) { $urlparams['contextid'] = $issue->contextid; } - $qrcodeurl = new \moodle_url('/mod/customcert/verify_certificate.php', $urlparams); + $qrcodeurl = new \moodle_url('https://app.pacificmedicaltraining.com/verify', $urlparams); $qrcodeurl = $qrcodeurl->out(false); } diff --git a/lang/en/customcert.php b/lang/en/customcert.php index 70108186..afc5bbf6 100644 --- a/lang/en/customcert.php +++ b/lang/en/customcert.php @@ -22,6 +22,7 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +$string['Upper/lower/digits'] = '6aOdbLEuoC (Upper/lower/digits random string)'; $string['activity'] = 'Activity'; $string['addcertpage'] = 'Add page'; $string['addelement'] = 'Add element'; @@ -65,6 +66,8 @@ $string['customcertsettings'] = 'Custom certificate settings'; $string['customfilenamepattern'] = 'Custom file name pattern'; $string['customfilenamepattern_help'] = 'Enter the pattern for naming certificate files. You can use placeholders such as {firstname}, {lastname}, {group}, {coursename}, {date}.'; +$string['custompattern'] = 'First name, Last name, Course short name, Issue date'; +$string['defaultpattern'] = 'Default (certificate name)'; $string['deletecertpage'] = 'Delete page'; $string['deleteconfirm'] = 'Delete confirmation'; $string['deleteelement'] = 'Delete element'; @@ -73,10 +76,14 @@ $string['deleteissuedcertificates'] = 'Delete issued certificates'; $string['deletepageconfirm'] = 'Are you sure you want to delete this certificate page?'; $string['deletetemplateconfirm'] = 'Are you sure you want to delete this certificate template?'; +$string['deliveryoption'] = 'Delivery option'; +$string['deliveryoption_help'] = 'Choose how the certificate should be delivered to users.'; $string['deliveryoptiondownload'] = 'Send to the browser and force a file download'; $string['deliveryoptioninline'] = 'Send the file inline to the browser'; $string['deliveryoptions'] = 'Delivery options'; $string['description'] = 'Description'; +$string['digits-with-hyphens'] = '0123-4567-8901 (Digits with hyphens)'; +$string['download'] = 'Force download'; $string['downloadallissuedcertificates'] = 'Download all issued certificates'; $string['downloadallsitecertificates'] = 'Download all site certificates'; $string['downloadallsitecertificatesdesc'] = 'This will download all the certificates on the site in a zip file.'; @@ -125,6 +132,8 @@ $string['eventtemplatedeleted'] = 'Custom certificate template deleted'; $string['eventtemplateupdated'] = 'Custom certificate template updated'; $string['exampledatawarning'] = 'Some of these values may just be an example to ensure positioning of the elements is possible.'; +$string['filenamepattern'] = 'File name pattern (legacy)'; +$string['filenamepattern_help'] = 'Choose the pattern for naming certificate files (legacy setting).'; $string['font'] = 'Font'; $string['font_help'] = 'The font used when generating this element.'; $string['fontcolour'] = 'Colour'; @@ -137,6 +146,7 @@ $string['height_help'] = 'This is the height of the certificate PDF in mm. For reference an A4 piece of paper is 297mm high and a letter is 279mm high.'; $string['includeinnotvisiblecourses'] = 'Include certificates in hidden courses'; $string['includeinnotvisiblecourses_desc'] = 'Certificates from hidden courses are not proccesed by default. If you want to include them, enable this setting.'; +$string['inline'] = 'Display inline'; $string['invalidcode'] = 'Invalid code supplied.'; $string['invalidcolour'] = 'Invalid colour chosen, please enter a valid HTML colour name, or a six-digit, or three-digit hexadecimal colour.'; $string['invalidelementwidthorheightnotnumber'] = 'Please enter a valid number.'; @@ -241,7 +251,7 @@ Enabling this option may improve the performance of the scheduled task by offloading email processing to ad-hoc tasks.'; $string['usecustomfilename'] = 'Use custom file name pattern'; -$string['usecustomfilename_help'] = 'If enabled, you can define a custom file name pattern for certificates using placeholders. The placeholders are {FIRST_NAME}, {LAST_NAME}, {GROUP_NAME}, {COURSE_SHORT_NAME}, {COURSE_FULL_NAME} and {ISSUE_DATE}.'; +$string['usecustomfilename_help'] = 'If enabled, you can define a custom file name pattern for certificates using placeholders such as {FIRST NAME}, {LAST NAME}, {GROUP}, {COUSRE SHORT NAME}, {DATE}.'; $string['userlanguage'] = 'Use user preferences'; $string['userlanguage_help'] = 'You can force the language of the certificate to override the user\'s language preferences.'; $string['verified'] = 'Verified'; diff --git a/lib.php b/lib.php index 69afda51..959db7a2 100644 --- a/lib.php +++ b/lib.php @@ -436,6 +436,63 @@ function mod_customcert_inplace_editable($itemtype, $itemid, $newvalue) { } } +// Prevent direct access to this file. +defined('MOODLE_INTERNAL') || die(); + +/** + * Generates a public URL for viewing a user's certificate (eCard). + * + * This function constructs a URL that allows public access to a certificate + * without requiring authentication. It does so by generating a secure token + * based on the certificate code. + * + * @param string $cert_code The unique code of the certificate. + * @return string The generated public URL for the certificate. + */ +function generate_public_url_for_certificate(string $cert_code): string { + global $CFG; + + // Generate a security token for the certificate using a private function. + $token = calculate_signature($cert_code); + + // Construct and return the public URL to view the certificate. + return $CFG->wwwroot . '/mod/customcert/view_user_cert.php?cert_code=' . urlencode($cert_code) . '&token=' . urlencode($token); +} + +/** + * Generates a secure HMAC signature for a certificate. + * + * This function creates a unique signature for a certificate based on its code. + * The signature is used as a security token to verify access to the certificate. + * It prevents unauthorized access by ensuring that only valid certificates can + * be accessed through a generated URL. + * + * The signature is generated using the HMAC (Hash-based Message Authentication Code) + * method with SHA-256, ensuring strong security. It uses Moodle's `siteidentifier` + * as the secret key, making it unique to each Moodle installation. + * + * @param string $cert_code The unique certificate code. + * @return string The generated HMAC signature. + */ +function calculate_signature(string $cert_code): string { + global $CFG; + + // Define a namespaced message prefix to avoid signature collisions. + $messagePrefix = 'mod_customcert:view_user_cert'; + + // Construct the message that will be signed. + // This includes the prefix and the certificate code to create a unique hash. + $message = $messagePrefix . '|' . $cert_code; + + // Use Moodle's unique site identifier as the secret key for HMAC. + // This ensures that signatures are installation-specific. + $secret = $CFG->siteidentifier; + + // Generate the HMAC hash using SHA-256. + // This provides a cryptographic signature that is difficult to forge. + return hash_hmac('sha256', $message, $secret); +} + /** * Get icon mapping for font-awesome. */ diff --git a/mod_form.php b/mod_form.php index 448fbba3..38382b96 100644 --- a/mod_form.php +++ b/mod_form.php @@ -77,6 +77,15 @@ public function definition() { $mform->addHelpButton('customfilenamepattern', 'customfilenamepattern', 'customcert'); $mform->disabledIf('customfilenamepattern', 'usecustomfilename', 'notchecked'); + if ($this->current) { + if (property_exists($this->current, 'usecustomfilename')) { + $mform->setDefault('usecustomfilename', $this->current->usecustomfilename); + } + if (property_exists($this->current, 'customfilenamepattern')) { + $mform->setDefault('customfilenamepattern', $this->current->customfilenamepattern); + } + } + if (has_capability('mod/customcert:manageemailstudents', $this->get_context())) { $mform->addElement('selectyesno', 'emailstudents', get_string('emailstudents', 'customcert')); $mform->setDefault('emailstudents', get_config('customcert', 'emailstudents')); diff --git a/tests/event/events_test.php b/tests/event/events_test.php index 2636a7f6..1f1d9204 100644 --- a/tests/event/events_test.php +++ b/tests/event/events_test.php @@ -466,4 +466,87 @@ public function test_deleting_an_element(): void { $this->assertEquals($templateupdatedevent->contextid, \context_system::instance()->id); $this->assertDebuggingNotCalled(); } + + /** + * Tests that the issue_created event is fired correctly. + */ + public function test_issue_created_event(): void { + global $DB; + + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_user(); + $customcert = $this->getDataGenerator()->create_module('customcert', ['course' => $course->id]); + $context = \context_module::instance($customcert->cmid); + + // Simulate certificate issue. + $issue = new \stdClass(); + $issue->userid = $user->id; + $issue->customcertid = $customcert->id; + $issue->timecreated = time(); + $issueid = $DB->insert_record('customcert_issues', $issue); + + $sink = $this->redirectEvents(); + + // Trigger event manually. + \mod_customcert\event\issue_created::create([ + 'objectid' => $issueid, + 'context' => $context, + 'relateduserid' => $user->id, + 'userid' => $user->id, + ])->trigger(); + + $events = $sink->get_events(); + $this->assertCount(1, $events); + + $event = reset($events); + $this->assertInstanceOf(\mod_customcert\event\issue_created::class, $event); + $this->assertEquals($issueid, $event->objectid); + $this->assertEquals($context->id, $event->contextid); + $this->assertDebuggingNotCalled(); + } + + /** + * Tests that the issue_deleted event is fired correctly. + */ + public function test_issue_deleted_event(): void { + global $DB; + + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_user(); + $customcert = $this->getDataGenerator()->create_module('customcert', ['course' => $course->id]); + $context = \context_module::instance($customcert->cmid); + + // Create an issue. + $issue = new \stdClass(); + $issue->userid = $user->id; + $issue->customcertid = $customcert->id; + $issue->timecreated = time(); + $issueid = $DB->insert_record('customcert_issues', $issue); + + $sink = $this->redirectEvents(); + + // Simulate deleting the issue. + $DB->delete_records('customcert_issues', ['id' => $issueid]); + + // Trigger event manually. + \mod_customcert\event\issue_deleted::create([ + 'objectid' => $issueid, + 'context' => $context, + 'relateduserid' => $user->id, + 'userid' => $user->id, + ])->trigger(); + + $events = $sink->get_events(); + $this->assertCount(1, $events); + + $event = reset($events); + $this->assertInstanceOf(\mod_customcert\event\issue_deleted::class, $event); + $this->assertEquals($issueid, $event->objectid); + $this->assertEquals($context->id, $event->contextid); + $this->assertDebuggingNotCalled(); + } } diff --git a/version.php b/version.php index c4c7e3cd..1cb55de3 100644 --- a/version.php +++ b/version.php @@ -24,7 +24,7 @@ defined('MOODLE_INTERNAL') || die('Direct access to this script is forbidden.'); -$plugin->version = 2024042213; // The current module version (Date: YYYYMMDDXX). +$plugin->version = 2025062100; // The current module version (Date: YYYYMMDDXX). $plugin->requires = 2024042200; // Requires this Moodle version (4.4). $plugin->cron = 0; // Period for cron to check this module (secs). $plugin->component = 'mod_customcert'; diff --git a/view_user_cert.php b/view_user_cert.php new file mode 100644 index 00000000..e71e1b77 --- /dev/null +++ b/view_user_cert.php @@ -0,0 +1,88 @@ +dirroot . '/mod/customcert/lib.php'); + +// Set up the page context before processing any parameters. +// This ensures that Moodle properly initializes the page and handles any errors gracefully. +$context = context_system::instance(); +$PAGE->set_context($context); +$PAGE->set_url('/mod/customcert/view_user_cert.php'); +$PAGE->set_title('View certificate'); +$PAGE->set_heading('View certificate'); + +/** + * Displays an error message in a formatted Moodle page and exits. + * + * This function helps standardize error handling by rendering the page + * properly and showing the error message in an alert box. + * + * @param string $message The error message to display. + */ +function display_error_page($message) { + global $OUTPUT; + + echo $OUTPUT->header(); // Display the page header. + echo $OUTPUT->box($message, 'alert alert-danger'); // Display the error message in a styled box. + echo $OUTPUT->footer(); // Display the page footer. + exit; // Stop further execution. +} + +// Retrieve certificate code and verification token from URL parameters. +// 'optional_param' is used instead of 'required_param' to avoid Moodle throwing an automatic error page. +$cert_code = optional_param('cert_code', '', PARAM_ALPHANUMEXT); +$token = optional_param('token', '', PARAM_ALPHANUMEXT); + +// Ensure both required parameters are provided. +if (empty($cert_code) || empty($token)) { + display_error_page('Certificate code or verification token is missing. Please check the URL and try again.'); +} + +// Validate the provided token by regenerating it using the expected algorithm. +$expected_token = calculate_signature($cert_code); +if ($token !== $expected_token) { + display_error_page('The verification token is invalid for this certificate. Please check the URL and try again.'); +} + +// Retrieve the certificate issue entry using the provided certificate code. +// This helps fetch the associated user ID to verify ownership. +$issue = $DB->get_record('customcert_issues', ['code' => $cert_code], '*'); + +if (!$issue) { + display_error_page('The certificate with the provided code could not be found. Please verify the certificate code and try again.'); +} + +// Fetch the certificate associated with the retrieved issue. +// The certificate must be one of the recognized eCard types: 'Cognitive eCard' or 'Completion eCard'. +$certificate = $DB->get_record_sql(" + SELECT * FROM {customcert} + WHERE id = ? AND name IN ('Cognitive eCard', 'Completion eCard') +", [$issue->customcertid]); + +if (!$certificate) { + display_error_page('The certificate type is not valid or does not exist. Please contact the site administrator for assistance.'); +} + +// Retrieve the corresponding template for the fetched certificate. +// The template defines the layout and content of the generated certificate. +$template = $DB->get_record('customcert_templates', ['id' => $certificate->templateid]); +if (!$template) { + display_error_page('The certificate template could not be found. Please contact the site administrator for assistance.'); +} + +try { + // Convert the template record into a template object. + // This object provides methods to generate and render the certificate. + $template = new \mod_customcert\template($template); + + // Generate and output the certificate PDF. + // 'false' indicates that the PDF is displayed inline instead of being force-downloaded. + // The second parameter ensures the certificate is generated for the correct user. + $template->generate_pdf(false, $issue->userid); +} catch (Exception $e) { + // Catch any errors that may occur while generating the certificate PDF. + display_error_page('There was an error generating the certificate PDF. Please try again later or contact support if the problem persists.'); +} + +// Prevent further execution after rendering the certificate. +exit;