Skip to content

Commit

Permalink
Merge pull request #409 from steffenschumacher/#316_bulk_update_delete
Browse files Browse the repository at this point in the history
Fixes #316 bulk update/delete on both Endpoint and RecordSet
  • Loading branch information
zachmoody authored Oct 29, 2021
2 parents cf9a4c8 + 5cbf749 commit 33a7fb9
Show file tree
Hide file tree
Showing 5 changed files with 355 additions and 20 deletions.
123 changes: 123 additions & 0 deletions pynetbox/core/endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,129 @@ def create(self, *args, **kwargs):
return [self.return_obj(i, self.api, self) for i in req]
return self.return_obj(req, self.api, self)

def update(self, objects):
r"""Bulk updates existing objects on an endpoint.
Allows for bulk updating of existing objects on an endpoint.
Objects is a list whic contain either json/dicts or Record
derived objects, which contain the updates to apply.
If json/dicts are used, then the id of the object *must* be
included
:arg list objects: A list of dicts or Record.
:returns: True if the update succeeded
:Examples:
Updating objects on the `devices` endpoint:
>>> device = netbox.dcim.devices.update([
... {'id': 1, 'name': 'test'},
... {'id': 2, 'name': 'test2'},
... ])
>>> True
Use bulk update by passing a list of Records:
>>> devices = nb.dcim.devices.all()
>>> for d in devices:
>>> d.name = d.name+'-test'
>>> nb.dcim.devices.update(devices)
>>> True
"""
series = []
if not isinstance(objects, list):
raise ValueError(
"Objects passed must be list[dict|Record] - was " + type(objects)
)
for o in objects:
if isinstance(o, Record):
data = o.updates()
if data:
data["id"] = o.id
series.append(data)
elif isinstance(o, dict):
if "id" not in o:
raise ValueError("id is missing from object: " + str(o))
series.append(o)
else:
raise ValueError(
"Object passed must be dict|Record - was " + type(objects)
)
req = Request(
base=self.url,
token=self.token,
session_key=self.session_key,
http_session=self.api.http_session,
).patch(series)

if isinstance(req, list):
return [self.return_obj(i, self.api, self) for i in req]
return self.return_obj(req, self.api, self)

def delete(self, objects):
r"""Bulk deletes objects on an endpoint.
Allows for batch deletion of multiple objects from
a single endpoint
:arg list objects: A list of either ids or Records or
a single RecordSet to delete.
:returns: True if bulk DELETE operation was successful.
:Examples:
Deleting all `devices`:
>>> netbox.dcim.devices.delete(netbox.dcim.devices.all(0))
>>>
Use bulk deletion by passing a list of ids:
>>> netbox.dcim.devices.delete([2, 243, 431, 700])
>>>
Use bulk deletion to delete objects eg. when filtering
on a `custom_field`:
>>> netbox.dcim.devices.delete([
>>> d for d in netbox.dcim.devices.all(0) \
>>> if d.custom_fields.get('field', False)
>>> ])
>>>
"""
cleaned_ids = []
if not isinstance(objects, list) and not isinstance(objects, RecordSet):
raise ValueError(
"objects must be list[str|int|Record]"
"|RecordSet - was " + str(type(objects))
)
for o in objects:
if isinstance(o, int):
cleaned_ids.append(o)
elif isinstance(o, str) and o.isnumeric():
cleaned_ids.append(int(o))
elif isinstance(o, Record):
if not hasattr(o, "id"):
raise ValueError(
"Record from '"
+ o.url
+ "' does not have an id and cannot be bulk deleted"
)
cleaned_ids.append(o.id)
else:
raise ValueError(
"Invalid object in list of " "objects to delete: " + str(type(o))
)

req = Request(
base=self.url,
token=self.token,
session_key=self.session_key,
http_session=self.api.http_session,
)
return True if req.delete(data=[{"id": i} for i in cleaned_ids]) else False

def choices(self):
""" Returns all choices from the endpoint.
Expand Down
8 changes: 5 additions & 3 deletions pynetbox/core/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ def normalize_url(self, url):
return url

def _make_call(self, verb="get", url_override=None, add_params=None, data=None):
if verb in ("post", "put"):
if verb in ("post", "put") or verb == "delete" and data:
headers = {"Content-Type": "application/json;"}
else:
headers = {"accept": "application/json;"}
Expand Down Expand Up @@ -386,18 +386,20 @@ def post(self, data):
"""
return self._make_call(verb="post", data=data)

def delete(self):
def delete(self, data=None):
"""Makes DELETE request.
Makes a DELETE request to NetBox's API.
:param data: (list) Contains a dict that will be turned into a
json object and sent to the API.
Returns:
True if successful.
Raises:
RequestError if req.ok doesn't return True.
"""
return self._make_call(verb="delete")
return self._make_call(verb="delete", data=data)

def patch(self, data):
"""Makes PATCH request.
Expand Down
93 changes: 79 additions & 14 deletions pynetbox/core/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,50 @@ def __len__(self):
return 0
return self.request.count

def update(self, **kwargs):
"""Updates kwargs onto all Records in the RecordSet and saves these.
Updates are only sent to the API if a value were changed, and only for
the Records which were changed
:returns: True if the update succeeded, None if no update were required
:example:
>>> result = nb.dcim.devices.filter(site_id=1).update(status='active')
True
>>>
"""
updates = []
for record in self:
# Update each record and determine if anything was updated
for k, v in kwargs.items():
setattr(record, k, v)
record_updates = record.updates()
if record_updates:
# if updated, add the id to the dict and append to list of updates
record_updates["id"] = record.id
updates.append(record_updates)
if updates:
return self.endpoint.update(updates)
else:
return None

def delete(self):
r"""Bulk deletes objects in a RecordSet.
Allows for batch deletion of multiple objects in a RecordSet
:returns: True if bulk DELETE operation was successful.
:Examples:
Deleting offline `devices` on site 1:
>>> netbox.dcim.devices.filter(site_id=1, status="offline").delete()
>>>
"""
return self.endpoint.delete(self)


class Record(object):
"""Create python objects from netbox API responses.
Expand Down Expand Up @@ -429,6 +473,30 @@ def fmt_dict(k, v):
)
return set([i[0] for i in set(current.items()) ^ set(init.items())])

