diff --git a/.github/workflows/moodle-ci.yml b/.github/workflows/moodle-ci.yml index 47f1b3aa..f50c4cdb 100644 --- a/.github/workflows/moodle-ci.yml +++ b/.github/workflows/moodle-ci.yml @@ -8,7 +8,7 @@ jobs: services: postgres: - image: postgres:13 + image: postgres:14 env: POSTGRES_USER: 'postgres' POSTGRES_HOST_AUTH_METHOD: 'trust' @@ -16,7 +16,7 @@ jobs: - 5432:5432 options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 3 mariadb: - image: mariadb:10.6.10 + image: mariadb:10.11 env: MYSQL_USER: 'root' MYSQL_ALLOW_EMPTY_PASSWORD: "true" @@ -28,17 +28,17 @@ jobs: fail-fast: false matrix: include: - - php: '8.3' - moodle-branch: 'MOODLE_404_STABLE' + - php: '8.4' + moodle-branch: 'MOODLE_500_STABLE' database: pgsql - - php: '8.3' - moodle-branch: 'MOODLE_404_STABLE' + - php: '8.4' + moodle-branch: 'MOODLE_500_STABLE' database: mariadb - - php: '8.1' - moodle-branch: 'MOODLE_404_STABLE' + - php: '8.2' + moodle-branch: 'MOODLE_500_STABLE' database: pgsql - - php: '8.1' - moodle-branch: 'MOODLE_404_STABLE' + - php: '8.2' + moodle-branch: 'MOODLE_500_STABLE' database: mariadb steps: @@ -56,7 +56,7 @@ jobs: - name: Initialise moodle-plugin-ci run: | - composer create-project -n --no-dev --prefer-dist moodlehq/moodle-plugin-ci ci ^3 + composer create-project -n --no-dev --prefer-dist moodlehq/moodle-plugin-ci ci ^4 echo $(cd ci/bin; pwd) >> $GITHUB_PATH echo $(cd ci/vendor/bin; pwd) >> $GITHUB_PATH sudo locale-gen en_AU.UTF-8 diff --git a/CHANGES.md b/CHANGES.md index 5d7b9b84..fca964c0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. Note - All hash comments refer to the issue number. Eg. #169 refers to https://github.com/mdjnelson/moodle-mod_customcert/issues/169. +## [5.0.0] - 2025-06-07 + +### Fixed + +- Updated subplugins.json file to match Moodle 5.0 format (#688). + ## [4.4.6] - 2025-06-07 ### Added diff --git a/backup/moodle2/backup_customcert_stepslib.php b/backup/moodle2/backup_customcert_stepslib.php index 01dc0f75..d2fecac6 100644 --- a/backup/moodle2/backup_customcert_stepslib.php +++ b/backup/moodle2/backup_customcert_stepslib.php @@ -40,8 +40,10 @@ protected function define_structure() { // The instance. $customcert = new backup_nested_element('customcert', ['id'], [ - 'templateid', 'name', 'intro', 'introformat', 'requiredtime', 'verifyany', 'emailstudents', - 'emailteachers', 'emailothers', 'protection', 'timecreated', 'timemodified']); + 'templateid', 'name', 'intro', 'introformat', 'requiredtime', 'verifyany', + 'deliveryoption', 'usecustomfilename', 'customfilenamepattern', 'emailstudents', + 'emailteachers', 'emailothers', 'protection', 'language', 'keeplocalcopy', 'timecreated', + 'timemodified']); // The template. $template = new backup_nested_element('template', ['id'], [ diff --git a/classes/certificate.php b/classes/certificate.php index e8e4fd4e..10e4a9dd 100644 --- a/classes/certificate.php +++ b/classes/certificate.php @@ -545,7 +545,7 @@ public static function issue_certificate($certificateid, $userid) { $event = \mod_customcert\event\issue_created::create([ 'objectid' => $issueid, 'context' => $context, - 'relateduserid' => $userid + 'relateduserid' => $userid, ]); $event->trigger(); diff --git a/classes/localfile.php b/classes/localfile.php new file mode 100644 index 00000000..724da911 --- /dev/null +++ b/classes/localfile.php @@ -0,0 +1,254 @@ +. + +/** + * Class represents a local file of an issued certificate. + * + * @package mod_customcert + * @copyright 2023 Giorgio Consorti + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_customcert; + +use file_exception; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class represents a local file of an issued certificate. + * + * @package mod_customcert + * @copyright 2023 Giorgio Consorti + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class localfile { + + /** + * @var \mod_customcert\template the template representing the content of the file. + */ + protected $template; + + /** + * The component name for the file storage. + */ + private const component = 'mod_customcert'; + + /** + * The filearea name for the file storage. + */ + private const filearea = 'customcert_issues'; + + /** + * The constructor. + * + * @param \mod_customcert\template $template + */ + public function __construct(\mod_customcert\template $template) { + $this->template = $template; + } + + /** + * Save the PDF to the file storage. + * + * @param string $pdfcontent string content of the pdf + * @param int|null $userid the id of the user whose certificate we want to save + * + * @return \stored_file|bool the stored_file object on success, false on error + */ + public function save_pdf(string $pdfcontent, ?int $userid = null): \stored_file|bool { + global $CFG, $USER; + require_once($CFG->libdir . '/filelib.php'); + + if (empty($userid)) { + $userid = $USER->id; + } + + try { + $file = $this->get_pdf($userid); + if (!$file) { + // Create file containing the pdf + $fs = get_file_storage(); + $file = $fs->create_file_from_string($this->buildFileInfo($userid), $pdfcontent); + } + return $file; + } catch (file_exception $e) { + // maybe log the exception + return false; + } + } + + /** + * Get the PDF from the file storage. + * + * @param int|null $userid the id of the user whose certificate we want to get + * + * @return \stored_file|bool the stored_file object on success, false on error + */ + public function get_pdf(?int $userid = null): \stored_file|bool { + global $CFG, $USER; + require_once($CFG->libdir . '/filelib.php'); + + if (empty($userid)) { + $userid = $USER->id; + } + + $fileinfo = $this->buildFileInfo($userid); + $fs = get_file_storage(); + return $fs->get_file($fileinfo['contextid'], $fileinfo['component'], $fileinfo['filearea'], + $fileinfo['itemid'], $fileinfo['filepath'], $fileinfo['filename']); + } + + /** + * Delete the PDF from the file storage. + * + * @param int|null $userid the id of the user whose certificate we want to get + * + * @return bool true on success + */ + public function delete_pdf(?int $userid = null): bool { + global $USER; + + if (empty($userid)) { + $userid = $USER->id; + } + + try { + $file = $this->get_pdf($userid); + if ($file) { + return $file->delete(); + } + return false; + } catch (file_exception $e) { + return false; + } + } + + /** + * Send the PDF to the browser or return it as a string. + * + * @param int $userid the id of the user whose certificate we want to view + * @param string $deliveryoption the delivery option of the customcert + * @param bool $return Do we want to return the contents of the PDF? + * + * @return string|null Can return the PDF in string format if specified. + */ + public function send_pdf(?int $userid = NULL, string $deliveryoption = certificate::DELIVERY_OPTION_DOWNLOAD, bool $return = false): string|null { + global $USER; + + if (empty($userid)) { + $userid = $USER->id; + } + + $file = $this->get_pdf($userid); + if ($file) { + if ($return) { + return $file->get_content(); + } else { + // send the file to the browser + send_stored_file( + $file, + 0, + 0, + $deliveryoption == certificate::DELIVERY_OPTION_DOWNLOAD, + ['filename' => $file->get_filename()] + ); + die(); + } + } + return null; + } + + /** + * Check if a pdf exists in the file storage area. + * + * @param \stdClass $cm the course module + * @param int|null $userid the id of the user whose PDF we want to check + * @param int|null $templateid the template id of the customcert we want to check + * + * @return \stored_file|false the stored_file object on success, false on error + */ + public static function does_pdf_exist($cm, ?int $userid = null, ?int $templateid = null): \stored_file|bool { + + $fileinfo = self::build_file_info($cm, $userid, $templateid); + $fs = get_file_storage(); + return $fs->get_file($fileinfo['contextid'], $fileinfo['component'], $fileinfo['filearea'], + $fileinfo['itemid'], $fileinfo['filepath'], $fileinfo['filename']); + } + + /** + * Build the fileinfo array needed by the file storage. + * + * @param int|null $userid the id of the user whose fileinfo array we want to generate + * + * @return array the fileinfo array + */ + protected function buildFileInfo(?int $userid = null): array { + + return self::build_file_info($this->template->get_cm(), $userid, $this->template->get_id()); + } + + /** + * Build the fileinfo array needed by the file storage, static version. + * + * @param \stdClass $cm the course module + * @param int|null $userid the id of the user whose fileinfo array we want to generate + * @param int|null $templateid the template id of the customcert of the array we want to generate + * + * @return array the fileinfo array + */ + private static function build_file_info ($cm, ?int $userid = null, ?int $templateid = null): array { + + /** @var \moodle_database $DB */ + global $DB, $USER; + + if (empty($userid)) { + $userid = $USER->id; + } + + if (empty($templateid)) { + $customcert = $DB->get_record('customcert', array('id' => $cm->instance), '*', MUST_EXIST); + $templateid = $customcert->templateid; + } + + $course = $DB->get_record('course', ['id' => $cm->course]); + $context = $DB->get_record('context', ['contextlevel' => '50', 'instanceid' => $course->id]); + $user_info = $DB->get_record('user', ['id' => $userid]); + + return [ + 'contextid' => $context->id, + 'component' => self::component, + 'filearea' => self::filearea, + 'itemid' => $templateid, + 'userid' => $USER->id, + 'author' => fullname($USER), + 'filepath' => '/' . $course->id . '/', + 'filename' => self::buildFileName($user_info->username, $templateid, $course->shortname), + ]; + } + + /** + * Build the PDF filename. + * + * @param string $username + * @param string $templateid + * @param string $courseShortname + * @return string the PDF file name + */ + public static function buildFileName($username, $templateid, $courseShortname): string { + return $username . '_cert-' . $templateid . '_course-' . $courseShortname . '.pdf'; + } +} diff --git a/classes/report_table.php b/classes/report_table.php index 6405e3f3..33f87bf9 100644 --- a/classes/report_table.php +++ b/classes/report_table.php @@ -212,16 +212,42 @@ public function col_download($user) { public function col_actions($user) { global $OUTPUT; - $icon = new \pix_icon('i/delete', get_string('delete')); - $link = new \moodle_url('/mod/customcert/view.php', + $actions = [ [ - 'id' => $this->cm->id, - 'deleteissue' => $user->issueid, - 'sesskey' => sesskey(), + 'icon' => new \pix_icon('i/delete', get_string('delete')), + 'link' => new \moodle_url( + '/mod/customcert/view.php', + [ + 'id' => $this->cm->id, + 'deleteissue' => $user->issueid, + 'sesskey' => sesskey() + ] + ), + 'attributes' => ['class' => 'action-icon delete-icon'], ] - ); + ]; + + if (has_capability('mod/customcert:deletelocalcopy', \context_module::instance($this->cm->id)) && localfile::does_pdf_exist($this->cm, $user->id)) { + $actions[] = [ + 'icon' => new \pix_icon('deletelocalcopy', get_string('deletelocalcopy', 'customcert'), 'customcert'), + 'link' => new \moodle_url( + '/mod/customcert/view.php', + [ + 'id' => $this->cm->id, + 'deleteissue' => $user->issueid, + 'deletelocalcopy' => 1, + 'sesskey' => sesskey() + ] + ), + 'attributes' => ['class' => 'action-icon deletelocalcopy-icon'], + ]; + } - return $OUTPUT->action_icon($link, $icon, null, ['class' => 'action-icon delete-icon']); + return implode(" ", array_map( + fn ($action) => + $OUTPUT->action_icon($action['link'], $action['icon'], null, $action['attributes'] ?? []), + $actions + )); } /** diff --git a/classes/task/issue_certificates_task.php b/classes/task/issue_certificates_task.php index fdd47106..fa444b75 100644 --- a/classes/task/issue_certificates_task.php +++ b/classes/task/issue_certificates_task.php @@ -45,24 +45,16 @@ public function get_name(): string { * Execute. */ public function execute() { - global $CFG, $DB; + global $DB; // Get the certificatesperrun, includeinnotvisiblecourses, and certificateexecutionperiod configurations. $certificatesperrun = (int)get_config('customcert', 'certificatesperrun'); $includeinnotvisiblecourses = (bool)get_config('customcert', 'includeinnotvisiblecourses'); $certificateexecutionperiod = (int)get_config('customcert', 'certificateexecutionperiod'); $offset = (int)get_config('customcert', 'certificate_offset'); - - if ($CFG->dbtype === 'oci') { - // For Oracle, convert the CLOB to a VARCHAR2 (limiting to 4000 characters) since we are using DISTINCT. - $emailothersselect = "DBMS_LOB.SUBSTR(c.emailothers, 4000, 1) AS emailothers"; - $emailotherslengthsql = "DBMS_LOB.GETLENGTH(c.emailothers)"; - } else { - $emailothersselect = "c.emailothers"; - $emailotherslengthsql = $DB->sql_length('c.emailothers'); - } - + $emailothersselect = "c.emailothers"; $emailotherslengthsql = $DB->sql_length('c.emailothers'); + $sql = "SELECT DISTINCT c.id, c.templateid, c.course, c.requiredtime, c.emailstudents, c.emailteachers, $emailothersselect, ct.id AS templateid, ct.name AS templatename, ct.contextid, co.id AS courseid, co.fullname AS coursefullname, co.shortname AS courseshortname @@ -71,7 +63,7 @@ public function execute() { ON c.templateid = ct.id JOIN {course} co ON c.course = co.id - JOIN {course_categories} cat + LEFT JOIN {course_categories} cat ON co.category = cat.id LEFT JOIN {customcert_issues} ci ON c.id = ci.customcertid @@ -84,7 +76,7 @@ public function execute() { // Check the includeinnotvisiblecourses configuration. if (!$includeinnotvisiblecourses) { // Exclude certificates from hidden courses. - $sql .= " AND co.visible = 1 AND cat.visible = 1"; + $sql .= " AND co.visible = 1 AND (cat.visible = 1 OR cat.id IS NULL)"; } // Add condition based on certificate execution period. diff --git a/classes/template.php b/classes/template.php index 79dfdbd6..cdac9919 100644 --- a/classes/template.php +++ b/classes/template.php @@ -48,6 +48,11 @@ class template { */ protected $contextid; + /** + * @var \mod_customcert\localfile the local file for the template. + */ + protected $localfile; + /** * The constructor. * @@ -57,6 +62,7 @@ public function __construct($template) { $this->id = $template->id; $this->name = $template->name; $this->contextid = $template->contextid; + $this->localfile = new localfile($this); } /** @@ -314,24 +320,81 @@ public function generate_pdf(bool $preview = false, ?int $userid = null, bool $r $deliveryoption = $customcert->deliveryoption; } - // Remove full-stop at the end, if it exists, to avoid "..pdf" being created and being filtered by clean_filename. - $filename = rtrim(format_string($this->name, true, ['context' => $this->get_context()]), '.'); + if ($customcert->keeplocalcopy) { + $retval = $this->localfile->send_pdf($userid, $deliveryoption, $return); + if ($return && !empty($retval)) { + return $retval; + } + } $pdf->setPrintHeader(false); $pdf->setPrintFooter(false); - $pdf->SetTitle($filename); $pdf->SetAutoPageBreak(true, 0); + // Get filename pattern from global settings. + if (empty($customcert->usecustomfilename) || empty($customcert->customfilenamepattern)) { + // 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 { + // 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, + ]); + + if ($issue && !empty($issue->timecreated)) { + $issuedate = date('Y-m-d', $issue->timecreated); + } else { + $issuedate = date('Y-m-d'); + } + + $course = $DB->get_record('course', ['id' => $customcert->course]); + + $values = [ + '{FIRST_NAME}' => $user->firstname ?? '', + '{LAST_NAME}' => $user->lastname ?? '', + '{COURSE_SHORT_NAME}' => $course ? $course->shortname : '', + '{COURSE_FULL_NAME}' => $course ? $course->fullname : '', + '{ISSUE_DATE}' => $issuedate, + ]; + + // Handle group if needed. + $groups = groups_get_all_groups($course->id, $user->id); + if (!empty($groups)) { + $groupnames = array_map(function($g) { + return $g->name; + }, $groups); + $values['{GROUP_NAME}'] = implode(', ', $groupnames); + } else { + $values['{GROUP_NAME}'] = ''; + } + + // Replace placeholders with actual values. + $filename = strtr($customcert->customfilenamepattern, $values); + + // Remove trailing dot to avoid "..pdf" issues. + $filename = rtrim($filename, '.'); + } + // This is the logic the TCPDF library uses when processing the name. This makes names // such as 'الشهادة' become empty, so set a default name in these cases. $filename = preg_replace('/[\s]+/', '_', $filename); $filename = preg_replace('/[^a-zA-Z0-9_\.-]/', '', $filename); + // If filename ends up empty (e.g. after removing unsupported characters), use default string. if (empty($filename)) { $filename = get_string('certificate', 'customcert'); } + // Remove existing ".pdf" extension if present to avoid duplication. + $filename = preg_replace('/\.pdf$/i', '', $filename); + + // Clean the final filename and append ".pdf". $filename = clean_filename($filename . '.pdf'); + + // Set the PDF document title (for metadata, not the filename itself). + $pdf->SetTitle($filename); + // Loop through the pages and display their content. foreach ($pages as $page) { // Add the page to the PDF. @@ -362,6 +425,10 @@ public function generate_pdf(bool $preview = false, ?int $userid = null, bool $r } } + if ($customcert->keeplocalcopy) { + $this->localfile->save_pdf($pdf->Output('', 'S'), $userid); + } + if ($return) { return $pdf->Output('', 'S'); } diff --git a/db/access.php b/db/access.php index 51966b02..b2f986fc 100644 --- a/db/access.php +++ b/db/access.php @@ -104,6 +104,26 @@ ], ], + 'mod/customcert:managekeeplocalcopy' => [ + 'captype' => 'write', + 'contextlevel' => CONTEXT_COURSE, + 'archetypes' => array( + 'editingteacher' => CAP_ALLOW, + 'manager' => CAP_ALLOW + ), + 'clonepermissionsfrom' => 'moodle/course:manageactivities' + ], + + 'mod/customcert:deletelocalcopy' => [ + 'captype' => 'write', + 'contextlevel' => CONTEXT_COURSE, + 'archetypes' => array( + 'editingteacher' => CAP_ALLOW, + 'manager' => CAP_ALLOW + ), + 'clonepermissionsfrom' => 'moodle/course:manageactivities' + ], + 'mod/customcert:manageemailstudents' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_COURSE, diff --git a/db/install.xml b/db/install.xml index ab6106a0..5517675d 100644 --- a/db/install.xml +++ b/db/install.xml @@ -15,6 +15,8 @@ + + @@ -22,6 +24,7 @@ + diff --git a/db/subplugins.json b/db/subplugins.json index 7ce4aca7..0ae5e5c5 100644 --- a/db/subplugins.json +++ b/db/subplugins.json @@ -1,5 +1,9 @@ { + "subplugintypes": { + "customcertelement": "element" + }, "plugintypes": { - "customcertelement": "mod\/customcert\/element" + "customcertelement": "mod/customcert/element" } } + diff --git a/db/upgrade.php b/db/upgrade.php index f0cf6a77..186ed062 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -316,5 +316,35 @@ function xmldb_customcert_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2024042210, 'mod', 'customcert'); } + if ($oldversion < 2025041401) { + $table = new xmldb_table('customcert'); + + // Add 'usecustomfilename' field. + $field = new xmldb_field('usecustomfilename', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '0', 'deliveryoption'); + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Add 'customfilenamepattern' field. + $field = new xmldb_field('customfilenamepattern', XMLDB_TYPE_TEXT, null, null, null, null, null, 'usecustomfilename'); + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Savepoint reached. + upgrade_mod_savepoint(true, 2025041401, 'customcert'); + } + + if ($oldversion < 2025041402) { + $table = new xmldb_table('customcert'); + $field = new xmldb_field('keeplocalcopy', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '0', 'language'); + + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + upgrade_mod_savepoint(true, 2025041402, 'customcert'); + } + return true; } diff --git a/element/qrcode/classes/element.php b/element/qrcode/classes/element.php index 4cd88977..8acbe21f 100644 --- a/element/qrcode/classes/element.php +++ b/element/qrcode/classes/element.php @@ -177,13 +177,21 @@ public function render($pdf, $preview, $user) { $qrcodeurl = $qrcodeurl->out(false); } - $barcode = new \TCPDF2DBarcode($qrcodeurl, self::BARCODETYPE); - $image = $barcode->getBarcodePngData($imageinfo->width, $imageinfo->height); + try { + $barcode = new \TCPDF2DBarcode($qrcodeurl, self::BARCODETYPE); + $image = $barcode->getBarcodePngData($imageinfo->width, $imageinfo->height); - $location = make_request_directory() . '/target'; - file_put_contents($location, $image); + $location = make_request_directory() . '/target'; + file_put_contents($location, $image); - $pdf->Image($location, $this->get_posx(), $this->get_posy(), $imageinfo->width, $imageinfo->height); + $pdf->Image($location, $this->get_posx(), $this->get_posy(), $imageinfo->width, $imageinfo->height); + } catch (\Throwable $e) { + if (!defined('PHPUNIT_TEST') && !defined('BEHAT_SITE_RUNNING')) { + debugging('QR code render failed: ' . $e->getMessage(), DEBUG_DEVELOPER); + } + + return; + } } /** @@ -205,7 +213,15 @@ public function render_html() { $qrcodeurl = new \moodle_url('/'); $qrcodeurl = $qrcodeurl->out(false); - $barcode = new \TCPDF2DBarcode($qrcodeurl, self::BARCODETYPE); - return $barcode->getBarcodeHTML($imageinfo->width / 10, $imageinfo->height / 10); + try { + $barcode = new \TCPDF2DBarcode($qrcodeurl, self::BARCODETYPE); + return $barcode->getBarcodeHTML($imageinfo->width / 10, $imageinfo->height / 10); + } catch (\Throwable $e) { + if (!defined('PHPUNIT_TEST') && !defined('BEHAT_SITE_RUNNING')) { + debugging('QR code render failed: ' . $e->getMessage(), DEBUG_DEVELOPER); + } + + return ''; + } } } diff --git a/lang/en/customcert.php b/lang/en/customcert.php index 2e9a5bef..00e5b2e8 100644 --- a/lang/en/customcert.php +++ b/lang/en/customcert.php @@ -38,6 +38,10 @@ $string['certificatesperrun'] = 'Certificates per run'; $string['certificatesperrun_desc'] = 'Enter the number of certificates to process per scheduled task run where 0 means it will process all certificates.'; $string['code'] = 'Code'; +$string['codegenerationmethod'] = 'Code generation method'; +$string['codegenerationmethod_desc'] = 'Choose between the two methods for generating certificate codes.'; +$string['codegenerationmethod_digitshyphens'] = '0123-4567-8901 (Digits with hyphens)'; +$string['codegenerationmethod_upperlowerdigits'] = '6aOdbLEuoC (Upper/lower/digits random string)'; $string['copy'] = 'Copy'; $string['coursetimereq'] = 'Required minutes in course'; $string['coursetimereq_help'] = 'Enter here the minimum amount of time, in minutes, that a student must be logged into the course before they will be able to receive @@ -45,6 +49,8 @@ $string['createtemplate'] = 'Create template'; $string['customcert:addinstance'] = 'Add a new custom certificate instance'; $string['customcert:manage'] = 'Manage a custom certificate'; +$string['customcert:managekeeplocalcopy'] = 'Manage keep local certificate copy setting'; +$string['customcert:deletelocalcopy'] = 'Delete local certificate copies'; $string['customcert:manageemailothers'] = 'Manage email others setting'; $string['customcert:manageemailstudents'] = 'Manage email students setting'; $string['customcert:manageemailteachers'] = 'Manage email teachers setting'; @@ -59,11 +65,14 @@ $string['customcert:viewallcertificates'] = 'View all certificates'; $string['customcert:viewreport'] = 'View course report'; $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['deletecertpage'] = 'Delete page'; $string['deleteconfirm'] = 'Delete confirmation'; $string['deleteelement'] = 'Delete element'; $string['deleteelementconfirm'] = 'Are you sure you want to delete this element?'; $string['deleteissueconfirm'] = 'Are you sure you want to delete this certificate issue?'; +$string['deletelocalcopyconfirm'] = 'Are you sure you want to delete this certificate PDF file?'; $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?'; @@ -104,6 +113,10 @@ $string['emailstudentgreeting'] = 'Dear {$a}'; $string['emailstudents'] = 'Email students'; $string['emailstudents_help'] = 'If set this will email the students a copy of the certificate when it becomes available. Warning: Setting this to \'Yes\' before you have finished creating the certificate will email the student an incomplete certificate.'; +$string['keeplocalcopy'] = 'Keep local certificate copy'; +$string['keeplocalcopy_help'] = 'If set this will keep a local copy of the certificate PDF. The copy will be served on all future certificate downloads until the file is deleted from the moodle files table.'; +$string['deletelocalcopy'] = 'Delete local certificate copy'; +$string['bulkdownloadlink'] = 'Download certificates'; $string['emailstudentsubject'] = '{$a->coursefullname}: {$a->certificatename}'; $string['emailteachers'] = 'Email teachers'; $string['emailteachers_help'] = 'If set this will email the teachers a copy of the certificate when it becomes available. Warning: Setting this to \'Yes\' before you have finished creating the certificate will email the teacher an incomplete certificate.'; @@ -197,6 +210,9 @@ $string['refpoint_help'] = 'The reference point is the location of an element from which its x and y coordinates are determined. It is indicated by the \'+\' that appears in the centre or corners of the element.'; $string['replacetemplate'] = 'Replace'; $string['requiredtimenotmet'] = 'You must spend at least a minimum of {$a->requiredtime} minutes in the course before you can access this certificate.'; +$string['returncourse'] = 'Return to course button'; +$string['returncourse_desc'] = 'Display a "Return to course" button on the view certificate page.'; +$string['returncourselabel'] = 'Return to course page'; $string['rightmargin'] = 'Right margin'; $string['rightmargin_help'] = 'This is the right margin of the certificate PDF in mm.'; $string['save'] = 'Save'; @@ -227,10 +243,12 @@ $string['uploadimage'] = 'Upload image'; $string['uploadimagedesc'] = 'This link will take you to a new screen where you will be able to upload images. Images uploaded using this method will be available throughout your site to all users who are able to create a certificate.'; -$string['useadhoc'] = 'Use Email Certificate Ad-hoc Task'; +$string['useadhoc'] = 'Use email certificate ad-hoc task'; $string['useadhoc_desc'] = 'When enabled, emails related to certificates will be handled immediately through an ad-hoc task created for each issue. When disabled, emails will be managed by the regular scheduled task. 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['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'; @@ -245,7 +263,3 @@ $string['verifycertificatedesc'] = 'This link will take you to a new screen where you will be able to verify certificates on the site'; $string['width'] = 'Width'; $string['width_help'] = 'This is the width of the certificate PDF in mm. For reference an A4 piece of paper is 210mm wide and a letter is 216mm wide.'; -$string['codegenerationmethod'] = 'Code generation method'; -$string['codegenerationmethod_desc'] = 'Choose between the two methods for generating certificate codes.'; -$string['codegenerationmethod_upperlowerdigits'] = '6aOdbLEuoC (Upper/lower/digits random string)'; -$string['codegenerationmethod_digitshyphens'] = '0123-4567-8901 (Digits with hyphens)'; diff --git a/lang/it/customcert.php b/lang/it/customcert.php new file mode 100644 index 00000000..0a8753e5 --- /dev/null +++ b/lang/it/customcert.php @@ -0,0 +1,31 @@ +. + +/** + * Language strings for the customcert module. + * + * @package mod_customcert + * @copyright 2013 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + $string['keeplocalcopy'] = 'Mantieni copia dei certificati'; + $string['keeplocalcopy_help'] = 'Se impostato a Sì, mantiene una copia del PDF dei certificati. La copia sarà scaricata ad ogni futura richiesta del certificato, finché il file non è cancellato dalla tabella files di moodle.'; + $string['deletelocalcopy'] = 'Cancella copia locale del certificato'; + $string['bulkdownloadlink'] = 'Download certificati'; + $string['deletelocalcopyconfirm'] = 'Sei sicuro di voler cancellare il file PDF del certificato?'; + $string['customcert:managekeeplocalcopy'] = 'Gestire impostazioni copia locale certificati'; + $string['customcert:deletelocalcopy'] = 'Cancella copia locale dei certificati'; diff --git a/lib.php b/lib.php index 69afda51..1f27a126 100644 --- a/lib.php +++ b/lib.php @@ -441,6 +441,7 @@ function mod_customcert_inplace_editable($itemtype, $itemid, $newvalue) { */ function mod_customcert_get_fontawesome_icon_map() { return [ + 'mod_customcert:deletelocalcopy' => 'fa-minus-square', 'mod_customcert:download' => 'fa-download', ]; } diff --git a/mod_form.php b/mod_form.php index fb284a14..9e7a02b8 100644 --- a/mod_form.php +++ b/mod_form.php @@ -66,6 +66,24 @@ public function definition() { $mform->addElement('select', 'deliveryoption', get_string('deliveryoptions', 'customcert'), $deliveryoptions); $mform->setDefault('deliveryoption', certificate::DELIVERY_OPTION_INLINE); + // Checkbox to enable custom file name pattern. + $mform->addElement('advcheckbox', 'usecustomfilename', get_string('usecustomfilename', 'customcert')); + $mform->addHelpButton('usecustomfilename', 'usecustomfilename', 'customcert'); + $mform->setDefault('usecustomfilename', 0); + + // Text field for custom file name pattern. + $mform->addElement('text', 'customfilenamepattern', get_string('customfilenamepattern', 'customcert'), ['size' => '50']); + $mform->setType('customfilenamepattern', PARAM_TEXT); + $mform->addHelpButton('customfilenamepattern', 'customfilenamepattern', 'customcert'); + $mform->disabledIf('customfilenamepattern', 'usecustomfilename', 'notchecked'); + + if (has_capability('mod/customcert:managekeeplocalcopy', $this->get_context())) { + $mform->addElement('selectyesno', 'keeplocalcopy', get_string('keeplocalcopy', 'customcert')); + $mform->setDefault('keeplocalcopy', get_config('customcert', 'keeplocalcopy')); + $mform->addHelpButton('keeplocalcopy', 'keeplocalcopy', 'customcert'); + $mform->setType('keeplocalcopy', PARAM_INT); + } + if (has_capability('mod/customcert:manageemailstudents', $this->get_context())) { $mform->addElement('selectyesno', 'emailstudents', get_string('emailstudents', 'customcert')); $mform->setDefault('emailstudents', get_config('customcert', 'emailstudents')); @@ -207,6 +225,7 @@ public function validation($data, $files) { */ protected function get_options_elements_with_required_caps() { return [ + 'keeplocalcopy' => 'mod/customcert:managekeeplocalcopy', 'emailstudents' => 'mod/customcert:manageemailstudents', 'emailteachers' => 'mod/customcert:manageemailteachers', 'emailothers' => 'mod/customcert:manageemailothers', diff --git a/settings.php b/settings.php index 27a34974..74e6c745 100644 --- a/settings.php +++ b/settings.php @@ -78,6 +78,11 @@ get_string('certificateexecutionperiod', 'customcert'), get_string('certificateexecutionperiod_desc', 'customcert'), 365 * DAYSECS)); +$settings->add(new admin_setting_configcheckbox('customcert/returncourse', + get_string('returncourse', 'customcert'), + get_string('returncourse_desc', 'customcert'), + 0)); + $settings->add(new admin_setting_heading('defaults', get_string('modeditdefaults', 'admin'), get_string('condifmodeditdefaults', 'admin'))); @@ -88,7 +93,7 @@ 0, // Default option (0 = Upper/lower/digits random string method). [ 0 => get_string('codegenerationmethod_upperlowerdigits', 'customcert'), // Upper/lower/digits random string. - 1 => get_string('codegenerationmethod_digitshyphens', 'customcert') // Digits with hyphens numeric code. + 1 => get_string('codegenerationmethod_digitshyphens', 'customcert'), // Digits with hyphens numeric code. ] )); @@ -96,6 +101,9 @@ 0 => get_string('no'), 1 => get_string('yes'), ]; + +$settings->add(new admin_setting_configselect('customcert/keeplocalcopy', + get_string('keeplocalcopy', 'customcert'), get_string('keeplocalcopy_help', 'customcert'), 0, $yesnooptions)); $settings->add(new admin_setting_configselect('customcert/emailstudents', get_string('emailstudents', 'customcert'), get_string('emailstudents_help', 'customcert'), 0, $yesnooptions)); $settings->add(new admin_setting_configselect('customcert/emailteachers', diff --git a/tests/email_certificate_task_test.php b/tests/email_certificate_task_test.php index 98ea5719..6432c219 100644 --- a/tests/email_certificate_task_test.php +++ b/tests/email_certificate_task_test.php @@ -26,6 +26,7 @@ namespace mod_customcert; use completion_info; +use context_module; use stdClass; use context_course; use advanced_testcase; @@ -223,6 +224,85 @@ public function test_email_certificates_students(): void { $this->assertCount(0, $emails); } + /** + * Tests the email certificate task for instance on the site home course. + * + * @covers \mod_customcert\task\issue_certificates_task + * @covers \mod_customcert\task\email_certificate_task + */ + public function test_email_certificates_sitehome(): void { + global $CFG, $DB, $SITE; + + // Create some users. + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + + // Create a custom certificate. + $customcert = $this->getDataGenerator()->create_module('customcert', ['course' => $SITE->id, + 'emailstudents' => 1]); + + $role = $DB->get_record('role', ['archetype' => 'user']); + role_change_permission($role->id, context_module::instance($customcert->cmid), 'mod/customcert:view', CAP_ALLOW); + role_change_permission($role->id, context_module::instance($customcert->cmid), 'mod/customcert:receiveissue', CAP_ALLOW); + + // Create template object. + $template = new stdClass(); + $template->id = $customcert->templateid; + $template->name = 'A template'; + $template->contextid = context_course::instance($SITE->id)->id; + $template = new template($template); + + // Add a page to this template. + $pageid = $template->add_page(); + + // Add an element to the page. + $element = new stdClass(); + $element->pageid = $pageid; + $element->name = 'Image'; + $DB->insert_record('customcert_elements', $element); + + // Ok, now issue this to one user. + \mod_customcert\certificate::issue_certificate($customcert->id, $user1->id); + + // Confirm there is only one entry in this table. + $this->assertEquals(1, $DB->count_records('customcert_issues')); + + // Run the task. + $sink = $this->redirectEmails(); + $task = new issue_certificates_task(); + $task->execute(); + $emails = $sink->get_messages(); + + // Get the issues from the issues table now. + $issues = $DB->get_records('customcert_issues'); + $this->assertCount(3, $issues); + + // Confirm that it was marked as emailed and was not issued to the teacher. + foreach ($issues as $issue) { + $this->assertEquals(1, $issue->emailed); + } + + // Confirm that we sent out emails to the two users. + $this->assertCount(3, $emails); + + $this->assertEquals($CFG->noreplyaddress, $emails[1]->from); + $this->assertEquals($user1->email, $emails[1]->to); + + $this->assertEquals($CFG->noreplyaddress, $emails[2]->from); + $this->assertEquals($user2->email, $emails[2]->to); + + // Now, run the task again and ensure we did not issue any more certificates. + $sink = $this->redirectEmails(); + $task = new issue_certificates_task(); + $task->execute(); + $emails = $sink->get_messages(); + + $issues = $DB->get_records('customcert_issues'); + + $this->assertCount(3, $issues); + $this->assertCount(0, $emails); + } + /** * Tests the email certificate task for teachers. * diff --git a/version.php b/version.php index 365a99d2..b595f0dc 100644 --- a/version.php +++ b/version.php @@ -24,10 +24,10 @@ defined('MOODLE_INTERNAL') || die('Direct access to this script is forbidden.'); -$plugin->version = 2024042212; // The current module version (Date: YYYYMMDDXX). -$plugin->requires = 2024042200; // Requires this Moodle version (4.4). +$plugin->version = 2025041402; // The current module version (Date: YYYYMMDDXX). +$plugin->requires = 2025041400; // Requires this Moodle version (5.0). $plugin->cron = 0; // Period for cron to check this module (secs). $plugin->component = 'mod_customcert'; $plugin->maturity = MATURITY_STABLE; -$plugin->release = "4.4.6"; // User-friendly version number. +$plugin->release = "5.0.0"; // User-friendly version number. diff --git a/view.php b/view.php index f6d76551..97a9ff77 100644 --- a/view.php +++ b/view.php @@ -30,6 +30,7 @@ $downloadtable = optional_param('download', null, PARAM_ALPHA); $downloadissue = optional_param('downloadissue', 0, PARAM_INT); $deleteissue = optional_param('deleteissue', 0, PARAM_INT); +$deletelocalcopy = optional_param('deletelocalcopy', 0, PARAM_INT); $confirm = optional_param('confirm', false, PARAM_BOOL); $page = optional_param('page', 0, PARAM_INT); $perpage = optional_param('perpage', \mod_customcert\certificate::CUSTOMCERT_PER_PAGE, PARAM_INT); @@ -47,6 +48,7 @@ $canreceive = has_capability('mod/customcert:receiveissue', $context); $canmanage = has_capability('mod/customcert:manage', $context); $canviewreport = has_capability('mod/customcert:viewreport', $context); +$candeletelocalcopy = has_capability('mod/customcert:deletelocalcopy', $context); // Initialise $PAGE. $pageurl = new moodle_url('/mod/customcert/view.php', ['id' => $cm->id]); @@ -67,18 +69,25 @@ if ($deleteissue && $canmanage && confirm_sesskey()) { if (!$confirm) { $nourl = new moodle_url('/mod/customcert/view.php', ['id' => $id]); - $yesurl = new moodle_url('/mod/customcert/view.php', + $yesurl = new moodle_url( + '/mod/customcert/view.php', [ 'id' => $id, 'deleteissue' => $deleteissue, + 'deletelocalcopy' => $deletelocalcopy, 'confirm' => 1, 'sesskey' => sesskey(), ] ); // Show a confirmation page. - $PAGE->navbar->add(get_string('deleteconfirm', 'customcert')); - $message = get_string('deleteissueconfirm', 'customcert'); + if ($deletelocalcopy) { + $PAGE->navbar->add(get_string('deleteconfirm', 'customcert')); + $message = get_string('deletelocalcopyconfirm', 'customcert'); + } else if ($deleteissue) { + $PAGE->navbar->add(get_string('deleteconfirm', 'customcert')); + $message = get_string('deleteissueconfirm', 'customcert'); + } echo $OUTPUT->header(); echo $OUTPUT->heading(format_string($customcert->name)); echo $OUTPUT->confirm($message, $yesurl, $nourl); @@ -86,19 +95,30 @@ exit(); } - // Delete the issue. - $issue = $DB->get_record('customcert_issues', ['id' => $deleteissue, 'customcertid' => $customcert->id], '*', MUST_EXIST); - $DB->delete_records('customcert_issues', ['id' => $deleteissue, 'customcertid' => $customcert->id]); + // Always delete local copy. + if ($candeletelocalcopy) { + $issues = $DB->get_records('customcert_issues', ['id' => $deleteissue, 'customcertid' => $customcert->id]); + if (!empty($issues)) { + $lf = new \mod_customcert\localfile(new \mod_customcert\template($template)); + array_map(fn($issue) => $lf->delete_pdf($issue->userid), $issues); + } + } - // Trigger event. - $cm = get_coursemodule_from_instance('customcert', $customcert->id, 0, false, MUST_EXIST); - $context = \context_module::instance($cm->id); - $event = \mod_customcert\event\issue_deleted::create([ - 'objectid' => $issue->id, - 'context' => $context, - 'relateduserid' => $issue->userid, - ]); - $event->trigger(); + if (!$deletelocalcopy) { + // Delete the issue. + $issue = $DB->get_record('customcert_issues', ['id' => $deleteissue, 'customcertid' => $customcert->id], '*', MUST_EXIST); + $DB->delete_records('customcert_issues', ['id' => $deleteissue, 'customcertid' => $customcert->id]); + + // Trigger event. + $cm = get_coursemodule_from_instance('customcert', $customcert->id, 0, false, MUST_EXIST); + $context = \context_module::instance($cm->id); + $event = \mod_customcert\event\issue_deleted::create([ + 'objectid' => $issue->id, + 'context' => $context, + 'relateduserid' => $issue->userid, + ]); + $event->trigger(); + } // Redirect back to the manage templates page. redirect(new moodle_url('/mod/customcert/view.php', ['id' => $id])); @@ -157,12 +177,19 @@ // Create the button to download the customcert. $downloadbutton = ''; + $renderbuttoncourse = ''; + $displayreturnbutton = get_config('customcert', 'returncourse'); if ($canreceive) { $linkname = get_string('getcustomcert', 'customcert'); $link = new moodle_url('/mod/customcert/view.php', ['id' => $cm->id, 'downloadown' => true]); $downloadbutton = new single_button($link, $linkname, 'get', single_button::BUTTON_PRIMARY); $downloadbutton->class .= ' m-b-1'; // Seems a bit hackish, ahem. $downloadbutton = $OUTPUT->render($downloadbutton); + if ($displayreturnbutton) { + $url = new moodle_url('/course/view.php', ['id' => $course->id]); + $buttonreturntocourse = new single_button($url, get_string('returncourselabel', 'customcert'), 'get'); + $renderbuttoncourse = $OUTPUT->render($buttonreturntocourse); + } } $numissues = \mod_customcert\certificate::get_number_of_issues($customcert->id, $cm, $groupmode); @@ -170,7 +197,8 @@ $downloadallbutton = ''; if ($canviewreport && $numissues > 0) { $linkname = get_string('downloadallissuedcertificates', 'customcert'); - $link = new moodle_url('/mod/customcert/view.php', + $link = new moodle_url( + '/mod/customcert/view.php', [ 'id' => $cm->id, 'downloadall' => true, @@ -187,6 +215,9 @@ echo $issuehtml; echo $downloadbutton; echo $downloadallbutton; + if ($displayreturnbutton) { + echo $renderbuttoncourse; + } if (isset($reporttable)) { echo $OUTPUT->heading(get_string('listofissues', 'customcert', $numissues), 3); groups_print_activity_menu($cm, $pageurl);