├── MANIFEST.in ├── netboxapi ├── __init__.py ├── exceptions.py ├── api.py └── mapper.py ├── tox.ini ├── .travis.yml ├── setup.cfg ├── setup.py ├── LICENSE.md ├── .gitignore ├── tests ├── test_api.py └── test_mapper.py └── README.md /MANIFEST.in: -------------------------------------------------------------------------------- 1 | prune test 2 | 3 | # Include relevant text files. 4 | include LICENSE.md README.md 5 | -------------------------------------------------------------------------------- /netboxapi/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from .api import NetboxAPI 4 | from .mapper import NetboxMapper 5 | -------------------------------------------------------------------------------- /netboxapi/exceptions.py: -------------------------------------------------------------------------------- 1 | class ForbiddenAsChildError(Exception): 2 | pass 3 | 4 | 5 | class ForbiddenAsPassiveMapperError(Exception): 6 | def __init__(self): 7 | super().__init__("No action is possible for this type of mapper") 8 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35, py36, py37, py38 3 | 4 | [testenv] 5 | deps = 6 | apipkg 7 | pytest 8 | pytest-mock 9 | 10 | commands= python setup.py test 11 | 12 | 13 | [testenv:coveralls] 14 | passenv = TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH COVERALLS_REPO_TOKEN 15 | usedevelop=True 16 | basepython=python3.8 17 | changedir=. 18 | deps = 19 | {[testenv]deps} 20 | coveralls 21 | commands= 22 | python setup.py testcoveralls 23 | coveralls 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | matrix: 4 | include: 5 | - python: 3.8-dev 6 | env: TOXENV=coveralls 7 | - python: 3.5-dev 8 | env: TOXENV=py35 9 | - python: 3.6-dev 10 | env: TOXENV=py36 11 | - python: 3.7-dev 12 | env: TOXENV=py37 13 | - python: 3.8-dev 14 | env: TOXENV=py38 15 | 16 | sudo: required 17 | dist: bionic 18 | 19 | before_install: 20 | - sudo apt-get -qq update 21 | 22 | install: 23 | - pip install -U tox 24 | 25 | script: 26 | - tox 27 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.1.7 3 | commit = True 4 | tag = True 5 | tag_name = {new_version} 6 | 7 | [bumpversion:file:setup.py] 8 | search = version="{current_version}" 9 | replace = version="{new_version}" 10 | 11 | [aliases] 12 | test = pytest 13 | testv = pytest --addopts "-v --duration=10" 14 | testd = pytest --addopts "--pdb" 15 | testlf = pytest --addopts "--lf" 16 | testcov = pytest --addopts "--cov netboxapi --cov-config .coveragerc" 17 | testcoveralls = pytest --addopts "--cov netboxapi --cov-report=" 18 | 19 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Netbox API 5 | """ 6 | 7 | from os import path 8 | from setuptools import setup 9 | 10 | setup( 11 | name="netboxapi", 12 | version="1.1.7", 13 | 14 | description="Client API for Netbox", 15 | 16 | author="Anthony25 ", 17 | author_email="anthony.ruhier@gmail.com", 18 | 19 | license="Simplified BSD", 20 | 21 | classifiers=[ 22 | "Programming Language :: Python :: 3 :: Only", 23 | "License :: OSI Approved :: BSD License", 24 | ], 25 | 26 | keywords=["netbox", "api"], 27 | packages=["netboxapi", ], 28 | install_requires=["requests", ], 29 | setup_requires=["pytest-runner", ], 30 | tests_require=[ 31 | "pytest", "pytest-cov", "pytest-mock", "pytest-xdist", 32 | "requests-mock" 33 | ], 34 | ) 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Anthony Ruhier All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 7 | 8 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 9 | 10 | The views and conclusions contained in the software and documentation are those of the authors and should not be interpreted as representing official policies, either expressed or implied, of the FreeBSD Project. 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #### joe made this: http://goel.io/joe 2 | 3 | #### python #### 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *,cover 50 | .hypothesis/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # dotenv 86 | .env 87 | 88 | # virtualenv 89 | .venv 90 | venv/ 91 | ENV/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | 96 | # Rope project settings 97 | .ropeproject 98 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | 2 | import pytest 3 | import requests_mock 4 | 5 | from netboxapi import NetboxAPI 6 | from netboxapi.api import _HTTPTokenAuth 7 | 8 | 9 | class TestNetboxAPI(): 10 | url = "http://localhost/api" 11 | login = "login" 12 | password = "password" 13 | token = "testing_token" 14 | 15 | @pytest.fixture() 16 | def prepared_api(self): 17 | return NetboxAPI(self.url) 18 | 19 | def test_url_sanitizer(self): 20 | complete_schema_url_netbox_api = NetboxAPI("http://test/api").url 21 | no_schema_url_netbox_api = NetboxAPI("test/api/").url 22 | 23 | assert complete_schema_url_netbox_api == no_schema_url_netbox_api 24 | 25 | def test_build_model_route(self, prepared_api): 26 | app = "test_app" 27 | model = "test_model" 28 | 29 | model_route = prepared_api.build_model_route(app, model).rstrip("/") 30 | expected_route = "{}/{}".format(app, model).rstrip("/") 31 | assert model_route == expected_route 32 | 33 | def test_build_model_url(self, prepared_api): 34 | app = "test_app" 35 | model = "test_model" 36 | 37 | model_url = prepared_api.build_model_url(app, model).rstrip("/") 38 | expected_url = self.url + "/{}/{}".format(app, model).rstrip("/") 39 | assert model_url == expected_url 40 | 41 | def test_get(self, prepared_api, **kwargs): 42 | self._generic_test_http_method_request(prepared_api, "get") 43 | 44 | def test_get_loggedin(self, **kwargs): 45 | prepared_api = NetboxAPI(self.url, self.login, self.password) 46 | self._generic_test_http_method_request(prepared_api, "get") 47 | 48 | def test_get_loggedin_token(self, **kwargs): 49 | prepared_api = NetboxAPI(self.url, token=self.token) 50 | self._generic_test_http_method_json(prepared_api, "get") 51 | 52 | def test_post(self, prepared_api, **kwargs): 53 | self._generic_test_http_method_json(prepared_api, "post") 54 | 55 | def test_put(self, prepared_api, **kwargs): 56 | self._generic_test_http_method_json(prepared_api, "put") 57 | 58 | def test_patch(self, prepared_api, **kwargs): 59 | self._generic_test_http_method_json(prepared_api, "patch") 60 | 61 | def test_delete(self, prepared_api, **kwargs): 62 | self._generic_test_http_method(prepared_api, "delete") 63 | 64 | def _generic_test_http_method_json(self, prepared_api, method): 65 | response, expected_json = self._generic_test_http_method_request( 66 | prepared_api, method 67 | ) 68 | 69 | assert response == expected_json 70 | 71 | def _generic_test_http_method(self, prepared_api, method): 72 | response, _ = self._generic_test_http_method_request( 73 | prepared_api, method 74 | ) 75 | 76 | assert response.status_code == 200 77 | 78 | def _generic_test_http_method_request(self, prepared_api, method): 79 | app = "test_app" 80 | model = "test_model" 81 | url = prepared_api.build_model_url("test_app", "test_model") 82 | route = prepared_api.build_model_route("test_app", "test_model") 83 | 84 | expected_json = {"id": 1, "name": "test"} 85 | with requests_mock.Mocker() as m: 86 | m.register_uri(method, url, json=expected_json) 87 | response = getattr(prepared_api, method)(route) 88 | 89 | return response, expected_json 90 | 91 | 92 | class TestHTTPTokenAuth(): 93 | def test_eq(self): 94 | assert _HTTPTokenAuth("test") == _HTTPTokenAuth("test") 95 | 96 | def test_not_eq(self): 97 | assert _HTTPTokenAuth("test") != _HTTPTokenAuth("test1") 98 | -------------------------------------------------------------------------------- /netboxapi/api.py: -------------------------------------------------------------------------------- 1 | 2 | import re 3 | import requests 4 | 5 | 6 | class _HTTPTokenAuth(requests.auth.AuthBase): 7 | """HTTP Basic Authentication with token.""" 8 | 9 | def __init__(self, token): 10 | self.token = token 11 | 12 | def __eq__(self, other): 13 | return self.token == getattr(other, 'token', None) 14 | 15 | def __ne__(self, other): 16 | return not self == other 17 | 18 | def __call__(self, r): 19 | r.headers["Authorization"] = "Token {}".format(self.token) 20 | return r 21 | 22 | 23 | class NetboxAPI(): 24 | def __init__(self, url, username=None, password=None, token=None): 25 | self.username = username 26 | self.password = password 27 | self.token = token 28 | 29 | if re.match("^.*://", url): 30 | self.url = url.rstrip("/") 31 | else: 32 | self.url = "http://{}".format(url.rstrip("/")) 33 | 34 | self.session = requests.Session() 35 | 36 | def get(self, route, **kwargs): 37 | """ 38 | :returns results: answer, as an unpacked json 39 | """ 40 | response = self._generic_http_method_request("get", route, **kwargs) 41 | return self._handle_json_response(response) 42 | 43 | def post(self, route, **kwargs): 44 | """ 45 | :returns added_object: new added object, as an unpacked json 46 | """ 47 | response = self._generic_http_method_request( 48 | "post", route, **kwargs 49 | ) 50 | return self._handle_json_response(response) 51 | 52 | def put(self, route, **kwargs): 53 | """ 54 | :returns updated_object: updated object, as an unpacked json 55 | """ 56 | response = self._generic_http_method_request( 57 | "put", route, **kwargs 58 | ) 59 | return self._handle_json_response(response) 60 | 61 | def patch(self, route, **kwargs): 62 | """ 63 | :returns updated_object: updated object, as an unpacked json 64 | """ 65 | response = self._generic_http_method_request( 66 | "patch", route, **kwargs 67 | ) 68 | return self._handle_json_response(response) 69 | 70 | def delete(self, route, **kwargs): 71 | """ 72 | :returns req_answer: answer as a requests object (as `delete` does not 73 | return any data) 74 | """ 75 | return self._generic_http_method_request( 76 | "delete", route, **kwargs 77 | ) 78 | 79 | def options(self, route, **kwargs): 80 | """ 81 | :returns results: answer, as an unpacked json 82 | """ 83 | response = self._generic_http_method_request("options", route, **kwargs) 84 | return self._handle_json_response(response) 85 | 86 | def _generic_http_method_request(self, method, route, **kwargs): 87 | http_method = getattr(self.session, method) 88 | req_url = "{}/{}".format(self.url.rstrip("/"), route.lstrip("/")) 89 | if self.username and self.password: 90 | response = http_method( 91 | req_url, auth=(self.username, self.password), **kwargs 92 | ) 93 | elif self.token: 94 | response = http_method( 95 | req_url, auth=_HTTPTokenAuth(self.token), **kwargs 96 | ) 97 | else: 98 | response = http_method(req_url, **kwargs) 99 | 100 | response.raise_for_status() 101 | return response 102 | 103 | def build_model_url(self, app_name, model): 104 | return "{}/{}".format( 105 | self.url.rstrip("/"), 106 | self.build_model_route(app_name, model).lstrip("/") 107 | ) 108 | 109 | def build_model_route(self, app_name, model): 110 | return "{}/{}/".format(app_name, model) 111 | 112 | def _handle_json_response(self, response): 113 | json_response = response.json() 114 | return json_response 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Python Netbox API 2 | ================= 3 | 4 | [![Build Status](https://travis-ci.org/scaleway/python-netboxapi.svg?branch=master)](https://travis-ci.org/scaleway/python-netboxapi) [![Coverage Status](https://coveralls.io/repos/github/scaleway/python-netboxapi/badge.svg?branch=master)](https://coveralls.io/github/scaleway/python-netboxapi?branch=master) 5 | 6 | Python client API for Netbox, using requests. 7 | 8 | 9 | Usage 10 | ----- 11 | 12 | Netbox API 13 | ========== 14 | 15 | Import `NetboxAPI`: 16 | 17 | ```python 18 | from netboxapi import NetboxAPI 19 | ``` 20 | 21 | Initialize a new `NetboxAPI` object: 22 | 23 | ```python 24 | netbox_api = NetboxAPI(url="netbox.example.com/api") 25 | 26 | # or if you enabled the authentication 27 | netbox_api = NetboxAPI( 28 | url="netbox.example.com/api", username="user", password="password" 29 | ) 30 | 31 | # or if you have generated a token 32 | netbox_api = NetboxAPI( 33 | url="netbox.example.com/api", token="token" 34 | ) 35 | 36 | # but the following is useless, as the token will not be used 37 | netbox_api = NetboxAPI( 38 | url="netbox.example.com/api", username="user", password="password", 39 | token="token" 40 | ) 41 | ``` 42 | 43 | Then use multiple available methods to interact with the api: 44 | 45 | ```python 46 | >>> netbox_api.get("dcim/sites/1/racks/") 47 | { 48 | "id": 1, 49 | "name": "Some rack", 50 | … 51 | } 52 | 53 | >>> netbox_api.post("dcim/device-roles/", json={"name": "test", …},) 54 | { 55 | "id": 1, 56 | "name": "test", 57 | … 58 | } 59 | 60 | >>> netbox_api.patch("dcim/device-roles/", json={"slug": "test"},) 61 | { 62 | "id": 1, 63 | "name": "test", 64 | "slug": "test", 65 | … 66 | } 67 | 68 | >>> netbox_api.put("dcim/device-roles/1/", json={"name": "test", …},) 69 | { 70 | "id": 1, 71 | "name": "test", 72 | "slug": "test", 73 | … 74 | } 75 | 76 | >>> netbox_api.delete("dcim/sites/1/") 77 | <> 78 | ``` 79 | 80 | Netbox Mapper 81 | ============= 82 | 83 | `NetboxMapper` is available to interact with Netbox objects. Received json from 84 | the netbox API is converted into mapper objects, by setting its attributes 85 | accordingly to the dict. To use it, first import `NetboxMapper`: 86 | 87 | ```python 88 | from netboxapi import NetboxAPI, NetboxMapper 89 | ``` 90 | 91 | Initialize a new `NetboxMapper` object: 92 | 93 | ```python 94 | netbox_api = NetboxAPI( 95 | url="netbox.example.com/api", username="user", password="password" 96 | ) 97 | netbox_mapper = NetboxMapper(netbox_api, app_name="dcim", model="sites") 98 | ``` 99 | 100 | ### GET 101 | 102 | Then get all objects of the model: 103 | 104 | ```python 105 | >>> sites = list(netbox_mapper.get()) 106 | [, , …] 107 | 108 | >>> print(sites[0].id) 109 | 1 110 | >>> print(sites[0].name) 111 | "Some site" 112 | ``` 113 | 114 | Or get a specific site by its id: 115 | 116 | ```python 117 | >>> netbox_mapper.get(1) 118 | ``` 119 | 120 | It is possible to get a subresourses of an object, and/or specify a query: 121 | 122 | ```python 123 | >>> netbox_mapper.get("1", "racks", q="name_to_filter") 124 | ``` 125 | 126 | Any `kwargs` (here `q=`) is used as a GET parameter for the request. 127 | 128 | Pagination is transparently handled, but it is possible to specify how many 129 | items are wanted per page by setting the GET parameter `limit`, to limit 130 | the number of requests done to Netbox in case of long iterations. 131 | 132 | #### Foreign keys 133 | 134 | Foreign keys are handle automatically by the mapper. 135 | 136 | ```python 137 | >>> site = next(netbox_mapper.get()) 138 | >>> print(site.region.name) 139 | "Some region" 140 | ``` 141 | 142 | When accessing to `site.region`, a query will be done to fetch the foreign 143 | object. It will then be saved in cache to avoid unnecessary queries for next 144 | accesses. 145 | 146 | To refresh an object and its foreign keys, just do: 147 | 148 | ```python 149 | >>> site = next(site.get()) 150 | ``` 151 | 152 | ### POST 153 | 154 | Use the `kwargs` of a mapper to send a post request and create a new object: 155 | 156 | ```python 157 | >>> netbox_mapper.post(name="A site", slug="a_site", region="Some region") 158 | # corresponding to the new created object 159 | ``` 160 | 161 | If a mapper is sent as parameter, `post()` will automatically take its id. 162 | However, it will not update the foreign object. 163 | 164 | ### PUT 165 | 166 | Use `put()` in a child mapper to update the resource upstream by reflecting 167 | the changes made in the object attributes: 168 | 169 | ```python 170 | >>> child_mapper = netbox_mapper.get(1) 171 | >>> child_mapper.name = "another name" 172 | >>> child_mapper.put() 173 | # requests object containing the netbox response 174 | ``` 175 | 176 | ### PATCH 177 | 178 | `PATCH` is not supported in mappers, as it does not make really sense (to me) 179 | with the mapper logic. 180 | 181 | ### DELETE 182 | 183 | Delete an object upstream by calling `delete()`: 184 | 185 | ```python 186 | >>> netbox_mapper.delete(1) 187 | # requests object containing the netbox response 188 | 189 | # OR 190 | 191 | >>> child_mapper = netbox_mapper.get(1) 192 | >>> child_mapper.delete() 193 | # requests object containing the netbox response 194 | ``` 195 | 196 | But trying to delete another object of the same model from a child mapper is 197 | not possible: 198 | 199 | ```python 200 | >>> child_mapper = netbox_mapper.get(1) 201 | >>> child_mapper.delete(2) 202 | Exception ForbiddenAsChildError 203 | ``` 204 | 205 | Dependencies 206 | ------------ 207 | * python 3.4 (it certainly works with prior versions, just not tested) 208 | 209 | 210 | License 211 | ------- 212 | 213 | Tool under the BSD license. Do not hesitate to report bugs, ask me some 214 | questions or do some pull request if you want to! 215 | -------------------------------------------------------------------------------- /netboxapi/mapper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import requests 4 | 5 | from .api import NetboxAPI 6 | from .exceptions import ForbiddenAsChildError, ForbiddenAsPassiveMapperError 7 | 8 | 9 | logger = logging.getLogger("netboxapi") 10 | 11 | 12 | class NetboxMapper(): 13 | def __init__(self, netbox_api, app_name, model, route=None): 14 | self.netbox_api = netbox_api 15 | self.__app_name__ = app_name 16 | self.__model__ = model 17 | self.__upstream_attrs__ = [] 18 | self.__foreign_keys__ = [] 19 | self.__original_foreign_keys_id__ = {} 20 | 21 | #: cache for foreign keys properties. 22 | self._fk_cache = {} 23 | 24 | self._route = ( 25 | route or 26 | self.netbox_api.build_model_route( 27 | self.__app_name__, self.__model__ 28 | ) 29 | ).rstrip("/") + "/" 30 | 31 | def __eq__(self, other): 32 | if not isinstance(other, NetboxMapper): 33 | return False 34 | 35 | if other._route != self._route: 36 | return False 37 | 38 | return self.to_dict() == other.to_dict() 39 | 40 | def get(self, *args, limit=50, **kwargs): 41 | """ 42 | Get netbox objects 43 | 44 | Use args and kwargs as filters: all *args items are joined with "/", 45 | and kwargs are use as data parameter. It is then used to build the 46 | request uri 47 | 48 | Example: 49 | >>> netbox_mapper.__app_name__ = "dcim" 50 | >>> netbox_mapper.__model__ = "sites" 51 | >>> netbox_mapper.get("1", "racks", q="name_to_filter") 52 | 53 | Will do a request to "/dcim/sites/1/racks/?q=name_to_filter" 54 | 55 | Some specific routes will not return objects with ID (this one for 56 | example: `/ipam/prefixes/{id}/available-prefixes/`). In this case, no 57 | mapper will be built from the result and it will be yield as received. 58 | """ 59 | kwargs.setdefault("limit", limit) 60 | self._replace_params_mappers_by_id(kwargs) 61 | 62 | if args: 63 | route = self._route + "/".join(str(a) for a in args) + "/" 64 | else: 65 | route = self._route 66 | 67 | new_mappers_props = self._iterate_over_get_query(route, kwargs) 68 | for nm_prop in new_mappers_props: 69 | try: 70 | if getattr(self, "id", None) is not None: 71 | new_mapper_route = route 72 | else: 73 | new_mapper_route = self._route + "{}/".format( 74 | nm_prop["id"] 75 | ) 76 | 77 | yield self._build_new_mapper_from(nm_prop, new_mapper_route) 78 | except (KeyError, TypeError): 79 | # Result objects have no id, cannot build a mapper from them, 80 | # yield them as received 81 | yield nm_prop 82 | yield from new_mappers_props 83 | return 84 | 85 | def _replace_params_mappers_by_id(self, params): 86 | """ 87 | Find mappers in a dict and replace them by their id 88 | 89 | Useful to send requests containing a mapper 90 | """ 91 | for k, v in params.items(): 92 | if isinstance(v, NetboxMapper): 93 | try: 94 | params[k] = v.id 95 | except AttributeError: 96 | raise ValueError("Mapper {} has no id".format(k)) 97 | 98 | def _iterate_over_get_query(self, route, params): 99 | """ 100 | Iterate over a get query and handle possible pagination 101 | """ 102 | while True: 103 | response = self.netbox_api.get(route, params=params) 104 | if "results" in response: 105 | new_mappers_props = response["results"] 106 | else: 107 | if isinstance(response, list): 108 | yield from response 109 | else: 110 | yield response 111 | return 112 | 113 | yield from new_mappers_props 114 | 115 | next_url = response.get("next") 116 | if next_url: 117 | params["offset"] = params.get("offset", 0) + params["limit"] 118 | else: 119 | return 120 | 121 | def post(self, **json): 122 | """ 123 | Post a new netbox object 124 | 125 | Use **json to build a json that will be used to package the new object 126 | atributes. Meant to be use for a "root mapper" (a mapper not directly 127 | linked to a resource, parent of children mappers). 128 | 129 | Example: 130 | >>> netbox_mapper.__app_name__ = "dcim" 131 | >>> netbox_mapper.__model__ = "console-ports" 132 | >>> netbox_mapper.post(device="A device", cs_port="cs port", 133 | ... name="example") 134 | 135 | 136 | :returns: child_mapper: Mapper containing the created object 137 | """ 138 | self._replace_params_mappers_by_id(json) 139 | new_mapper_dict = self.netbox_api.post(self._route, json=json) 140 | try: 141 | return next(self.get(new_mapper_dict["id"])) 142 | except requests.exceptions.HTTPError as e: 143 | if e.response.status_code == 404: 144 | logger.debug( 145 | "Rare case of weird endpoint where object cannot be fetch " 146 | "after POST by using the same endpoint. Returning a " 147 | "mapper based on this answer instead of fetching the " 148 | "entire object." 149 | "" 150 | "Do not try to put this mapper as it will fail." 151 | ) 152 | return self._build_new_mapper_from( 153 | new_mapper_dict, 154 | self._route + "{}/".format(new_mapper_dict["id"]), 155 | passive_mapper=True 156 | ) 157 | 158 | def put(self): 159 | """ 160 | Update an already existing netbox object 161 | 162 | Use all mapper attributes contained in self.__upstream_attrs__ to build 163 | a json and send a put request to netbox. 164 | 165 | Example: 166 | >>> netbox_mapper.__app_name__ = "dcim" 167 | >>> netbox_mapper.__model__ = "console-ports" 168 | >>> child_mapper = netbox_mapper.get(1) 169 | >>> child_mapper.name = "another name" 170 | >>> child_mapper.put() 171 | 172 | :returns: request_reponse: requests object containing the netbox 173 | response 174 | """ 175 | assert getattr(self, "id", None) is not None, "self.id does not exist" 176 | 177 | return self.netbox_api.put(self._route, json=self.to_dict()) 178 | 179 | def to_dict(self): 180 | serialize = {} 181 | foreign_keys = self.__foreign_keys__.copy() 182 | exclude = ("created", "last_updated") 183 | for a in self.__upstream_attrs__: 184 | if a in exclude: 185 | continue 186 | 187 | val = getattr(self, a, None) 188 | if isinstance(val, dict): 189 | if "value" in val and "label" in val: 190 | val = val["value"] 191 | elif isinstance(val, NetboxMapper): 192 | foreign_keys.append(a) 193 | continue 194 | 195 | serialize[a] = val 196 | 197 | for fk in foreign_keys: 198 | if fk in exclude: 199 | continue 200 | 201 | if hasattr(self, "_{}_id".format(fk)): 202 | serialize[fk] = getattr(self, "_{}_id".format(fk), None) 203 | else: 204 | serialize[fk] = self._get_foreign_object_id( 205 | getattr(self, fk, None) 206 | ) 207 | 208 | return serialize 209 | 210 | def delete(self, id=None): 211 | """ 212 | Delete netbox object or self 213 | 214 | Example: 215 | >>> netbox_mapper.__app_name__ = "dcim" 216 | >>> netbox_mapper.__model__ = "sites" 217 | >>> netbox_mapper.delete(1) 218 | 219 | Will delete the `Site` object with `id=1`. It is the same as doing: 220 | 221 | >>> netbox_mapper.__app_name__ = "dcim" 222 | >>> netbox_mapper.__model__ = "sites" 223 | >>> child_mapper = netbox_mapper.get(1) 224 | >>> child_mapper.delete() 225 | 226 | :param id: id to delete. Only root mappers can delete any id, so if 227 | self has already an id, it will be considered as a child (a single 228 | object in netbox) and will not be able to delete other ID than 229 | itself. In this case, specifying an ID will conflict and raise a 230 | ForbiddenAsChildError 231 | """ 232 | if id is not None and getattr(self, "id", None) is not None: 233 | raise ForbiddenAsChildError( 234 | "Cannot specify an ID to delete when self is a mapper child " 235 | "and has an ID" 236 | ) 237 | elif id is None and getattr(self, "id", None) is None: 238 | raise ValueError("Delete needs an id when self.id does not exist") 239 | 240 | delete_route = self._route + "{}/".format(id) if id else self._route 241 | return self.netbox_api.delete(delete_route) 242 | 243 | def options(self): 244 | """ 245 | Get netbox options on a model 246 | 247 | Example: 248 | >>> netbox_mapper.__app_name__ = "dcim" 249 | >>> netbox_mapper.__model__ = "devices" 250 | >>> netbox_mapper.options() 251 | 252 | Will do an OPTIONS request to "/dcim/devices/" 253 | """ 254 | return self.netbox_api.options(self._route) 255 | 256 | 257 | def _build_new_mapper_from( 258 | self, mapper_attributes, new_route, passive_mapper=False 259 | ): 260 | cls = NetboxPassiveMapper if passive_mapper else NetboxMapper 261 | mapper_class = type( 262 | "NetboxMapper_{}_{}".format( 263 | re.sub("_|-", "", self.__model__.title()), 264 | re.sub("_|-", "", "".join( 265 | s.title() for s in new_route.split("/") 266 | )) 267 | ), (cls,), {} 268 | ) 269 | 270 | mapper = mapper_class( 271 | self.netbox_api, self.__app_name__, self.__model__, new_route 272 | ) 273 | mapper.__upstream_attrs__ = [] 274 | mapper.__foreign_keys__ = [] 275 | for attr, val in mapper_attributes.items(): 276 | if isinstance(val, dict) and "id" in val and "url" in val: 277 | mapper.__foreign_keys__.append(attr) 278 | mapper.__original_foreign_keys_id__[attr] = val["id"] 279 | mapper._set_property_foreign_key(attr, val) 280 | else: 281 | mapper.__upstream_attrs__.append(attr) 282 | setattr(mapper, attr, val) 283 | 284 | return mapper 285 | 286 | def _set_property_foreign_key(self, attr, value): 287 | def get_foreign_object(*args): 288 | if hasattr(self, "_{}".format(attr)): 289 | return getattr(self, "_{}".format(attr)) 290 | 291 | if attr in self._fk_cache: 292 | return self._fk_cache[attr] 293 | 294 | url = value["url"] 295 | route = url.replace(self.netbox_api.url, "", 1).lstrip("/") 296 | model, app_name, *params = route.split("/") 297 | 298 | fk = list(NetboxMapper(self.netbox_api, model, app_name).get( 299 | *[p for p in params if p] 300 | )) 301 | if not fk: 302 | fk = None 303 | elif len(fk) == 1: 304 | fk = fk[0] 305 | 306 | self._fk_cache[attr] = fk 307 | return fk 308 | 309 | def setter(cls, value): 310 | setattr(self, "_{}".format(attr), value) 311 | 312 | def getter_fk_id(*args): 313 | original_id_condition = ( 314 | not hasattr(self, "_{}".format(attr)) and 315 | attr in self.__original_foreign_keys_id__ 316 | ) 317 | 318 | if original_id_condition: 319 | return self.__original_foreign_keys_id__[attr] 320 | else: 321 | return self._get_foreign_object_id(getattr(self, attr)) 322 | 323 | try: 324 | self._fk_cache.pop(attr) 325 | except KeyError: 326 | pass 327 | setattr(type(self), attr, property(get_foreign_object, setter)) 328 | setattr( 329 | type(self), "_{}_id".format(attr), property(getter_fk_id) 330 | ) 331 | 332 | def _get_foreign_object_id(self, fk_obj): 333 | if isinstance(fk_obj, int): 334 | return fk_obj 335 | else: 336 | try: 337 | # check that fk is iterable 338 | iter(fk_obj) 339 | return [getattr(i, "id", None) for i in fk_obj] 340 | except TypeError: 341 | return getattr(fk_obj, "id", None) 342 | 343 | 344 | class NetboxPassiveMapper(NetboxMapper): 345 | def get(self, *args, **kwargs): 346 | raise ForbiddenAsPassiveMapperError() 347 | 348 | def post(self, *args, **kwargs): 349 | raise ForbiddenAsPassiveMapperError() 350 | 351 | def put(self, *args, **kwargs): 352 | raise ForbiddenAsPassiveMapperError() 353 | 354 | def delete(self, *args, **kwargs): 355 | raise ForbiddenAsPassiveMapperError() 356 | -------------------------------------------------------------------------------- /tests/test_mapper.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import pytest 3 | import requests_mock 4 | 5 | from netboxapi import NetboxMapper, NetboxAPI 6 | from netboxapi.mapper import NetboxPassiveMapper 7 | from netboxapi.exceptions import ( 8 | ForbiddenAsChildError, ForbiddenAsPassiveMapperError 9 | ) 10 | 11 | 12 | class TestNetboxMapper(): 13 | url = "http://localhost/api" 14 | api = NetboxAPI(url) 15 | test_app_name = "test_app_name" 16 | test_model = "test_model" 17 | 18 | @pytest.fixture() 19 | def mapper(self): 20 | return self.get_mapper() 21 | 22 | def get_mapper(self): 23 | return NetboxMapper(self.api, self.test_app_name, self.test_model) 24 | 25 | def test_get(self, mapper): 26 | url = self.get_mapper_url(mapper) 27 | expected_attr = { 28 | "count": 1, "next": None, "previous": None, 29 | "results": [{"id": 1, "name": "test"}] 30 | } 31 | with requests_mock.Mocker() as m: 32 | m.register_uri("get", url, json=expected_attr) 33 | child_mapper = next(mapper.get()) 34 | 35 | for key, val in expected_attr["results"][0].items(): 36 | assert getattr(child_mapper, key) == val 37 | 38 | def test_get_submodel(self, mapper): 39 | url = self.get_mapper_url(mapper) 40 | expected_attr = { 41 | "count": 1, "next": None, "previous": None, 42 | "results": [{"id": 1, "name": "first_model"}] 43 | } 44 | 45 | with requests_mock.Mocker() as m: 46 | m.register_uri( 47 | "get", url, 48 | json={ 49 | "count": 1, "next": None, "previous": None, 50 | "results": [{"id": 1}] 51 | } 52 | ) 53 | parent_mapper = next(mapper.get()) 54 | m.register_uri( 55 | "get", url + "{}/{}/".format(parent_mapper.id, "submodel"), 56 | json=expected_attr 57 | ) 58 | submodel_mapper = next(parent_mapper.get("submodel")) 59 | 60 | for key, val in expected_attr["results"][0].items(): 61 | assert getattr(submodel_mapper, key) == val 62 | 63 | def test_get_id(self, mapper): 64 | url = self.get_mapper_url(mapper) + "1/" 65 | expected_attr = {"id": 1, "name": "test"} 66 | with requests_mock.Mocker() as m: 67 | m.register_uri("get", url, json=expected_attr) 68 | obj = next(mapper.get(1)) 69 | 70 | assert obj.name == "test" 71 | assert obj.id == 1 72 | 73 | def test_get_update_obj(self, mapper): 74 | url = self.get_mapper_url(mapper) + "1/" 75 | expected_attr = {"id": 1, "name": "test"} 76 | with requests_mock.Mocker() as m: 77 | m.register_uri("get", url, json=expected_attr) 78 | obj = next(mapper.get(1)) 79 | obj_clone = next(obj.get()) 80 | obj_clone = next(obj_clone.get()) 81 | 82 | assert obj.name == obj_clone.name 83 | assert obj.id == obj_clone.id 84 | 85 | def test_get_query(self, mapper): 86 | url = self.get_mapper_url(mapper) + "?name=test" 87 | expected_attr = { 88 | "count": 1, "next": None, "previous": None, 89 | "results": [{"id": 1, "name": "test"}] 90 | } 91 | with requests_mock.Mocker() as m: 92 | m.register_uri("get", url, json=expected_attr) 93 | next(mapper.get(name="test")) 94 | 95 | def test_get_submodel_without_id(self, mapper): 96 | url = self.get_mapper_url(mapper) 97 | expected_attr = [ 98 | {"vrf": None, "name": "test"}, 99 | {"vrf": 1, "name": "test2"} 100 | ] 101 | with requests_mock.Mocker() as m: 102 | m.register_uri("get", url, json=expected_attr) 103 | next(mapper.get()) 104 | 105 | def test_get_pagination(self, mapper): 106 | url = self.get_mapper_url(mapper) 107 | next_url = url + "?limit=50&offset=50" 108 | nb_obj = 75 109 | results = [ 110 | {"id": i, "name": "test{}".format(i)} for i in range(nb_obj) 111 | ] 112 | 113 | with requests_mock.Mocker() as m: 114 | m.register_uri( 115 | "get", url, json={ 116 | "count": nb_obj, "next": next_url, 117 | "previous": None, "results": results[:50] 118 | } 119 | ) 120 | m.register_uri( 121 | "get", next_url, json={ 122 | "count": nb_obj, 123 | "previous": None, "results": results[50:] 124 | } 125 | ) 126 | received_list = tuple(mapper.get(limit=50)) 127 | 128 | assert len(results) == len(received_list) 129 | for expected, received in zip(results, received_list): 130 | assert expected["id"] == received.id 131 | assert expected["name"] == received.name 132 | 133 | def test_get_submodel_with_choice(self, mapper): 134 | """ 135 | Choices are enum handled by netbox. Try to get a model with it. 136 | """ 137 | url = self.get_mapper_url(mapper) 138 | expected_attr = { 139 | "count": 1, "next": None, "previous": None, 140 | "results": [{ 141 | "id": 1, "name": "test", "choice": { 142 | "value": 1, "label": "Some choice" 143 | } 144 | }] 145 | } 146 | with requests_mock.Mocker() as m: 147 | m.register_uri("get", url, json=expected_attr) 148 | next(mapper.get()) 149 | 150 | def test_cache_foreign_key(self, mapper): 151 | attr = { 152 | "id": 1, "name": "test", 153 | "vrf": { 154 | "id": 1, "name": "vrf_test", 155 | "url": mapper.netbox_api.build_model_url("ipam", "vrfs") + "1/" 156 | } 157 | } 158 | child_mapper = self._get_child_mapper_variable_attr(mapper, attr) 159 | 160 | with requests_mock.Mocker() as m: 161 | m.register_uri( 162 | "get", attr["vrf"]["url"], json={ 163 | "id": 1, "name": "vrf_test" 164 | } 165 | ) 166 | vrf = child_mapper.vrf 167 | 168 | # request mocker is down, so any new request will fail 169 | assert vrf == child_mapper.vrf 170 | 171 | def test_multiple_get_foreign_key(self, mapper): 172 | """ 173 | Test multiple get on the same object to control foreign keys behavior 174 | 175 | As foreign keys are properties, a unique class is done to any object 176 | based on its route. But if multiple successiveget are done on the same 177 | object, the class does not change and its properties should be 178 | overriden. 179 | """ 180 | attr = { 181 | "id": 1, "name": "test", 182 | "vrf": { 183 | "id": 1, "name": "vrf_test", 184 | "url": mapper.netbox_api.build_model_url("ipam", "vrfs") + "1/" 185 | } 186 | } 187 | child_mapper = self._get_child_mapper_variable_attr(mapper, attr) 188 | 189 | with requests_mock.Mocker() as m: 190 | m.register_uri( 191 | "get", attr["vrf"]["url"], json={ 192 | "id": 1, "name": "vrf_test" 193 | } 194 | ) 195 | assert child_mapper.vrf.id == 1 196 | 197 | attr["vrf"] = { 198 | "id": 2, 199 | "url": mapper.netbox_api.build_model_url("ipam", "vrfs") + "2/" 200 | } 201 | with requests_mock.Mocker() as m: 202 | m.register_uri( 203 | "get", self.get_mapper_url(child_mapper) + "1/", 204 | json=attr 205 | ) 206 | m.register_uri( 207 | "get", attr["vrf"]["url"], json={ 208 | "id": 2, "name": "vrf_test2" 209 | } 210 | ) 211 | child_mapper = next(child_mapper.get()) 212 | assert child_mapper.vrf.id == 2 213 | 214 | def _get_child_mapper_variable_attr(self, mapper, expected_attr): 215 | """ 216 | Get child mapper with expected_attr as parameter 217 | """ 218 | url = self.get_mapper_url(mapper) 219 | with requests_mock.Mocker() as m: 220 | m.register_uri("get", url, json=expected_attr) 221 | child_mapper = next(mapper.get()) 222 | 223 | return child_mapper 224 | 225 | def test_post(self, mapper): 226 | url = self.get_mapper_url(mapper) 227 | 228 | with requests_mock.Mocker() as m: 229 | received_req = m.register_uri( 230 | "post", url, json=self.update_or_create_resource_json_callback 231 | ) 232 | m.register_uri( 233 | "get", url + "1/", 234 | json={ 235 | "count": 1, "next": None, "previous": None, 236 | "results": [{"id": 1, "name": "testname"}] 237 | } 238 | ) 239 | child_mapper = mapper.post(name="testname") 240 | 241 | assert child_mapper.id == 1 242 | assert child_mapper.name == "testname" 243 | 244 | def test_post_with_failing_get(self, mapper): 245 | url = self.get_mapper_url(mapper) 246 | 247 | with requests_mock.Mocker() as m: 248 | m.register_uri( 249 | "post", url, json=self.update_or_create_resource_json_callback 250 | ) 251 | m.register_uri( 252 | "get", url + "1/", 253 | text="Not Found", status_code=404 254 | ) 255 | child_mapper = mapper.post(name="testname") 256 | 257 | assert isinstance(child_mapper, NetboxPassiveMapper) 258 | assert child_mapper.id == 1 259 | assert child_mapper.name == "testname" 260 | 261 | for m in ("get", "put", "post", "delete"): 262 | with pytest.raises(ForbiddenAsPassiveMapperError): 263 | getattr(child_mapper, m)() 264 | 265 | def update_or_create_resource_json_callback(self, request, context): 266 | json = request.json() 267 | json["id"] = 1 268 | assert "created" not in json 269 | assert "last_updated" not in json 270 | return json 271 | 272 | def test_post_foreign_key(self, mapper): 273 | url = self.get_mapper_url(mapper) 274 | 275 | fk_mapper = NetboxMapper(mapper.netbox_api, "foo", "bar") 276 | fk_mapper.id = 2 277 | 278 | with requests_mock.Mocker() as m: 279 | received_req = m.register_uri( 280 | "post", url, json={ 281 | "id": 1, "name": "testname", "fk": {"id": 2} 282 | } 283 | ) 284 | m.register_uri( 285 | "get", url + "1/", 286 | json={ 287 | "count": 1, "next": None, "previous": None, 288 | "results": [{ 289 | "id": 1, "name": "testname", "fk": { 290 | "id": 2, 291 | "url": self.get_mapper_url(fk_mapper) + "2/" 292 | } 293 | }] 294 | } 295 | ) 296 | mapper.post(name="testname", fk=fk_mapper) 297 | 298 | assert received_req.last_request.json()["fk"] == 2 299 | 300 | def test_post_foreign_key_broken_mapper(self, mapper): 301 | url = self.get_mapper_url(mapper) 302 | 303 | fk_mapper = NetboxMapper(mapper.netbox_api, "foo", "bar") 304 | 305 | with requests_mock.Mocker() as m: 306 | m.register_uri( 307 | "post", url, json={ 308 | "id": 1, "name": "testname", "fk": {"id": 2} 309 | } 310 | ) 311 | with pytest.raises(ValueError): 312 | mapper.post(name="testname", fk=fk_mapper) 313 | 314 | def test_put(self, mapper): 315 | child_mapper = self.get_child_mapper(mapper) 316 | url = self.get_mapper_url(child_mapper) + "{}/".format(child_mapper.id) 317 | with requests_mock.Mocker() as m: 318 | received_req = m.register_uri( 319 | "put", url, json=self.update_or_create_resource_json_callback 320 | ) 321 | child_mapper.name = "another testname" 322 | child_mapper.put() 323 | 324 | req_json = received_req.last_request.json() 325 | assert req_json["name"] == child_mapper.name 326 | 327 | def get_child_mapper(self, mapper): 328 | expected_attr = { 329 | "count": 1, "next": None, "previous": None, 330 | "results": [{"id": 1, "name": "test"}] 331 | } 332 | return self._get_child_mapper_variable_attr(mapper, expected_attr) 333 | 334 | def test_put_with_foreign_key(self, mapper): 335 | """ 336 | Test that objects that are foreign keys are put by their ID 337 | """ 338 | child_mapper = self.get_child_mapper_foreign_key(mapper) 339 | vrf_mapper = NetboxMapper(self.api, "ipam", "vrfs") 340 | 341 | with requests_mock.Mocker() as m: 342 | m.register_uri( 343 | "get", self.get_mapper_url(vrf_mapper) + "1/", 344 | json={ 345 | "count": 1, "next": None, "previous": None, 346 | "results": [{"id": 1, "name": "test_vrf"}] 347 | } 348 | ) 349 | child_mapper_url = ( 350 | self.get_mapper_url(child_mapper) + 351 | "{}/".format(child_mapper.id) 352 | ) 353 | received_req = m.register_uri( 354 | "put", child_mapper_url, 355 | json=self.update_or_create_resource_json_callback 356 | ) 357 | 358 | child_mapper.put() 359 | 360 | req_json = received_req.last_request.json() 361 | assert isinstance(req_json["vrf"], int) 362 | 363 | def test_put_with_null_foreign_key(self, mapper): 364 | """ 365 | Test PUT with an object previously having a null foreign key 366 | """ 367 | expected_attr = { 368 | "id": 1, "name": "test", "vrf": None, 369 | } 370 | child_mapper = self._get_child_mapper_variable_attr( 371 | mapper, expected_attr 372 | ) 373 | 374 | vrf_mapper = NetboxMapper(self.api, "ipam", "vrfs") 375 | child_vrf_mapper = self._get_child_mapper_variable_attr( 376 | vrf_mapper, { 377 | "count": 1, "next": None, "previous": None, 378 | "results": [{"id": 1, "name": "test_vrf"}] 379 | } 380 | ) 381 | 382 | with requests_mock.Mocker() as m: 383 | child_mapper_url = ( 384 | self.get_mapper_url(child_mapper) + 385 | "{}/".format(child_mapper.id) 386 | ) 387 | m.register_uri( 388 | "put", child_mapper_url, 389 | json=self.update_or_create_resource_json_callback 390 | ) 391 | child_mapper.vrf = child_vrf_mapper 392 | req_json = child_mapper.put() 393 | 394 | assert req_json["vrf"] == child_vrf_mapper.id 395 | 396 | def test_put_with_int_foreign_key(self, mapper): 397 | """ 398 | Test PUT with an object having an id as foreign key, and not a mapper 399 | """ 400 | child_mapper = self.get_child_mapper_foreign_key(mapper) 401 | child_mapper.vrf = 2 402 | 403 | with requests_mock.Mocker() as m: 404 | child_mapper_url = ( 405 | self.get_mapper_url(child_mapper) + 406 | "{}/".format(child_mapper.id) 407 | ) 408 | received_req = m.register_uri( 409 | "put", child_mapper_url, 410 | json=self.update_or_create_resource_json_callback 411 | ) 412 | 413 | child_mapper.put() 414 | 415 | req_json = received_req.last_request.json() 416 | assert req_json["vrf"] == 2 417 | 418 | def get_child_mapper_foreign_key(self, mapper): 419 | expected_attr = { 420 | "id": 1, "name": "test", 421 | "vrf": { 422 | "id": 1, "name": "vrf_test", 423 | "url": "{}/{}/".format( 424 | mapper.netbox_api.build_model_url("ipam", "vrfs"), 1 425 | ), 426 | }, "choice": {"value": 1, "label": "Choice"}, 427 | "created": "1970-01-01", "last_updated": "1970-01-01T00:00:00Z", 428 | } 429 | return self._get_child_mapper_variable_attr(mapper, expected_attr) 430 | 431 | def test_put_choice(self, mapper): 432 | """ 433 | Choices are enum handled by netbox. Try to get a model with it. 434 | """ 435 | child_mapper = self.get_child_mapper_with_choice(mapper) 436 | url = self.get_mapper_url(child_mapper) + "{}/".format(child_mapper.id) 437 | with requests_mock.Mocker() as m: 438 | received_req = m.register_uri( 439 | "put", url, json=self.update_or_create_resource_json_callback 440 | ) 441 | child_mapper.put() 442 | 443 | req_json = received_req.last_request.json() 444 | assert req_json["choice"] == child_mapper.choice["value"] 445 | 446 | def get_child_mapper_with_choice(self, mapper): 447 | expected_attr = { 448 | "id": 1, "name": "test", 449 | "choice": {"value": 1, "label": "Choice"} 450 | } 451 | return self._get_child_mapper_variable_attr(mapper, expected_attr) 452 | 453 | def test_delete(self, mapper): 454 | url = self.get_mapper_url(mapper) 455 | with requests_mock.Mocker() as m: 456 | m.register_uri("delete", url + "1/") 457 | req = mapper.delete(1) 458 | 459 | assert req.status_code == 200 460 | 461 | def test_delete_without_id(self, mapper): 462 | with pytest.raises(ValueError): 463 | mapper.delete() 464 | 465 | def test_delete_from_child(self, mapper): 466 | url = self.get_mapper_url(mapper) + "1/" 467 | 468 | with requests_mock.Mocker() as m: 469 | m.register_uri( 470 | "get", url, json={"id": 1} 471 | ) 472 | obj_mapper = next(mapper.get(1)) 473 | 474 | with requests_mock.Mocker() as m: 475 | m.register_uri("delete", url) 476 | response = obj_mapper.delete() 477 | 478 | assert response.status_code == 200 479 | 480 | def test_delete_other_id_from_child(self, mapper): 481 | url = self.get_mapper_url(mapper) + "1/" 482 | 483 | with requests_mock.Mocker() as m: 484 | m.register_uri( 485 | "get", url, json={"id": 1} 486 | ) 487 | obj_mapper = next(mapper.get(1)) 488 | 489 | with pytest.raises(ForbiddenAsChildError): 490 | obj_mapper.delete(1) 491 | 492 | def test_eq_equals(self, mapper): 493 | assert mapper == self.get_mapper() 494 | 495 | def test_eq_not_equals(self, mapper): 496 | assert mapper != NetboxMapper(self.api, "an_app", "a_model") 497 | 498 | def test_eq_equals_child_mapper(self, mapper): 499 | expected_attr = { 500 | "count": 1, "next": None, "previous": None, 501 | "results": [{ 502 | "id": 1, "name": "test", "created": "1970-01-01", 503 | "last_updated": "1970-01-01T00:00:00Z" 504 | }] 505 | } 506 | url = self.get_mapper_url(mapper) 507 | with requests_mock.Mocker() as m: 508 | m.register_uri("get", url, json=expected_attr) 509 | child_mapper = next(mapper.get()) 510 | 511 | expected_attr["results"][0]["created"] = "1971-01-01" 512 | expected_attr["results"][0]["last_updated"] = "1971-01-01T00:00:00Z" 513 | with requests_mock.Mocker() as m: 514 | m.register_uri("get", url, json=expected_attr) 515 | copy_child_mapper = next(mapper.get()) 516 | 517 | assert child_mapper == copy_child_mapper 518 | copy_child_mapper.name = "else" 519 | assert child_mapper != copy_child_mapper 520 | 521 | def get_mapper_url(self, mapper): 522 | return mapper.netbox_api.build_model_url( 523 | mapper.__app_name__, mapper.__model__ 524 | ) 525 | --------------------------------------------------------------------------------