def updates(self):
"""Compiles changes for an existing object into a dict.
Takes a diff between the objects current state and its state at init
and returns them as a dictionary, which will be empty if no changes.
:returns: dict.
:example:
>>> x = nb.dcim.devices.get(name='test1-a3-tor1b')
>>> x.serial
u''
>>> x.serial = '1234'
>>> x.updates()
{'serial': '1234'}
>>>
"""
if self.id:
diff = self._diff()
if diff:
serialized = self.serialize()
return {i: serialized[i] for i in diff}
return {}

def save(self):
"""Saves changes to an existing object.
Expand All @@ -446,20 +514,17 @@ def save(self):
True
>>>
"""
if self.id:
diff = self._diff()
if diff:
serialized = self.serialize()
req = Request(
key=self.id,
base=self.endpoint.url,
token=self.api.token,
session_key=self.api.session_key,
http_session=self.api.http_session,
)
if req.patch({i: serialized[i] for i in diff}):
return True

updates = self.updates()
if updates:
req = Request(
key=self.id,
base=self.endpoint.url,
token=self.api.token,
session_key=self.api.session_key,
http_session=self.api.http_session,
)
if req.patch(updates):
return True
return False

def update(self, data):
Expand Down
95 changes: 95 additions & 0 deletions tests/unit/test_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,58 @@ def test_get_with_filter(self):
test = test_obj.get(name="test")
self.assertEqual(test.id, 123)

def test_delete_with_ids(self):
with patch(
"pynetbox.core.query.Request._make_call", return_value=Mock()
) as mock:
ids = [1, 3, 5]
mock.return_value = True
api = Mock(base_url="http://localhost:8000/api")
app = Mock(name="test")
test_obj = Endpoint(api, app, "test")
test = test_obj.delete(ids)
mock.assert_called_with(verb="delete", data=[{"id": i} for i in ids])
self.assertTrue(test)

def test_delete_with_objects(self):
with patch(
"pynetbox.core.query.Request._make_call", return_value=Mock()
) as mock:
from pynetbox.core.response import Record

ids = [1, 3, 5]
mock.return_value = True
api = Mock(base_url="http://localhost:8000/api")
app = Mock(name="test")
test_obj = Endpoint(api, app, "test")
objects = [
Record({"id": i, "name": "dummy" + str(i)}, api, test_obj) for i in ids
]
test = test_obj.delete(objects)
mock.assert_called_with(verb="delete", data=[{"id": i} for i in ids])
self.assertTrue(test)

def test_delete_with_recordset(self):
with patch(
"pynetbox.core.query.Request._make_call", return_value=Mock()
) as mock:
from pynetbox.core.response import RecordSet

ids = [1, 3, 5]

class FakeRequest:
def get(self):
return iter([{"id": i, "name": "dummy" + str(i)} for i in ids])

mock.return_value = True
api = Mock(base_url="http://localhost:8000/api")
app = Mock(name="test")
test_obj = Endpoint(api, app, "test")
recordset = RecordSet(test_obj, FakeRequest())
test = test_obj.delete(recordset)
mock.assert_called_with(verb="delete", data=[{"id": i} for i in ids])
self.assertTrue(test)

def test_get_greater_than_one(self):
with patch(
"pynetbox.core.query.Request._make_call", return_value=Mock()
Expand All @@ -92,3 +144,46 @@ def test_get_no_results(self):
test_obj = Endpoint(api, app, "test")
test = test_obj.get(name="test")
self.assertIsNone(test)

def test_bulk_update_records(self):
with patch(
"pynetbox.core.query.Request._make_call", return_value=Mock()
) as mock:
from pynetbox.core.response import Record

ids = [1, 3, 5]
mock.return_value = True
api = Mock(base_url="http://localhost:8000/api")
app = Mock(name="test")
test_obj = Endpoint(api, app, "test")
objects = [
Record(
{"id": i, "name": "dummy" + str(i), "unchanged": "yes"},
api,
test_obj,
)
for i in ids
]
for o in objects:
o.name = "fluffy" + str(o.id)
mock.return_value = [o.serialize() for o in objects]
test = test_obj.update(objects)
mock.assert_called_with(
verb="patch", data=[{"id": i, "name": "fluffy" + str(i)} for i in ids]
)
self.assertTrue(test)

def test_bulk_update_json(self):
with patch(
"pynetbox.core.query.Request._make_call", return_value=Mock()
) as mock:
ids = [1, 3, 5]
changes = [{"id": i, "name": "puffy" + str(i)} for i in ids]
mock.return_value = True
api = Mock(base_url="http://localhost:8000/api")
app = Mock(name="test")
mock.return_value = changes
test_obj = Endpoint(api, app, "test")
test = test_obj.update(changes)
mock.assert_called_with(verb="patch", data=changes)
self.assertTrue(test)
Loading

0 comments on commit 33a7fb9

Please sign in to comment.