diff --git a/plugins/lookup/lookup.py b/plugins/lookup/lookup.py index 91575a0a..71e8276f 100644 --- a/plugins/lookup/lookup.py +++ b/plugins/lookup/lookup.py @@ -154,6 +154,8 @@ def get_endpoint(nautobot, term): "console-ports": {"endpoint": nautobot.dcim.console_ports}, "console-server-port-templates": {"endpoint": nautobot.dcim.console_server_port_templates}, "console-server-ports": {"endpoint": nautobot.dcim.console_server_ports}, + "custom-fields": {"endpoint": nautobot.extras.custom_fields}, + "custom-field-choices": {"endpoint": nautobot.extras.custom_field_choices}, "device-bay-templates": {"endpoint": nautobot.dcim.device_bay_templates}, "device-bays": {"endpoint": nautobot.dcim.device_bays}, "device-types": {"endpoint": nautobot.dcim.device_types}, diff --git a/plugins/module_utils/extras.py b/plugins/module_utils/extras.py index 4f4e85b6..13e8505f 100644 --- a/plugins/module_utils/extras.py +++ b/plugins/module_utils/extras.py @@ -13,6 +13,8 @@ NB_TAGS = "tags" NB_STATUS = "statuses" NB_RELATIONSHIP_ASSOCIATIONS = "relationship_associations" +NB_CUSTOM_FIELDS = "custom_fields" +NB_CUSTOM_FIELD_CHOICES = "custom_field_choices" class NautobotExtrasModule(NautobotModule): @@ -42,6 +44,10 @@ def run(self): name = data["name"] elif endpoint_name == "relationship_associations": name = f"{data['source_type']} -> {data['destination_type']}" + elif endpoint_name == "custom_field": + name = data["label"] + elif endpoint_name == "custom_field_choice": + name = data["value"] else: name = data.get("id") diff --git a/plugins/module_utils/utils.py b/plugins/module_utils/utils.py index 2f074efe..691d2bbd 100644 --- a/plugins/module_utils/utils.py +++ b/plugins/module_utils/utils.py @@ -65,7 +65,14 @@ "rear_port_templates", "virtual_chassis", ], - extras=["tags", "statuses", "relationship_associations", "roles"], + extras=[ + "custom_fields", + "custom_field_choices", + "relationship_associations", + "roles", + "statuses", + "tags", + ], ipam=[ "ip_addresses", "ip_address_to_interface", @@ -73,10 +80,10 @@ "prefixes", "rirs", "route_targets", + "services", "vlans", "vlan_groups", "vrfs", - "services", ], plugins=[], secrets=[], @@ -212,6 +219,8 @@ "console_port_templates": "console_port_template", "console_server_ports": "console_server_port", "console_server_port_templates": "console_server_port_template", + "custom_fields": "custom_field", + "custom_field_choices": "custom_field_choice", "device_bays": "device_bay", "device_bay_templates": "device_bay_template", "devices": "device", @@ -270,6 +279,8 @@ "console_port_template": set(["name", "device_type"]), "console_server_port": set(["name", "device"]), "console_server_port_template": set(["name", "device_type"]), + "custom_field": set(["label"]), + "custom_field_choice": set(["value", "custom_field"]), "dcim.consoleport": set(["name", "device"]), "dcim.consoleserverport": set(["name", "device"]), "dcim.frontport": set(["name", "device", "rear_port"]), diff --git a/plugins/modules/custom_field.py b/plugins/modules/custom_field.py new file mode 100644 index 00000000..8ea43d73 --- /dev/null +++ b/plugins/modules/custom_field.py @@ -0,0 +1,218 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2023, Network to Code (@networktocode) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: custom_field +short_description: Creates or removes custom fields from Nautobot +description: + - Creates or removes custom fields from Nautobot +notes: + - This should be ran with connection C(local) and hosts C(localhost) +author: + - Joe Wesch (@joewesch) +requirements: + - pynautobot +version_added: "5.1.0" +extends_documentation_fragment: + - networktocode.nautobot.fragments.base +options: + label: + description: + - Name of the field as displayed to users + required: true + type: str + version_added: "5.1.0" + grouping: + description: + - Human-readable grouping that this custom field belongs to + required: false + type: str + version_added: "5.1.0" + key: + description: + - Internal name of this field + - Required if I(state=present) and the custom field does not exist yet + required: false + type: str + version_added: "5.1.0" + type: + description: + - Data type of this field + - Required if I(state=present) and the custom field does not exist yet + - I(type=select) and I(type=multi-select) require choices to be defined separately with the I(custom_field_choice) module + required: false + choices: + - text + - integer + - boolean + - date + - url + - select + - multi-select + - json + - markdown + type: str + version_added: "5.1.0" + weight: + description: + - Position this field should be displayed in + required: false + type: int + version_added: "5.1.0" + description: + description: + - Description of this field + - Also used as the help text when editing models using this custom field + - Markdown is supported + required: false + type: str + version_added: "5.1.0" + required: + description: + - Whether or not a value is required for this field when editing models + required: false + type: bool + version_added: "5.1.0" + default: + description: + - Default value for this field when editing models + - Must be in JSON format + required: false + type: raw + version_added: "5.1.0" + filter_logic: + description: + - Filter logic to apply when filtering models based on this field + - Only compatible with I(type=text), I(type=url) and I(type=json) + required: false + type: str + choices: + - disabled + - loose + - exact + version_added: "5.1.0" + advanced_ui: + description: + - Whether or not to display this field in the advanced tab + required: false + type: bool + version_added: "5.1.0" + content_types: + description: + - Content types that this field should be available for + - Required if I(state=present) and the custom field does not exist yet + required: false + type: list + elements: str + version_added: "5.1.0" + validation_minimum: + description: + - Minimum value allowed for this field + - Only compatible with I(type=integer) + required: false + type: int + version_added: "5.1.0" + validation_maximum: + description: + - Maximum value allowed for this field + - Only compatible with I(type=integer) + required: false + type: int + version_added: "5.1.0" + validation_regex: + description: + - Regular expression that this field must match + - Only compatible with I(type=text) + required: false + type: str + version_added: "5.1.0" +""" + +EXAMPLES = r""" +- name: Create custom field within Nautobot with only required information + networktocode.nautobot.custom_field: + url: http://nautobot.local + token: thisIsMyToken + label: My Custom Field + key: my_custom_field + type: text + state: present + +- name: Create custom field within Nautobot with all information + networktocode.nautobot.custom_field: + url: http://nautobot.local + token: thisIsMyToken + label: My Custom Field + grouping: My Grouping + key: my_custom_field + type: text + weight: 100 + description: My Description + required: true + default: My Default + filter_logic: loose + advanced_ui: true + content_types: + - dcim.device + validation_minimum: 0 + validation_maximum: 100 + validation_regex: ^[a-z]+$ + state: present +""" + +RETURN = r""" +custom_field: + description: Serialized object as created or already existent within Nautobot + returned: success (when I(state=present)) + type: dict +msg: + description: Message indicating failure or info about what has been achieved + returned: always + type: str +""" + +from ansible_collections.networktocode.nautobot.plugins.module_utils.utils import NAUTOBOT_ARG_SPEC +from ansible_collections.networktocode.nautobot.plugins.module_utils.extras import ( + NautobotExtrasModule, + NB_CUSTOM_FIELDS, +) +from ansible.module_utils.basic import AnsibleModule +from copy import deepcopy + + +def main(): + """Execute custom field module.""" + argument_spec = deepcopy(NAUTOBOT_ARG_SPEC) + argument_spec.update( + dict( + label=dict(required=True, type="str"), + grouping=dict(required=False, type="str"), + key=dict(required=False, type="str"), + type=dict(required=False, choices=["text", "integer", "boolean", "date", "url", "select", "multi-select", "json", "markdown"], type="str"), + weight=dict(required=False, type="int"), + description=dict(required=False, type="str"), + required=dict(required=False, type="bool"), + default=dict(required=False, type="raw"), + filter_logic=dict(required=False, choices=["disabled", "loose", "exact"], type="str"), + advanced_ui=dict(required=False, type="bool"), + content_types=dict(required=False, type="list", elements="str"), + validation_minimum=dict(required=False, type="int"), + validation_maximum=dict(required=False, type="int"), + validation_regex=dict(required=False, type="str"), + ) + ) + + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + custom_field = NautobotExtrasModule(module, NB_CUSTOM_FIELDS) + custom_field.run() + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/plugins/modules/custom_field_choice.py b/plugins/modules/custom_field_choice.py new file mode 100644 index 00000000..55965702 --- /dev/null +++ b/plugins/modules/custom_field_choice.py @@ -0,0 +1,95 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2023, Network to Code (@networktocode) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: custom_field_choice +short_description: Creates or removes custom field choices from Nautobot +description: + - Creates or removes custom field choices from Nautobot +notes: + - This should be ran with connection C(local) and hosts C(localhost) +author: + - Joe Wesch (@joewesch) +requirements: + - pynautobot +version_added: "5.1.0" +extends_documentation_fragment: + - networktocode.nautobot.fragments.base +options: + value: + description: + - Value of this choice + required: true + type: str + version_added: "5.1.0" + weight: + description: + - Weight of this choice + required: false + type: int + version_added: "5.1.0" + custom_field: + description: + - Custom field this choice belongs to + required: true + type: raw + version_added: "5.1.0" +""" + +EXAMPLES = r""" +--- +- name: Create a custom field choice + networktocode.nautobot.custom_field_choice: + url: http://nautobot.local + token: thisIsMyToken + value: "Choice 1" + weight: 100 + custom_field: "Custom Field 1" + state: present +""" + +RETURN = r""" +custom_field_choice: + description: Serialized object as created or already existent within Nautobot + returned: success (when I(state=present)) + type: dict +msg: + description: Message indicating failure or info about what has been achieved + returned: always + type: str +""" + +from ansible_collections.networktocode.nautobot.plugins.module_utils.utils import NAUTOBOT_ARG_SPEC +from ansible_collections.networktocode.nautobot.plugins.module_utils.extras import ( + NautobotExtrasModule, + NB_CUSTOM_FIELD_CHOICES, +) +from ansible.module_utils.basic import AnsibleModule +from copy import deepcopy + + +def main(): + """Execute custom field choice module.""" + argument_spec = deepcopy(NAUTOBOT_ARG_SPEC) + argument_spec.update( + dict( + value=dict(required=True, type="str"), + weight=dict(required=False, type="int"), + custom_field=dict(required=True, type="raw"), + ) + ) + + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + custom_field_choice = NautobotExtrasModule(module, NB_CUSTOM_FIELD_CHOICES) + custom_field_choice.run() + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/tests/integration/nautobot-populate.py b/tests/integration/nautobot-populate.py index 1e21fcf7..7a463469 100755 --- a/tests/integration/nautobot-populate.py +++ b/tests/integration/nautobot-populate.py @@ -37,6 +37,7 @@ def make_nautobot_calls(endpoint, payload): created = endpoint.create(payload) except pynautobot.RequestError as e: print(e.error) + global ERRORS # pylint: disable=global-statement ERRORS = True return @@ -574,5 +575,16 @@ def make_nautobot_calls(endpoint, payload): ] created_device_redundancy_groups = make_nautobot_calls(nb.dcim.device_redundancy_groups, device_redundancy_groups) +# Create Custom Fields +custom_fields = [ + { + "label": "My Selection Custom Field", + "key": "my_selection_custom_field", + "type": "select", + "content_types": ["circuits.circuit"], + } +] +created_custom_fields = make_nautobot_calls(nb.extras.custom_fields, custom_fields) + if ERRORS: sys.exit("Errors have occurred when creating objects, and should have been printed out. Check previous output.") diff --git a/tests/integration/targets/latest/tasks/custom_field.yml b/tests/integration/targets/latest/tasks/custom_field.yml new file mode 100644 index 00000000..75a304e7 --- /dev/null +++ b/tests/integration/targets/latest/tasks/custom_field.yml @@ -0,0 +1,155 @@ +--- +## +## +### PYNAUTOBOT_CUSTOM_FIELD +## +## +- name: "1 - Create custom field within Nautobot with only required information" + networktocode.nautobot.custom_field: + url: "{{ nautobot_url }}" + token: "{{ nautobot_token }}" + label: Test Custom Field + key: test_custom_field + type: text + content_types: + - dcim.device + register: test_create_min + +- name: "1 - ASSERT" + assert: + that: + - test_create_min is changed + - test_create_min['diff']['before']['state'] == "absent" + - test_create_min['diff']['after']['state'] == "present" + - test_create_min['custom_field']['label'] == "Test Custom Field" + - test_create_min['custom_field']['key'] == "test_custom_field" + - test_create_min['custom_field']['type'] == "text" + - test_create_min['msg'] == "custom_field Test Custom Field created" + +- name: "2 - Duplicate" + networktocode.nautobot.custom_field: + url: "{{ nautobot_url }}" + token: "{{ nautobot_token }}" + label: Test Custom Field + key: test_custom_field + type: text + content_types: + - dcim.device + register: test_create_idem + +- name: "2 - ASSERT" + assert: + that: + - not test_create_idem['changed'] + - test_create_idem['msg'] == "custom_field Test Custom Field already exists" + - test_create_idem['custom_field']['label'] == "Test Custom Field" + +- name: "3 - Update custom field" + networktocode.nautobot.custom_field: + url: "{{ nautobot_url }}" + token: "{{ nautobot_token }}" + label: Test Custom Field + description: Test Custom Field Description + register: test_update + +- name: "3 - ASSERT" + assert: + that: + - test_update is changed + - test_update['diff']['before']['description'] == "" + - test_update['diff']['after']['description'] == "Test Custom Field Description" + +- name: "4 - Create custom field with all parameters" + networktocode.nautobot.custom_field: + url: "{{ nautobot_url }}" + token: "{{ nautobot_token }}" + label: Test Custom Field 2 + key: test_custom_field_2 + type: integer + content_types: + - dcim.device + - dcim.location + description: Test Custom Field Description 2 + required: true + filter_logic: exact + default: 1 + weight: 100 + validation_minimum: 1 + validation_maximum: 10 + register: test_create_max + +- name: "4 - ASSERT" + assert: + that: + - test_create_max is changed + - test_create_max['diff']['before']['state'] == "absent" + - test_create_max['diff']['after']['state'] == "present" + - test_create_max['custom_field']['label'] == "Test Custom Field 2" + - test_create_max['custom_field']['key'] == "test_custom_field_2" + - test_create_max['custom_field']['type'] == "integer" + - test_create_max['msg'] == "custom_field Test Custom Field 2 created" + - test_create_max['custom_field']['content_types'] == ['dcim.device', 'dcim.location'] + - test_create_max['custom_field']['description'] == "Test Custom Field Description 2" + - test_create_max['custom_field']['required'] == true + - test_create_max['custom_field']['filter_logic'] == "exact" + - test_create_max['custom_field']['default'] == 1 + - test_create_max['custom_field']['weight'] == 100 + - test_create_max['custom_field']['validation_minimum'] == 1 + - test_create_max['custom_field']['validation_maximum'] == 10 + +- name: "5 - Duplicate create with all parameters" + networktocode.nautobot.custom_field: + url: "{{ nautobot_url }}" + token: "{{ nautobot_token }}" + label: Test Custom Field 2 + key: test_custom_field_2 + type: integer + content_types: + - dcim.device + - dcim.location + description: Test Custom Field Description 2 + required: true + filter_logic: exact + default: 1 + weight: 100 + validation_minimum: 1 + validation_maximum: 10 + register: test_create_max_idem + +- name: "5 - ASSERT" + assert: + that: + - not test_create_max_idem['changed'] + - test_create_max_idem['msg'] == "custom_field Test Custom Field 2 already exists" + - test_create_max_idem['custom_field']['label'] == "Test Custom Field 2" + +- name: "6 - Delete custom field" + networktocode.nautobot.custom_field: + url: "{{ nautobot_url }}" + token: "{{ nautobot_token }}" + label: Test Custom Field 2 + state: absent + register: test_delete + +- name: "6 - ASSERT" + assert: + that: + - test_delete is changed + - test_delete['diff']['before']['state'] == "present" + - test_delete['diff']['after']['state'] == "absent" + - test_delete['custom_field']['label'] == "Test Custom Field 2" + - "'deleted' in test_delete['msg']" + +- name: "7 - Delete idempotent" + networktocode.nautobot.custom_field: + url: "{{ nautobot_url }}" + token: "{{ nautobot_token }}" + label: Test Custom Field 2 + state: absent + register: test_delete_idem + +- name: "7 - ASSERT" + assert: + that: + - not test_delete_idem['changed'] + - "'already absent' in test_delete_idem['msg']" diff --git a/tests/integration/targets/latest/tasks/custom_field_choice.yml b/tests/integration/targets/latest/tasks/custom_field_choice.yml new file mode 100644 index 00000000..498d8b1b --- /dev/null +++ b/tests/integration/targets/latest/tasks/custom_field_choice.yml @@ -0,0 +1,89 @@ +--- +## +## +### PYNAUTOBOT_CUSTOM_FIELD_CHOICE +## +## +- set_fact: + custom_field: "{{ lookup('networktocode.nautobot.lookup', 'custom-fields', api_endpoint=nautobot_url, token=nautobot_token, api_filter='label=\"My Selection Custom Field\"') }}" + +- name: "1 - Create custom field choice within Nautobot with only required information" + networktocode.nautobot.custom_field_choice: + url: "{{ nautobot_url }}" + token: "{{ nautobot_token }}" + custom_field: "{{ custom_field['key'] }}" + value: "Test Custom Field Choice" + register: test_create_min + +- name: "1 - ASSERT" + assert: + that: + - test_create_min is changed + - test_create_min['diff']['before']['state'] == "absent" + - test_create_min['diff']['after']['state'] == "present" + - test_create_min['custom_field_choice']['value'] == "Test Custom Field Choice" + - test_create_min['custom_field_choice']['custom_field'] == custom_field['key'] + - test_create_min['msg'] == "custom_field_choice Test Custom Field Choice created" + +- name: "2 - Duplicate" + networktocode.nautobot.custom_field_choice: + url: "{{ nautobot_url }}" + token: "{{ nautobot_token }}" + custom_field: "{{ custom_field['key'] }}" + value: "Test Custom Field Choice" + register: test_create_idem + +- name: "2 - ASSERT" + assert: + that: + - not test_create_idem['changed'] + - test_create_idem['msg'] == "custom_field_choice Test Custom Field Choice already exists" + - test_create_idem['custom_field_choice']['value'] == "Test Custom Field Choice" + +- name: "3 - Update custom field choice" + networktocode.nautobot.custom_field_choice: + url: "{{ nautobot_url }}" + token: "{{ nautobot_token }}" + custom_field: "{{ custom_field['key'] }}" + value: "Test Custom Field Choice" + weight: 200 + register: test_update + +- name: "3 - ASSERT" + assert: + that: + - test_update is changed + - test_update['diff']['before']['weight'] == 100 + - test_update['diff']['after']['weight'] == 200 + +- name: "4 - Delete custom field choice" + networktocode.nautobot.custom_field_choice: + url: "{{ nautobot_url }}" + token: "{{ nautobot_token }}" + custom_field: "{{ custom_field['key'] }}" + value: "Test Custom Field Choice" + state: absent + register: test_delete + +- name: "4 - ASSERT" + assert: + that: + - test_delete is changed + - test_delete['diff']['before']['state'] == "present" + - test_delete['diff']['after']['state'] == "absent" + - "'deleted' in test_delete['msg']" + +- name: "5 - Delete idempotent" + networktocode.nautobot.custom_field_choice: + url: "{{ nautobot_url }}" + token: "{{ nautobot_token }}" + custom_field: "{{ custom_field['key'] }}" + value: "Test Custom Field Choice" + state: absent + register: test_delete_idem + +- name: "5 - ASSERT" + assert: + that: + - not test_delete_idem['changed'] + - "'already absent' in test_delete_idem['msg']" diff --git a/tests/integration/targets/latest/tasks/main.yml b/tests/integration/targets/latest/tasks/main.yml index 0675af6e..fe669412 100644 --- a/tests/integration/targets/latest/tasks/main.yml +++ b/tests/integration/targets/latest/tasks/main.yml @@ -493,3 +493,21 @@ - device_redundancy_group tags: - device_redundancy_group + +- name: "PYNAUTOBOT_CUSTOM_FIELD TESTS" + include_tasks: + file: "custom_field.yml" + apply: + tags: + - custom_field + tags: + - custom_field + +- name: "PYNAUTOBOT_CUSTOM_FIELD_CHOICE TESTS" + include_tasks: + file: "custom_field_choice.yml" + apply: + tags: + - custom_field_choice + tags: + - custom_field_choice diff --git a/tests/sanity/ignore-2.14.txt b/tests/sanity/ignore-2.14.txt index ee1e1c82..935f26da 100644 --- a/tests/sanity/ignore-2.14.txt +++ b/tests/sanity/ignore-2.14.txt @@ -1 +1,2 @@ +plugins/modules/custom_field.py validate-modules:no-log-needed # key is not an actual secret that requires no_log to be set plugins/modules/device_redundancy_group.py validate-modules:no-log-needed # secrets_group is not an actual secret that requires no_log to be set diff --git a/tests/sanity/ignore-2.15.txt b/tests/sanity/ignore-2.15.txt index ee1e1c82..935f26da 100644 --- a/tests/sanity/ignore-2.15.txt +++ b/tests/sanity/ignore-2.15.txt @@ -1 +1,2 @@ +plugins/modules/custom_field.py validate-modules:no-log-needed # key is not an actual secret that requires no_log to be set plugins/modules/device_redundancy_group.py validate-modules:no-log-needed # secrets_group is not an actual secret that requires no_log to be set