diff --git a/front/change.form.php b/front/change.form.php index c9540ed7d43..e66e11fe189 100644 --- a/front/change.form.php +++ b/front/change.form.php @@ -53,6 +53,7 @@ if (isset($_POST["add"])) { $change->check(-1, CREATE, $_POST); + $_POST = $change->enforceReadonlyFields($_POST, true); $newID = $change->add($_POST); Event::log( $newID, @@ -109,6 +110,7 @@ } elseif (isset($_POST["update"])) { $change->check($_POST["id"], UPDATE); + $_POST = $change->enforceReadonlyFields($_POST); $change->update($_POST); Event::log( $_POST["id"], diff --git a/front/problem.form.php b/front/problem.form.php index 046f99b3842..12753211290 100644 --- a/front/problem.form.php +++ b/front/problem.form.php @@ -53,6 +53,7 @@ if (isset($_POST["add"])) { $problem->check(-1, CREATE, $_POST); + $_POST = $problem->enforceReadonlyFields($_POST, true); if ($newID = $problem->add($_POST)) { Event::log( $newID, @@ -108,6 +109,7 @@ } elseif (isset($_POST["update"])) { $problem->check($_POST["id"], UPDATE); + $_POST = $problem->enforceReadonlyFields($_POST); $problem->update($_POST); Event::log( $_POST["id"], diff --git a/front/ticket.form.php b/front/ticket.form.php index f2fefafa232..c50d4202ba2 100644 --- a/front/ticket.form.php +++ b/front/ticket.form.php @@ -74,6 +74,7 @@ if (isset($_POST["add"])) { $track->check(-1, CREATE, $_POST); + $_POST = $track->enforceReadonlyFields($_POST, true); if ($track->add($_POST)) { if ($_SESSION['glpibackcreated']) { @@ -85,6 +86,7 @@ if (!$track::canUpdate()) { throw new AccessDeniedHttpException(); } + $_POST = $track->enforceReadonlyFields($_POST); $track->update($_POST); if (isset($_POST['kb_linked_id'])) { diff --git a/src/CommonITILObject.php b/src/CommonITILObject.php index 3c93deb5d0d..7827e82a440 100644 --- a/src/CommonITILObject.php +++ b/src/CommonITILObject.php @@ -52,6 +52,7 @@ use Glpi\RichText\UserMention; use Glpi\Search\Output\HTMLSearchOutput; use Glpi\Team\Team; +use Glpi\Urgency; use Safe\Exceptions\DatetimeException; use function Safe\getimagesize; @@ -1768,17 +1769,6 @@ public function cleanDBonPurge() protected function handleTemplateFields(array $input, bool $show_error_message = true) { //// check mandatory fields - // First get ticket template associated: entity and type/category - $entid = $input['entities_id'] ?? $this->fields['entities_id']; - - $type = null; - if (isset($input['type'])) { - $type = $input['type']; - } elseif (isset($this->fields['type'])) { - $type = $this->fields['type']; - } - - $categid = $input['itilcategories_id'] ?? $this->fields['itilcategories_id']; $check_allowed_fields_for_template = false; $allowed_fields = []; @@ -1865,9 +1855,9 @@ class_exists($validation_class) } } - $tt = $this->getITILTemplateToUse(0, $type, $categid, $entid); - - if (count($tt->mandatory)) { + // First get ticket template associated: entity and type/category + $tt = $this->getITILTemplateFromInput($input); + if ($tt && count($tt->mandatory)) { $mandatory_missing = []; $fieldsname = $tt->getAllowedFieldsNames(true); foreach ($tt->mandatory as $key => $val) { @@ -2341,6 +2331,40 @@ public function prepareInputForUpdate($input) return $input; } + /** + * Processes readonly fields in the input array based on the ITIL template data. + * + * @param array $input The user input data to process (often $_POST). + * @param bool $isAdd true if we are in a creation, will force to apply the template predefined field. + * + * @return array The modified user input array after processing readonly fields. + * + * @since 11.0.2 + */ + public function enforceReadonlyFields(array $input, bool $isAdd = false): array + { + $tt = $this->getITILTemplateFromInput($input); + if (!$tt) { + return $input; + } + + $tt->getFromDBWithData($tt->getID()); // We load the fields (predefined and readonly) + + foreach (array_keys($tt->readonly) as $read_only_field) { + if ($isAdd && array_key_exists($read_only_field, $tt->predefined)) { + $input[$read_only_field] = $tt->predefined[$read_only_field]; + continue; + } + + if (array_key_exists($read_only_field, $this->fields)) { + $input[$read_only_field] = $this->fields[$read_only_field]; + } else { + unset($input[$read_only_field]); + } + } + return $input; + } + public function post_updateItem($history = true) { // Handle rich-text images and uploaded documents @@ -2824,7 +2848,7 @@ public function prepareInputForAdd($input) } // save value before clean; - $title = ltrim($input['name']); + $title = ltrim($input['name'] ?? ''); // Set default status to avoid notice if (!isset($input["status"])) { @@ -2835,7 +2859,7 @@ public function prepareInputForAdd($input) !isset($input["urgency"]) || !($CFG_GLPI['urgency_mask'] & (1 << $input["urgency"])) ) { - $input["urgency"] = 3; + $input["urgency"] = Urgency::MEDIUM->value; } if ( !isset($input["impact"]) @@ -2886,8 +2910,8 @@ public function prepareInputForAdd($input) } // No name set name - $input["name"] = ltrim($input["name"]); - $input['content'] = ltrim($input['content']); + $input["name"] = ltrim($input["name"] ?? ''); + $input['content'] = ltrim($input['content'] ?? ''); if (empty($input["name"])) { // Build name based on content @@ -8252,6 +8276,36 @@ public function getITILTemplateToUse( return $tt; } + /** + * Get the template to use + * If the input is not defined, it will get it from the object fields datas + * + * @param array $input + * @return ITILTemplate|null + * + * @since 11.0.2 + */ + public function getITILTemplateFromInput(array $input = []): ?ITILTemplate + { + $entid = $input['entities_id'] ?? $this->fields['entities_id'] ?? $input['id'] ?? null; + if (is_null($entid)) { + return null; + } + + $type = null; + if (isset($input['type'])) { + $type = $input['type']; + } elseif (isset($this->fields['type'])) { + $type = $this->fields['type']; + } + + $categid = $input['itilcategories_id'] ?? $this->fields['itilcategories_id'] ?? null; + if (is_null($categid)) { + return null; + } + return $this->getITILTemplateToUse(0, $type, $categid, $entid); + } + /** * Get template field name * diff --git a/src/Ticket.php b/src/Ticket.php index a1b1764302c..8babb00e50f 100644 --- a/src/Ticket.php +++ b/src/Ticket.php @@ -45,6 +45,7 @@ use Glpi\RichText\RichText; use Glpi\RichText\UserMention; use Glpi\Search\DefaultSearchRequestInterface; +use Glpi\Urgency; use Safe\DateTime; use function Safe\preg_match; @@ -3489,7 +3490,7 @@ public static function getDefaultValues($entity = 0) 'name' => '', 'content' => '', 'itilcategories_id' => 0, - 'urgency' => 3, + 'urgency' => Urgency::MEDIUM->value, 'impact' => 3, 'priority' => self::computePriority(3, 3), 'requesttypes_id' => $requesttype, diff --git a/tests/cypress/e2e/ITILObject/ticket_form.cy.js b/tests/cypress/e2e/ITILObject/ticket_form.cy.js index 5124954f55a..d629780d9b9 100644 --- a/tests/cypress/e2e/ITILObject/ticket_form.cy.js +++ b/tests/cypress/e2e/ITILObject/ticket_form.cy.js @@ -334,4 +334,52 @@ describe("Ticket Form", () => { cy.findByRole('cell').should('contain.text', 'No results found'); }); }); + + it('Create/update a ticket using a template with readonly fields', () => { + const ticket_template_name = `test template ${rand}`; + cy.createWithAPI('TicketTemplate', { + 'name': ticket_template_name, + }).as('ticket_template_id'); + + cy.get('@ticket_template_id').then((ticket_template_id) => { + cy.createWithAPI('TicketTemplatePredefinedField', { + 'tickettemplates_id': ticket_template_id, // Default template + 'num': 10, // Urgency + 'value': 4, // High + }); + + cy.createWithAPI('TicketTemplateReadonlyField', { + 'tickettemplates_id': ticket_template_id, + 'num': 10, + }); + + cy.createWithAPI('ITILCategory', { + 'name':ticket_template_name, + 'tickettemplates_id': ticket_template_id, + 'tickettemplates_id_incident': ticket_template_id, + 'tickettemplates_id_demand': ticket_template_id, + 'changetemplates_id': ticket_template_id, + 'problemtemplates_id': ticket_template_id, + }); + }); + + // Create form + cy.visit(`/front/ticket.form.php`); + + // intercept form submit + cy.intercept('POST', '/front/ticket.form.php').as('submit'); + + cy.getDropdownByLabelText('Category').selectDropdownValue(`ยป${ticket_template_name}`); + + // We change the value of a readonly field, it should be ignored + cy.get('input[name="urgency"]').invoke('val', '1'); + cy.findByRole('button', {'name': 'Add'}).click(); + cy.wait('@submit').its('response.statusCode').should('eq', 200); + cy.get('input[name="urgency"]').should('have.value', '4'); // Should be the template 4 value + + // We try updating it + cy.get('input[name="urgency"]').invoke('val', '1'); + cy.findByRole('button', {'name': 'Save'}).click(); + cy.get('input[name="urgency"]').should('have.value', '4'); // Should be the template 4 value + }); }); diff --git a/tests/functional/ChangeITILTemplateReadonlyFieldTest.php b/tests/functional/ChangeITILTemplateReadonlyFieldTest.php new file mode 100644 index 00000000000..52aaf42f225 --- /dev/null +++ b/tests/functional/ChangeITILTemplateReadonlyFieldTest.php @@ -0,0 +1,46 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace functional; + +use Change; +use Glpi\Tests\AbstractITILTemplateReadonlyFieldTest; + +class ChangeITILTemplateReadonlyFieldTest extends AbstractITILTemplateReadonlyFieldTest +{ + public function getITILClass(): Change + { + return new Change(); + } +} diff --git a/tests/functional/ProblemITILTemplateReadonlyFieldTest.php b/tests/functional/ProblemITILTemplateReadonlyFieldTest.php new file mode 100644 index 00000000000..84f9979f45b --- /dev/null +++ b/tests/functional/ProblemITILTemplateReadonlyFieldTest.php @@ -0,0 +1,46 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace functional; + +use Glpi\Tests\AbstractITILTemplateReadonlyFieldTest; +use Problem; + +class ProblemITILTemplateReadonlyFieldTest extends AbstractITILTemplateReadonlyFieldTest +{ + public function getITILClass(): Problem + { + return new Problem(); + } +} diff --git a/tests/functional/TicketITILTemplateReadonlyFieldTest.php b/tests/functional/TicketITILTemplateReadonlyFieldTest.php new file mode 100644 index 00000000000..d574258b326 --- /dev/null +++ b/tests/functional/TicketITILTemplateReadonlyFieldTest.php @@ -0,0 +1,46 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace functional; + +use Glpi\Tests\AbstractITILTemplateReadonlyFieldTest; +use Ticket; + +class TicketITILTemplateReadonlyFieldTest extends AbstractITILTemplateReadonlyFieldTest +{ + public function getITILClass(): Ticket + { + return new Ticket(); + } +} diff --git a/tests/src/AbstractITILTemplateReadonlyFieldTest.php b/tests/src/AbstractITILTemplateReadonlyFieldTest.php new file mode 100644 index 00000000000..cd637445606 --- /dev/null +++ b/tests/src/AbstractITILTemplateReadonlyFieldTest.php @@ -0,0 +1,287 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Tests; + +use CommonITILObject; +use DbTestCase; +use Glpi\Urgency; +use ITILCategory; +use ITILTemplate; +use ITILTemplatePredefinedField; +use ITILTemplateReadonlyField; +use Ticket; + +abstract class AbstractITILTemplateReadonlyFieldTest extends DbTestCase +{ + abstract public function getITILClass(): CommonITILObject; + + /** + * Helper to create a template and a category associated with it. + * + * @param array $readonly Array of field names to be readonly. + * @param array $predefined Array of predefined field values. + * + * @return int The ID of the created category. + */ + protected function createTemplateAndCategory(array $readonly = [], array $predefined = []): int + { + $itil_object = $this->getITILClass(); + $itil_type = $itil_object->getType(); + + // Create Template + $template_class = $itil_type . 'Template'; + $template = new $template_class(); + $this->assertInstanceOf(ITILTemplate::class, $template); + + $template_input = [ + 'name' => 'test_template_' . mt_rand(), + 'is_active' => 1, + 'entities_id' => 0, + 'is_recursive' => 1, + ]; + $template_id = $template->add($template_input); + $this->assertGreaterThan(0, $template_id, 'Template creation failed'); + + // Add Readonly Fields + if (!empty($readonly)) { + $readonly_field_class = $itil_type . 'TemplateReadonlyField'; + $readonly_field = new $readonly_field_class(); + $this->assertInstanceOf(ITILTemplateReadonlyField::class, $readonly_field); + + $foreign_key_field = $readonly_field::$items_id; + + foreach ($readonly as $field_name) { + $result = $readonly_field->add([ + $foreign_key_field => $template_id, + 'num' => $this->getIdFromSearchOptions($field_name), + ]); + $this->assertNotFalse($result, "Failed to add readonly field '$field_name'"); + } + } + + // Add Predefined Fields + if (!empty($predefined)) { + $predefined_field_class = $itil_type . 'TemplatePredefinedField'; + $predefined_field = new $predefined_field_class(); + $this->assertInstanceOf(ITILTemplatePredefinedField::class, $predefined_field); + + $foreign_key_field = $predefined_field::$items_id; + + foreach ($predefined as $field_name => $field_value) { + $result = $predefined_field->add([ + $foreign_key_field => $template_id, + 'num' => $this->getIdFromSearchOptions($field_name), + 'value' => $field_value, + ]); + $this->assertNotFalse($result, "Failed to add predefined field '$field_name'"); + } + } + + // Create Category and associate template + $category = new ITILCategory(); + $cat_id = $category->add(['name' => 'test_cat_' . mt_rand(), 'entities_id' => 0, 'is_recursive' => 1]); + $this->assertGreaterThan(0, $cat_id, 'Category creation failed'); + + $type = null; + if ($itil_object instanceof Ticket) { + $type = Ticket::INCIDENT_TYPE; + } else { + // For Change and Problem, the template is not type-specific in the same way. + $type = true; + } + $template_field_name = $itil_object->getTemplateFieldName($type); + + $category->update([ + 'id' => $cat_id, + $template_field_name => $template_id, + ]); + + return $cat_id; + } + + protected function getIdFromSearchOptions(string $field): ?string + { + $item = $this->getITILClass(); + foreach ($item->getSearchOptionsMain() as $option) { + if (isset($option['field']) && $option['field'] === $field) { + return (string) $option['id']; + } + } + return null; + } + + public function setUp(): void + { + parent::setUp(); + $this->login(); + } + + public function testHandleReadonlyFieldsWithNoTemplate(): void + { + $itil_object = $this->getITILClass(); + $input = [ + 'urgency' => Urgency::LOW->value, + 'name' => 'Some content', + 'status' => CommonITILObject::INCOMING, + 'entities_id' => 0, + ]; + if ($itil_object instanceof Ticket) { + $input['type'] = Ticket::INCIDENT_TYPE; + } + + $processed_input = $itil_object->enforceReadonlyFields($input, true); + + $this->assertEquals(Urgency::LOW->value, $processed_input['urgency']); + $this->assertEquals('Some content', $processed_input['name']); + } + + public function testHandleReadonlyFieldsOnAddWithPredefined(): void + { + $cat_id = $this->createTemplateAndCategory(['urgency'], ['urgency' => Urgency::HIGH->value]); + + $itil_object = $this->getITILClass(); + $input = [ + 'urgency' => Urgency::LOW->value, + 'name' => 'Some content', + 'status' => CommonITILObject::INCOMING, + 'itilcategories_id' => $cat_id, + 'entities_id' => 0, + ]; + if ($itil_object instanceof Ticket) { + $input['type'] = Ticket::INCIDENT_TYPE; + } + + $processed_input = $itil_object->enforceReadonlyFields($input, true); + + $this->assertEquals(Urgency::HIGH->value, $processed_input['urgency']); + $this->assertEquals('Some content', $processed_input['name']); + } + + public function testHandleReadonlyFieldsOnAddWithoutPredefined(): void + { + $cat_id = $this->createTemplateAndCategory(['urgency']); + + $itil_object = $this->getITILClass(); + $input = [ + 'urgency' => Urgency::LOW->value, + 'name' => 'Some content', + 'status' => CommonITILObject::INCOMING, + 'itilcategories_id' => $cat_id, + 'entities_id' => 0, + ]; + if ($itil_object instanceof Ticket) { + $input['type'] = Ticket::INCIDENT_TYPE; + } + + $processed_input = $itil_object->enforceReadonlyFields($input, true); + + $this->assertArrayNotHasKey('urgency', $processed_input); // Default value + $this->assertEquals('Some content', $processed_input['name']); + } + + public function testHandleReadonlyFieldsOnUpdateWithExistingValue(): void + { + $cat_id = $this->createTemplateAndCategory(['urgency']); + + $itil_object = $this->getITILClass(); + $add_input = [ + 'name' => 'Initial content', + 'status' => CommonITILObject::ASSIGNED, + 'itilcategories_id' => $cat_id, + 'entities_id' => 0, + ]; + if ($itil_object instanceof Ticket) { + $add_input['type'] = Ticket::INCIDENT_TYPE; + } + $item_id = $itil_object->add($add_input); + $this->assertGreaterThan(0, $item_id); + + $itil_object->getFromDB($item_id); + + $update_input = [ + 'id' => $item_id, + 'urgency' => Urgency::HIGH->value, + 'name' => 'Updated content', + 'status' => CommonITILObject::ASSIGNED, + 'itilcategories_id' => $cat_id, + 'entities_id' => 0, + ]; + if ($itil_object instanceof Ticket) { + $update_input['type'] = Ticket::INCIDENT_TYPE; + } + + $processed_input = $itil_object->enforceReadonlyFields($update_input); + + $this->assertEquals(Urgency::MEDIUM->value, $processed_input['urgency']); + $this->assertEquals('Updated content', $processed_input['name']); + } + + public function testHandleReadonlyFieldsOnUpdateWithoutExistingValue(): void + { + $cat_id = $this->createTemplateAndCategory(['urgency']); + + $itil_object = $this->getITILClass(); + $add_input = [ + 'name' => 'Initial content', + 'status' => CommonITILObject::ASSIGNED, + 'itilcategories_id' => $cat_id, + 'entities_id' => 0, + ]; + if ($itil_object instanceof Ticket) { + $add_input['type'] = Ticket::INCIDENT_TYPE; + } + $item_id = $itil_object->add($add_input); + $this->assertGreaterThan(0, $item_id); + + $itil_object->getFromDB($item_id); + + $update_input = [ + 'id' => $item_id, + 'urgency' => Urgency::LOW->value, + 'name' => 'Updated content', + 'status' => CommonITILObject::ASSIGNED, + 'itilcategories_id' => $cat_id, + 'entities_id' => 0, + ]; + if ($itil_object instanceof Ticket) { + $update_input['type'] = Ticket::INCIDENT_TYPE; + } + + $processed_input = $itil_object->enforceReadonlyFields($update_input); + + $this->assertEquals(Urgency::MEDIUM->value, $processed_input['urgency']); // Default value + $this->assertEquals('Updated content', $processed_input['name']); + } +}