From 21c5e3f82edd41aed5a92d184708092524d3acbc Mon Sep 17 00:00:00 2001 From: Justus Dieckmann Date: Tue, 20 Feb 2024 16:05:52 +0100 Subject: [PATCH 1/8] Check backup consistency before importing workflow --- .../backup/restore_lifecycle_workflow.php | 34 +++++++++++++++++++ lang/en/tool_lifecycle.php | 2 ++ step/lib.php | 10 ++++++ .../lang/de/lifecycletrigger_categories.php | 1 + .../lang/en/lifecycletrigger_categories.php | 1 + trigger/categories/lib.php | 18 ++++++++++ trigger/lib.php | 9 +++++ 7 files changed, 75 insertions(+) diff --git a/classes/local/backup/restore_lifecycle_workflow.php b/classes/local/backup/restore_lifecycle_workflow.php index 4fdf1640..36fb71a1 100644 --- a/classes/local/backup/restore_lifecycle_workflow.php +++ b/classes/local/backup/restore_lifecycle_workflow.php @@ -25,6 +25,7 @@ use tool_lifecycle\local\entity\step_subplugin; use tool_lifecycle\local\entity\trigger_subplugin; use tool_lifecycle\local\entity\workflow; +use tool_lifecycle\local\manager\lib_manager; use tool_lifecycle\local\manager\workflow_manager; use tool_lifecycle\local\manager\step_manager; use tool_lifecycle\local\manager\trigger_manager; @@ -76,6 +77,7 @@ public function execute() { // If the workflow could be loaded continue with the subplugins. if ($this->workflow) { $this->load_subplugins(); + $this->check_subplugin_validity(); // Validate the subplugin data. if (empty($this->errors) && $this->all_subplugins_installed()) { // If all loaded data is valid, the new workflow and the steps can be stored in the database. @@ -174,6 +176,38 @@ private function all_subplugins_installed() { return true; } + private function check_subplugin_validity() { + foreach ($this->steps as $step) { + $steplib = lib_manager::get_step_lib($step->subpluginname); + $filteredsettings = []; + foreach ($this->settings as $setting) { + if ($setting->pluginid === $step->id) { + $filteredsettings[$setting->name] = $setting->value; + } + } + $errors = array_map( + fn($x) => get_string('restore_error_in_step', 'tool_lifecycle', $step->instancename) . $x, + $steplib->ensure_validity($filteredsettings) + ); + $this->errors = array_merge($this->errors, $errors); + } + + foreach ($this->trigger as $trigger) { + $steplib = lib_manager::get_trigger_lib($trigger->subpluginname); + $filteredsettings = []; + foreach ($this->settings as $setting) { + if ($setting->pluginid === $trigger->id) { + $filteredsettings[$setting->name] = $setting->value; + } + } + $errors = array_map( + fn($x) => get_string('restore_error_in_trigger', 'tool_lifecycle', $trigger->instancename) . $x, + $steplib->ensure_validity($filteredsettings) + ); + $this->errors = array_merge($this->errors, $errors); + } + } + /** * Stores all loaded data in the database. * @throws \moodle_exception diff --git a/lang/en/tool_lifecycle.php b/lang/en/tool_lifecycle.php index c88413f3..533c4a4c 100644 --- a/lang/en/tool_lifecycle.php +++ b/lang/en/tool_lifecycle.php @@ -188,6 +188,8 @@ $string['restore_subplugins_invalid'] = 'Wrong format of the backup file. The structure of the subplugin elements is not as expected.'; $string['restore_step_does_not_exist'] = 'The step {$a} is not installed, but is included in the backup file. Please installed it first and try again.'; $string['restore_trigger_does_not_exist'] = 'The trigger {$a} is not installed, but is included in the backup file. Please installed it first and try again.'; +$string['restore_error_in_step'] = 'An error occurred when importing step "{$a}": '; +$string['restore_error_in_trigger'] = 'An error occurred when importing trigger "{$a}": '; // Events. $string['process_triggered_event'] = 'A process has been triggered'; diff --git a/step/lib.php b/step/lib.php index 86c8345c..20754d9b 100644 --- a/step/lib.php +++ b/step/lib.php @@ -142,6 +142,16 @@ public function extend_add_instance_form_definition_after_data($mform, $settings public function abort_course($process) { } + + /** + * Ensure validity of settings upon backup restoration. + * @param array $settings + * @return array List of errors with settings. If empty, the given settings are valid. + */ + public function ensure_validity(array $settings) : array { + return []; + } + } /** diff --git a/trigger/categories/lang/de/lifecycletrigger_categories.php b/trigger/categories/lang/de/lifecycletrigger_categories.php index fcec1378..926e9d88 100644 --- a/trigger/categories/lang/de/lifecycletrigger_categories.php +++ b/trigger/categories/lang/de/lifecycletrigger_categories.php @@ -28,3 +28,4 @@ $string['categories'] = 'Kategorien, für die der Workflow ausgelöst werden soll.'; $string['categories_noselection'] = 'Bitte wählen sie mindestens eine Kategorie aus.'; $string['exclude'] = 'Falls ausgewählt, werden gerade die Kurse der angegebenen Kategorien nicht ausgelöst.'; +$string['category_does_not_exist'] = 'Es gibt keine Kurskategorie mit der ID {$a}.'; diff --git a/trigger/categories/lang/en/lifecycletrigger_categories.php b/trigger/categories/lang/en/lifecycletrigger_categories.php index 423bd48e..7b1f9b3a 100644 --- a/trigger/categories/lang/en/lifecycletrigger_categories.php +++ b/trigger/categories/lang/en/lifecycletrigger_categories.php @@ -28,3 +28,4 @@ $string['categories'] = 'Categories, for which the workflow should be triggered'; $string['categories_noselection'] = 'Please choose at least one category.'; $string['exclude'] = 'If ticked, the named categories are excluded from triggering instead.'; +$string['category_does_not_exist'] = 'There is no course category with id {$a}.'; diff --git a/trigger/categories/lib.php b/trigger/categories/lib.php index 0ace8460..43b5f15e 100644 --- a/trigger/categories/lib.php +++ b/trigger/categories/lib.php @@ -131,4 +131,22 @@ public function extend_add_instance_form_definition($mform) { $mform->setType('exclude', PARAM_BOOL); } + /** + * Ensure validity of settings upon backup restoration. + * @param array $settings + * @return array List of errors with settings. If empty, the given settings are valid. + */ + public function ensure_validity(array $settings): array { + $errors = []; + $categories = explode(',', $settings['categories']); + // Use core_course_category for moodle 3.6 and higher. + $categoryobjects = \core_course_category::get_many($categories); + foreach ($categories as $category) { + if (!$categoryobjects[$category]) { + $errors[] = get_string('category_does_not_exist', 'lifecycletrigger_categories', $category); + } + } + return $errors; + } + } diff --git a/trigger/lib.php b/trigger/lib.php index 312c967c..c597c392 100644 --- a/trigger/lib.php +++ b/trigger/lib.php @@ -115,6 +115,15 @@ public function get_status_message() { return get_string("workflow_started", "tool_lifecycle"); } + /** + * Ensure validity of settings upon backup restoration. + * @param array $settings + * @return array List of errors with settings. If empty, the given settings are valid. + */ + public function ensure_validity(array $settings) : array { + return []; + } + } /** From 62d75bb12adaa5f037b682632ded8c5c53592b32 Mon Sep 17 00:00:00 2001 From: Justus Dieckmann Date: Tue, 20 Feb 2024 16:55:40 +0100 Subject: [PATCH 2/8] Fix PHPDocs --- classes/local/backup/restore_lifecycle_workflow.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/classes/local/backup/restore_lifecycle_workflow.php b/classes/local/backup/restore_lifecycle_workflow.php index 36fb71a1..ceb22fe8 100644 --- a/classes/local/backup/restore_lifecycle_workflow.php +++ b/classes/local/backup/restore_lifecycle_workflow.php @@ -176,6 +176,9 @@ private function all_subplugins_installed() { return true; } + /** + * Calls the subplugins to check the consistency and validity of the step and trigger settings. + */ private function check_subplugin_validity() { foreach ($this->steps as $step) { $steplib = lib_manager::get_step_lib($step->subpluginname); From 178b6617f5979b18dd64de7af06fe4fc587713ff Mon Sep 17 00:00:00 2001 From: Justus Dieckmann Date: Wed, 21 Feb 2024 12:26:16 +0100 Subject: [PATCH 3/8] Use existing category in backup_and_restore test --- tests/backup_and_restore_workflow_test.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/backup_and_restore_workflow_test.php b/tests/backup_and_restore_workflow_test.php index 2472092b..97e3a6b9 100644 --- a/tests/backup_and_restore_workflow_test.php +++ b/tests/backup_and_restore_workflow_test.php @@ -28,6 +28,7 @@ require_once(__DIR__ . '/generator/lib.php'); require_once(__DIR__ . '/../lib.php'); +use mod_bigbluebuttonbn\settings; use tool_lifecycle\local\backup\backup_lifecycle_workflow; use tool_lifecycle\local\backup\restore_lifecycle_workflow; use tool_lifecycle\local\manager\workflow_manager; @@ -60,6 +61,14 @@ public function setUp() : void { $this->resetAfterTest(true); $generator = $this->getDataGenerator()->get_plugin_generator('tool_lifecycle'); $this->workflow = $generator->create_workflow(['startdatedelay', 'categories'], ['email', 'createbackup', 'deletecourse']); + $category = $this->getDataGenerator()->create_category(); + foreach (trigger_manager::get_triggers_for_workflow($this->workflow->id) as $trigger) { + if ($trigger->subpluginname === 'categories') { + settings_manager::save_setting($trigger->id, settings_type::TRIGGER, 'categories', + 'categories', $category->id); + } + } + foreach (workflow_manager::get_workflows() as $existingworkflow) { $this->existingworkflows[] = $existingworkflow->id; } From a0e676f155f89fe24514913824cae7fabf83a74c Mon Sep 17 00:00:00 2001 From: Justus Dieckmann Date: Wed, 21 Feb 2024 12:34:01 +0100 Subject: [PATCH 4/8] lifecycletrigger_categories: A bit of general refactoring --- trigger/categories/lib.php | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/trigger/categories/lib.php b/trigger/categories/lib.php index 43b5f15e..97ed716f 100644 --- a/trigger/categories/lib.php +++ b/trigger/categories/lib.php @@ -63,7 +63,7 @@ public function check_course($course, $triggerid) { public function get_course_recordset_where($triggerid) { global $DB, $CFG; $categories = settings_manager::get_settings($triggerid, settings_type::TRIGGER)['categories']; - $exclude = settings_manager::get_settings($triggerid, settings_type::TRIGGER)['exclude'] && true; + $exclude = settings_manager::get_settings($triggerid, settings_type::TRIGGER)['exclude']; $categories = explode(',', $categories); // Use core_course_category for moodle 3.6 and higher. @@ -75,17 +75,14 @@ public function get_course_recordset_where($triggerid) { } $allcategories = []; foreach ($categories as $category) { - array_push($allcategories , $category); + array_push($allcategories, $category); $children = $categoryobjects[$category]->get_all_children_ids(); - $allcategories = array_merge($allcategories , $children); + $allcategories = array_merge($allcategories, $children); } - list($insql, $inparams) = $DB->get_in_or_equal($allcategories, SQL_PARAMS_NAMED); + list($insql, $inparams) = $DB->get_in_or_equal($allcategories, SQL_PARAMS_NAMED, 'param', !$exclude); $where = "{course}.category {$insql}"; - if ($exclude) { - $where = "NOT " . $where; - } return [$where, $inparams]; } @@ -142,7 +139,7 @@ public function ensure_validity(array $settings): array { // Use core_course_category for moodle 3.6 and higher. $categoryobjects = \core_course_category::get_many($categories); foreach ($categories as $category) { - if (!$categoryobjects[$category]) { + if (!isset($categoryobjects[$category])) { $errors[] = get_string('category_does_not_exist', 'lifecycletrigger_categories', $category); } } From 2b548044ac8106bf871b657511e54feaa34de067 Mon Sep 17 00:00:00 2001 From: Justus Dieckmann Date: Wed, 21 Feb 2024 12:34:28 +0100 Subject: [PATCH 5/8] lifecycletrigger_categories: Don't fail if category doesn't exist --- classes/local/backup/restore_lifecycle_workflow.php | 9 +++++---- trigger/categories/lib.php | 5 ++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/classes/local/backup/restore_lifecycle_workflow.php b/classes/local/backup/restore_lifecycle_workflow.php index ceb22fe8..5a74df3d 100644 --- a/classes/local/backup/restore_lifecycle_workflow.php +++ b/classes/local/backup/restore_lifecycle_workflow.php @@ -77,11 +77,13 @@ public function execute() { // If the workflow could be loaded continue with the subplugins. if ($this->workflow) { $this->load_subplugins(); - $this->check_subplugin_validity(); // Validate the subplugin data. if (empty($this->errors) && $this->all_subplugins_installed()) { - // If all loaded data is valid, the new workflow and the steps can be stored in the database. - $this->persist(); + $this->check_subplugin_validity(); + if (empty($this->errors)) { + // If all loaded data is valid, the new workflow and the steps can be stored in the database. + $this->persist(); + } } } return $this->errors; @@ -103,7 +105,6 @@ private function load_workflow() { $this->workflow->timeactive = null; $this->workflow->timedeactive = null; $this->workflow->sortindex = null; - workflow_manager::insert_or_update($this->workflow); } /** diff --git a/trigger/categories/lib.php b/trigger/categories/lib.php index 97ed716f..7ca38d58 100644 --- a/trigger/categories/lib.php +++ b/trigger/categories/lib.php @@ -76,6 +76,9 @@ public function get_course_recordset_where($triggerid) { $allcategories = []; foreach ($categories as $category) { array_push($allcategories, $category); + if (!isset($categoryobjects[$category]) || !$categoryobjects[$category]) { + continue; + } $children = $categoryobjects[$category]->get_all_children_ids(); $allcategories = array_merge($allcategories, $children); } @@ -139,7 +142,7 @@ public function ensure_validity(array $settings): array { // Use core_course_category for moodle 3.6 and higher. $categoryobjects = \core_course_category::get_many($categories); foreach ($categories as $category) { - if (!isset($categoryobjects[$category])) { + if (!isset($categoryobjects[$category]) || !$categoryobjects[$category]) { $errors[] = get_string('category_does_not_exist', 'lifecycletrigger_categories', $category); } } From 41d23fc123296d62e9658c151dab286e8d51cb07 Mon Sep 17 00:00:00 2001 From: Justus Dieckmann Date: Thu, 22 Feb 2024 15:22:18 +0100 Subject: [PATCH 6/8] Add possibility to force import of errorneous backup --- .../backup/restore_lifecycle_workflow.php | 20 ++++++++++++------- classes/local/form/form_upload_workflow.php | 6 ++++++ lang/en/tool_lifecycle.php | 2 ++ renderer.php | 8 ++------ .../lang/de/lifecycletrigger_categories.php | 2 +- .../lang/en/lifecycletrigger_categories.php | 2 +- trigger/categories/lib.php | 11 ++++++---- uploadworkflow.php | 11 ++++++++-- 8 files changed, 41 insertions(+), 21 deletions(-) diff --git a/classes/local/backup/restore_lifecycle_workflow.php b/classes/local/backup/restore_lifecycle_workflow.php index 5a74df3d..bbd79857 100644 --- a/classes/local/backup/restore_lifecycle_workflow.php +++ b/classes/local/backup/restore_lifecycle_workflow.php @@ -66,24 +66,30 @@ public function __construct($xmldata) { * Executes the restore process. It loads the workflow with all steps and triggers from the xml data. * If all data is valid, it restores the workflow with all subplugins and settings. * Otherwise an array with error strings is returned. + * @param bool $force force import, even if there are errors. * @return string[] Errors, which occurred during the restore process. * @throws \coding_exception * @throws \moodle_exception */ - public function execute() { + public function execute(bool $force) { $this->reader->read(); $this->load_workflow(); // If the workflow could be loaded continue with the subplugins. if ($this->workflow) { $this->load_subplugins(); + + if (!$this->all_subplugins_installed()) { + return $this->errors; + } + // Validate the subplugin data. - if (empty($this->errors) && $this->all_subplugins_installed()) { - $this->check_subplugin_validity(); - if (empty($this->errors)) { - // If all loaded data is valid, the new workflow and the steps can be stored in the database. - $this->persist(); - } + $this->check_subplugin_validity(); + if (empty($this->errors) || $force) { + // If all loaded data is valid, the new workflow and the steps can be stored in the database. + // If we force the import, we empty the errors; + $this->errors = []; + $this->persist(); } } return $this->errors; diff --git a/classes/local/form/form_upload_workflow.php b/classes/local/form/form_upload_workflow.php index 5ccd6aec..ee30d441 100644 --- a/classes/local/form/form_upload_workflow.php +++ b/classes/local/form/form_upload_workflow.php @@ -43,6 +43,12 @@ public function definition() { $mform->addElement('filepicker', 'backupfile', get_string('file'), null, ['accepted_types' => 'xml']); + + $showforce = isset($this->_customdata['showforce']) && $this->_customdata['showforce']; + $mform->addElement($showforce ? 'checkbox' : 'hidden', 'force', get_string('force_import', 'tool_lifecycle')); + $mform->setDefault('force', 0); + $mform->setType('force', PARAM_BOOL); + $this->add_action_buttons('true', get_string('upload')); } diff --git a/lang/en/tool_lifecycle.php b/lang/en/tool_lifecycle.php index 533c4a4c..fb99fb1a 100644 --- a/lang/en/tool_lifecycle.php +++ b/lang/en/tool_lifecycle.php @@ -190,6 +190,8 @@ $string['restore_trigger_does_not_exist'] = 'The trigger {$a} is not installed, but is included in the backup file. Please installed it first and try again.'; $string['restore_error_in_step'] = 'An error occurred when importing step "{$a}": '; $string['restore_error_in_trigger'] = 'An error occurred when importing trigger "{$a}": '; +$string['workflow_was_not_imported'] = 'The workflow was not imported!'; +$string['force_import'] = 'Try ignoring errors and import the workflow anyway. Use this at your own risk!'; // Events. $string['process_triggered_event'] = 'A process has been triggered'; diff --git a/renderer.php b/renderer.php index f9b7eb9b..6d3db9e7 100644 --- a/renderer.php +++ b/renderer.php @@ -53,15 +53,11 @@ public function header($title = null) { /** * Renders the workflow upload form including errors, which occured during upload. * @param \tool_lifecycle\local\form\form_upload_workflow $form - * @param array $errors * @throws coding_exception */ - public function render_workflow_upload_form($form, $errors = []) { + public function render_workflow_upload_form($form) { $this->header(get_string('adminsettings_edit_workflow_definition_heading', 'tool_lifecycle')); - foreach ($errors as $error) { - \core\notification::add($error, \core\notification::ERROR); - } - echo $form->render(); + $form->display(); $this->footer(); } diff --git a/trigger/categories/lang/de/lifecycletrigger_categories.php b/trigger/categories/lang/de/lifecycletrigger_categories.php index 926e9d88..fb3a9e89 100644 --- a/trigger/categories/lang/de/lifecycletrigger_categories.php +++ b/trigger/categories/lang/de/lifecycletrigger_categories.php @@ -28,4 +28,4 @@ $string['categories'] = 'Kategorien, für die der Workflow ausgelöst werden soll.'; $string['categories_noselection'] = 'Bitte wählen sie mindestens eine Kategorie aus.'; $string['exclude'] = 'Falls ausgewählt, werden gerade die Kurse der angegebenen Kategorien nicht ausgelöst.'; -$string['category_does_not_exist'] = 'Es gibt keine Kurskategorie mit der ID {$a}.'; +$string['categories_do_not_exist'] = 'Es gibt keine Kurskategorien mit den folgenden IDs: {$a}.'; diff --git a/trigger/categories/lang/en/lifecycletrigger_categories.php b/trigger/categories/lang/en/lifecycletrigger_categories.php index 7b1f9b3a..d065edb7 100644 --- a/trigger/categories/lang/en/lifecycletrigger_categories.php +++ b/trigger/categories/lang/en/lifecycletrigger_categories.php @@ -28,4 +28,4 @@ $string['categories'] = 'Categories, for which the workflow should be triggered'; $string['categories_noselection'] = 'Please choose at least one category.'; $string['exclude'] = 'If ticked, the named categories are excluded from triggering instead.'; -$string['category_does_not_exist'] = 'There is no course category with id {$a}.'; +$string['categories_do_not_exist'] = 'There are no categories with the following ids: {$a}.'; diff --git a/trigger/categories/lib.php b/trigger/categories/lib.php index 7ca38d58..95a64610 100644 --- a/trigger/categories/lib.php +++ b/trigger/categories/lib.php @@ -137,16 +137,19 @@ public function extend_add_instance_form_definition($mform) { * @return array List of errors with settings. If empty, the given settings are valid. */ public function ensure_validity(array $settings): array { - $errors = []; + $missingcategories = []; $categories = explode(',', $settings['categories']); - // Use core_course_category for moodle 3.6 and higher. $categoryobjects = \core_course_category::get_many($categories); foreach ($categories as $category) { if (!isset($categoryobjects[$category]) || !$categoryobjects[$category]) { - $errors[] = get_string('category_does_not_exist', 'lifecycletrigger_categories', $category); + $missingcategories[] = $category; } } - return $errors; + if ($missingcategories) { + return [get_string('categories_do_not_exist', 'lifecycletrigger_categories', join(', ', $missingcategories))]; + } else { + return []; + } } } diff --git a/uploadworkflow.php b/uploadworkflow.php index 06161e1a..c71b473d 100644 --- a/uploadworkflow.php +++ b/uploadworkflow.php @@ -51,11 +51,18 @@ if ($data = $form->get_data()) { $xmldata = $form->get_file_content('backupfile'); $restore = new restore_lifecycle_workflow($xmldata); - $errors = $restore->execute(); + $force = $data->force ?? false; + $errors = $restore->execute($force); if (count($errors) != 0) { + \core\notification::add(get_string('workflow_was_not_imported', 'tool_lifecycle'), \core\notification::ERROR); + foreach ($errors as $error) { + \core\notification::add($error, \core\notification::ERROR); + } + $form = new form_upload_workflow(null, ['showforce' => true]); + /** @var \tool_lifecycle_renderer $renderer */ $renderer = $PAGE->get_renderer('tool_lifecycle'); - $renderer->render_workflow_upload_form($form, $errors); + $renderer->render_workflow_upload_form($form); die(); } else { // Redirect to workflow page. From 9933487eca470011cb8c5f553f7134771b3d56ad Mon Sep 17 00:00:00 2001 From: Justus Dieckmann <45795270+justusdieckmann@users.noreply.github.com> Date: Thu, 22 Feb 2024 18:48:21 +0100 Subject: [PATCH 7/8] Use $force = false as default in restore_workflow:execute --- classes/local/backup/restore_lifecycle_workflow.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classes/local/backup/restore_lifecycle_workflow.php b/classes/local/backup/restore_lifecycle_workflow.php index bbd79857..5e9b1fe9 100644 --- a/classes/local/backup/restore_lifecycle_workflow.php +++ b/classes/local/backup/restore_lifecycle_workflow.php @@ -71,7 +71,7 @@ public function __construct($xmldata) { * @throws \coding_exception * @throws \moodle_exception */ - public function execute(bool $force) { + public function execute(bool $force = false) { $this->reader->read(); $this->load_workflow(); From ee6b4103fcb2642350db06cf68c622b8644891f3 Mon Sep 17 00:00:00 2001 From: NinaHerrmann Date: Wed, 24 Apr 2024 10:24:22 +0200 Subject: [PATCH 8/8] Display only unique errors when Workflows causes multiple errors --- uploadworkflow.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/uploadworkflow.php b/uploadworkflow.php index c71b473d..351adebb 100644 --- a/uploadworkflow.php +++ b/uploadworkflow.php @@ -22,6 +22,7 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use core\notification; use tool_lifecycle\local\backup\restore_lifecycle_workflow; use tool_lifecycle\local\form\form_upload_workflow; use tool_lifecycle\permission_and_navigation; @@ -54,9 +55,9 @@ $force = $data->force ?? false; $errors = $restore->execute($force); if (count($errors) != 0) { - \core\notification::add(get_string('workflow_was_not_imported', 'tool_lifecycle'), \core\notification::ERROR); - foreach ($errors as $error) { - \core\notification::add($error, \core\notification::ERROR); + notification::add(get_string('workflow_was_not_imported', 'tool_lifecycle'), notification::ERROR); + foreach (array_unique($errors) as $error) { + notification::add($error, notification::ERROR); } $form = new form_upload_workflow(null, ['showforce' => true]);