Merge pull request #6542 from netbox-community/develop

Release v2.11.5
This commit is contained in:
Jeremy Stretch 2021-06-04 09:29:32 -04:00 committed by GitHub
commit fe4de7f929
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 1028 additions and 280 deletions

View File

@ -17,7 +17,7 @@ body:
What version of NetBox are you currently running? (If you don't have access to the most What version of NetBox are you currently running? (If you don't have access to the most
recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/) recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
before opening a bug report to see if your issue has already been addressed.) before opening a bug report to see if your issue has already been addressed.)
placeholder: v2.11.4 placeholder: v2.11.5
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -3,7 +3,10 @@ blank_issues_enabled: false
contact_links: contact_links:
- name: 📖 Contributing Policy - name: 📖 Contributing Policy
url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md
about: Please read through our contributing policy before opening an issue or pull request about: "Please read through our contributing policy before opening an issue or pull request"
- name: 💬 Discussion Group - name: ❓ Discussion
url: https://groups.google.com/g/netbox-discuss url: https://github.com/netbox-community/netbox/discussions
about: Join our discussion group for assistance with installation issues and other problems about: "If you're just looking for help, try starting a discussion instead"
- name: 💬 Community Slack
url: https://netdev.chat/
about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems"

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v2.11.4 placeholder: v2.11.5
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -175,7 +175,7 @@ A particular object within NetBox. Each ObjectVar must specify a particular mode
* `null_option` - A label representing a "null" or empty choice (optional) * `null_option` - A label representing a "null" or empty choice (optional)
!!! warning !!! warning
The `display_field` parameter is now deprecated, and will be removed in NetBox v2.12. All ObjectVar instances will The `display_field` parameter is now deprecated, and will be removed in NetBox v3.0. All ObjectVar instances will
instead use the new standard `display` field for all serializers (introduced in NetBox v2.11). instead use the new standard `display` field for all serializers (introduced in NetBox v2.11).
To limit the selections available within the list, additional query parameters can be passed as the `query_params` dictionary. For example, to show only devices with an "active" status: To limit the selections available within the list, additional query parameters can be passed as the `query_params` dictionary. For example, to show only devices with an "active" status:

View File

@ -80,7 +80,7 @@ class DeviceConnectionsReport(Report):
self.log_success(device) self.log_success(device)
``` ```
As you can see, reports are completely customizable. Validation logic can be as simple or as complex as needed. As you can see, reports are completely customizable. Validation logic can be as simple or as complex as needed. Also note that the `description` attribute support markdown syntax. It will be rendered in the report list page.
!!! warning !!! warning
Reports should never alter data: If you find yourself using the `create()`, `save()`, `update()`, or `delete()` methods on objects within reports, stop and re-evaluate what you're trying to accomplish. Note that there are no safeguards against the accidental alteration or destruction of data. Reports should never alter data: If you find yourself using the `create()`, `save()`, `update()`, or `delete()` methods on objects within reports, stop and re-evaluate what you're trying to accomplish. Note that there are no safeguards against the accidental alteration or destruction of data.
@ -93,7 +93,7 @@ The following methods are available to log results within a report:
* log_warning(object, message) * log_warning(object, message)
* log_failure(object, message) * log_failure(object, message)
The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. Log messages also support using markdown syntax and will be rendered on the report result page.
To perform additional tasks, such as sending an email or calling a webhook, after a report has been run, extend the `post_run()` method. The status of the report is available as `self.failed` and the results object is `self.result`. To perform additional tasks, such as sending an email or calling a webhook, after a report has been run, extend the `post_run()` method. The status of the report is available as `self.failed` and the results object is `self.result`.

View File

@ -24,7 +24,7 @@ The video below demonstrates the installation of NetBox v2.10.3 on Ubuntu 20.04
| Redis | 4.0 | | Redis | 4.0 |
!!! note !!! note
Python 3.7 or later will be required in NetBox v2.12. Users are strongly encouraged to install NetBox using Python 3.7 or later for new deployments. Python 3.7 or later will be required in NetBox v3.0. Users are strongly encouraged to install NetBox using Python 3.7 or later for new deployments.
Below is a simplified overview of the NetBox application stack for reference: Below is a simplified overview of the NetBox application stack for reference:

View File

@ -1,5 +1,30 @@
# NetBox v2.11 # NetBox v2.11
## v2.11.5 (2021-06-04)
**NOTE:** This release includes a database migration that calculates and annotates prefix depth. It may impose a noticeable delay on the upgrade process: Users should anticipate roughly one minute of delay per 100 thousand prefixes being updated.
### Enhancements
* [#6087](https://github.com/netbox-community/netbox/issues/6087) - Improved prefix hierarchy rendering
* [#6487](https://github.com/netbox-community/netbox/issues/6487) - Add location filter to cable connection form
* [#6501](https://github.com/netbox-community/netbox/issues/6501) - Expose prefix depth and children on REST API serializer
* [#6527](https://github.com/netbox-community/netbox/issues/6527) - Support Markdown for report descriptions
* [#6540](https://github.com/netbox-community/netbox/issues/6540) - Add a "flat" column to the prefix table
### Bug Fixes
* [#6064](https://github.com/netbox-community/netbox/issues/6064) - Fix object permission assignments for user and group models
* [#6217](https://github.com/netbox-community/netbox/issues/6217) - Disallow passing of string values for integer custom fields
* [#6284](https://github.com/netbox-community/netbox/issues/6284) - Avoid sending redundant webhooks when adding/removing tags
* [#6492](https://github.com/netbox-community/netbox/issues/6492) - Correct tag population in post-change data resulting from REST API changes
* [#6496](https://github.com/netbox-community/netbox/issues/6496) - Fix upgrade script when Python installed in nonstandard path
* [#6502](https://github.com/netbox-community/netbox/issues/6502) - Correct permissions evaluation for running a report via the REST API
* [#6517](https://github.com/netbox-community/netbox/issues/6517) - Fix assignment of user when creating rack reservations via REST API
* [#6525](https://github.com/netbox-community/netbox/issues/6525) - Paginate related IPs table under IP address view
---
## v2.11.4 (2021-05-25) ## v2.11.4 (2021-05-25)
### Enhancements ### Enhancements
@ -93,7 +118,7 @@
## v2.11.0 (2021-04-16) ## v2.11.0 (2021-04-16)
**Note:** NetBox v2.11 is the last major release that will support Python 3.6. Beginning with NetBox v2.12, Python 3.7 or later will be required. **Note:** NetBox v2.11 is the last major release that will support Python 3.6. Beginning with NetBox v3.0, Python 3.7 or later will be required.
### Breaking Changes ### Breaking Changes
@ -151,7 +176,7 @@ Devices can now be assigned to locations (formerly known as rack groups) within
When exporting a list of objects in NetBox, users now have the option of selecting the "current view". This will render CSV output matching the current configuration of the table being viewed. For example, if you modify the sites list to display only the site name, tenant, and status, the rendered CSV will include only these columns, and they will appear in the order chosen. When exporting a list of objects in NetBox, users now have the option of selecting the "current view". This will render CSV output matching the current configuration of the table being viewed. For example, if you modify the sites list to display only the site name, tenant, and status, the rendered CSV will include only these columns, and they will appear in the order chosen.
The legacy static export behavior has been retained to ensure backward compatibility for dependent integrations. However, users are strongly encouraged to adapt custom export templates where needed as this functionality will be removed in v2.12. The legacy static export behavior has been retained to ensure backward compatibility for dependent integrations. However, users are strongly encouraged to adapt custom export templates where needed as this functionality will be removed in v3.0.
#### Variable Scope Support for VLAN Groups ([#5284](https://github.com/netbox-community/netbox/issues/5284)) #### Variable Scope Support for VLAN Groups ([#5284](https://github.com/netbox-community/netbox/issues/5284))

View File

@ -246,10 +246,6 @@ class RackReservationViewSet(ModelViewSet):
serializer_class = serializers.RackReservationSerializer serializer_class = serializers.RackReservationSerializer
filterset_class = filtersets.RackReservationFilterSet filterset_class = filtersets.RackReservationFilterSet
# Assign user from request
def perform_create(self, serializer):
serializer.save(user=self.request.user)
# #
# Manufacturers # Manufacturers

View File

@ -3923,13 +3923,23 @@ class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm):
'group_id': '$termination_b_site_group', 'group_id': '$termination_b_site_group',
} }
) )
termination_b_location = DynamicModelChoiceField(
queryset=Location.objects.all(),
label='Location',
required=False,
null_option='None',
query_params={
'site_id': '$termination_b_site'
}
)
termination_b_rack = DynamicModelChoiceField( termination_b_rack = DynamicModelChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
label='Rack', label='Rack',
required=False, required=False,
null_option='None', null_option='None',
query_params={ query_params={
'site_id': '$termination_b_site' 'site_id': '$termination_b_site',
'location_id': '$termination_b_location',
} }
) )
termination_b_device = DynamicModelChoiceField( termination_b_device = DynamicModelChoiceField(

View File

@ -349,40 +349,36 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
user = User.objects.create(username='user1', is_active=True) user = User.objects.create(username='user1', is_active=True)
site = Site.objects.create(name='Test Site 1', slug='test-site-1') site = Site.objects.create(name='Test Site 1', slug='test-site-1')
cls.racks = ( racks = (
Rack(site=site, name='Rack 1'), Rack(site=site, name='Rack 1'),
Rack(site=site, name='Rack 2'), Rack(site=site, name='Rack 2'),
) )
Rack.objects.bulk_create(cls.racks) Rack.objects.bulk_create(racks)
rack_reservations = ( rack_reservations = (
RackReservation(rack=cls.racks[0], units=[1, 2, 3], user=user, description='Reservation #1'), RackReservation(rack=racks[0], units=[1, 2, 3], user=user, description='Reservation #1'),
RackReservation(rack=cls.racks[0], units=[4, 5, 6], user=user, description='Reservation #2'), RackReservation(rack=racks[0], units=[4, 5, 6], user=user, description='Reservation #2'),
RackReservation(rack=cls.racks[0], units=[7, 8, 9], user=user, description='Reservation #3'), RackReservation(rack=racks[0], units=[7, 8, 9], user=user, description='Reservation #3'),
) )
RackReservation.objects.bulk_create(rack_reservations) RackReservation.objects.bulk_create(rack_reservations)
def setUp(self): cls.create_data = [
super().setUp()
# We have to set creation data under setUp() because we need access to the test user.
self.create_data = [
{ {
'rack': self.racks[1].pk, 'rack': racks[1].pk,
'units': [10, 11, 12], 'units': [10, 11, 12],
'user': self.user.pk, 'user': user.pk,
'description': 'Reservation #4', 'description': 'Reservation #4',
}, },
{ {
'rack': self.racks[1].pk, 'rack': racks[1].pk,
'units': [13, 14, 15], 'units': [13, 14, 15],
'user': self.user.pk, 'user': user.pk,
'description': 'Reservation #5', 'description': 'Reservation #5',
}, },
{ {
'rack': self.racks[1].pk, 'rack': racks[1].pk,
'units': [16, 17, 18], 'units': [16, 17, 18],
'user': self.user.pk, 'user': user.pk,
'description': 'Reservation #6', 'description': 'Reservation #6',
}, },
] ]

View File

@ -239,7 +239,7 @@ class ReportViewSet(ViewSet):
Run a Report identified as "<module>.<script>" and return the pending JobResult as the result Run a Report identified as "<module>.<script>" and return the pending JobResult as the result
""" """
# Check that the user has permission to run reports. # Check that the user has permission to run reports.
if not request.user.has_perm('extras.run_script'): if not request.user.has_perm('extras.run_report'):
raise PermissionDenied("This user does not have permission to run reports.") raise PermissionDenied("This user does not have permission to run reports.")
# Check that at least one RQ worker is running # Check that at least one RQ worker is running

View File

@ -4,6 +4,7 @@ from django.db.models.signals import m2m_changed, pre_delete, post_save
from extras.signals import _handle_changed_object, _handle_deleted_object from extras.signals import _handle_changed_object, _handle_deleted_object
from utilities.utils import curry from utilities.utils import curry
from .webhooks import flush_webhooks
@contextmanager @contextmanager
@ -14,9 +15,11 @@ def change_logging(request):
:param request: WSGIRequest object with a unique `id` set :param request: WSGIRequest object with a unique `id` set
""" """
webhook_queue = []
# Curry signals receivers to pass the current request # Curry signals receivers to pass the current request
handle_changed_object = curry(_handle_changed_object, request) handle_changed_object = curry(_handle_changed_object, request, webhook_queue)
handle_deleted_object = curry(_handle_deleted_object, request) handle_deleted_object = curry(_handle_deleted_object, request, webhook_queue)
# Connect our receivers to the post_save and post_delete signals. # Connect our receivers to the post_save and post_delete signals.
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object') post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
@ -30,3 +33,7 @@ def change_logging(request):
post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object') post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
m2m_changed.disconnect(handle_changed_object, dispatch_uid='handle_changed_object') m2m_changed.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object') pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object')
# Flush queued webhooks to RQ
flush_webhooks(webhook_queue)
del webhook_queue

View File

@ -286,9 +286,7 @@ class CustomField(BigIDModel):
# Validate integer # Validate integer
if self.type == CustomFieldTypeChoices.TYPE_INTEGER: if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
try: if type(value) is not int:
int(value)
except ValueError:
raise ValidationError("Value must be an integer.") raise ValidationError("Value must be an integer.")
if self.validation_minimum is not None and value < self.validation_minimum: if self.validation_minimum is not None and value < self.validation_minimum:
raise ValidationError(f"Value must be at least {self.validation_minimum}") raise ValidationError(f"Value must be at least {self.validation_minimum}")

View File

@ -188,10 +188,10 @@ class ObjectVar(ScriptVariable):
def __init__(self, model, query_params=None, null_option=None, *args, **kwargs): def __init__(self, model, query_params=None, null_option=None, *args, **kwargs):
# TODO: Remove display_field in v2.12 # TODO: Remove display_field in v3.0
if 'display_field' in kwargs: if 'display_field' in kwargs:
warnings.warn( warnings.warn(
"The 'display_field' parameter has been deprecated, and will be removed in NetBox v2.12. Object " "The 'display_field' parameter has been deprecated, and will be removed in NetBox v3.0. Object "
"variables will now reference the 'display' attribute available on all model serializers by default." "variables will now reference the 'display' attribute available on all model serializers by default."
) )
display_field = kwargs.pop('display_field', 'display') display_field = kwargs.pop('display_field', 'display')

View File

@ -12,17 +12,27 @@ from prometheus_client import Counter
from .choices import ObjectChangeActionChoices from .choices import ObjectChangeActionChoices
from .models import CustomField, ObjectChange from .models import CustomField, ObjectChange
from .webhooks import enqueue_webhooks from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
# #
# Change logging/webhooks # Change logging/webhooks
# #
def _handle_changed_object(request, sender, instance, **kwargs): def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
""" """
Fires when an object is created or updated. Fires when an object is created or updated.
""" """
def is_same_object(instance, webhook_data):
return (
ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
instance.pk == webhook_data['object_id'] and
request.id == webhook_data['request_id']
)
if not hasattr(instance, 'to_objectchange'):
return
m2m_changed = False m2m_changed = False
# Determine the type of change being made # Determine the type of change being made
@ -53,8 +63,13 @@ def _handle_changed_object(request, sender, instance, **kwargs):
objectchange.request_id = request.id objectchange.request_id = request.id
objectchange.save() objectchange.save()
# Enqueue webhooks # If this is an M2M change, update the previously queued webhook (from post_save)
enqueue_webhooks(instance, request.user, request.id, action) if m2m_changed and webhook_queue and is_same_object(instance, webhook_queue[-1]):
instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments
webhook_queue[-1]['data'] = serialize_for_webhook(instance)
webhook_queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
else:
enqueue_object(webhook_queue, instance, request.user, request.id, action)
# Increment metric counters # Increment metric counters
if action == ObjectChangeActionChoices.ACTION_CREATE: if action == ObjectChangeActionChoices.ACTION_CREATE:
@ -68,10 +83,13 @@ def _handle_changed_object(request, sender, instance, **kwargs):
ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS) ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
def _handle_deleted_object(request, sender, instance, **kwargs): def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs):
""" """
Fires when an object is deleted. Fires when an object is deleted.
""" """
if not hasattr(instance, 'to_objectchange'):
return
# Record an ObjectChange if applicable # Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'): if hasattr(instance, 'to_objectchange'):
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE) objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
@ -80,7 +98,7 @@ def _handle_deleted_object(request, sender, instance, **kwargs):
objectchange.save() objectchange.save()
# Enqueue webhooks # Enqueue webhooks
enqueue_webhooks(instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE) enqueue_object(webhook_queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
# Increment metric counters # Increment metric counters
model_deletes.labels(instance._meta.model_name).inc() model_deletes.labels(instance._meta.model_name).inc()

View File

@ -11,8 +11,8 @@ from rest_framework import status
from dcim.models import Site from dcim.models import Site
from extras.choices import ObjectChangeActionChoices from extras.choices import ObjectChangeActionChoices
from extras.models import Webhook from extras.models import Tag, Webhook
from extras.webhooks import enqueue_webhooks, generate_signature from extras.webhooks import enqueue_object, flush_webhooks, generate_signature
from extras.webhooks_worker import process_webhook from extras.webhooks_worker import process_webhook
from utilities.testing import APITestCase from utilities.testing import APITestCase
@ -20,11 +20,10 @@ from utilities.testing import APITestCase
class WebhookTest(APITestCase): class WebhookTest(APITestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.queue = django_rq.get_queue('default') self.queue = django_rq.get_queue('default')
self.queue.empty() # Begin each test with an empty queue self.queue.empty()
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -34,38 +33,104 @@ class WebhookTest(APITestCase):
DUMMY_SECRET = "LOOKATMEIMASECRETSTRING" DUMMY_SECRET = "LOOKATMEIMASECRETSTRING"
webhooks = Webhook.objects.bulk_create(( webhooks = Webhook.objects.bulk_create((
Webhook(name='Site Create Webhook', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers='X-Foo: Bar'), Webhook(name='Webhook 1', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers='X-Foo: Bar'),
Webhook(name='Site Update Webhook', type_update=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET), Webhook(name='Webhook 2', type_update=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
Webhook(name='Site Delete Webhook', type_delete=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET), Webhook(name='Webhook 3', type_delete=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
)) ))
for webhook in webhooks: for webhook in webhooks:
webhook.content_types.set([site_ct]) webhook.content_types.set([site_ct])
Tag.objects.bulk_create((
Tag(name='Foo', slug='foo'),
Tag(name='Bar', slug='bar'),
Tag(name='Baz', slug='baz'),
))
def test_enqueue_webhook_create(self): def test_enqueue_webhook_create(self):
# Create an object via the REST API # Create an object via the REST API
data = { data = {
'name': 'Test Site', 'name': 'Site 1',
'slug': 'test-site', 'slug': 'site-1',
'tags': [
{'name': 'Foo'},
{'name': 'Bar'},
]
} }
url = reverse('dcim-api:site-list') url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site') self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Site.objects.count(), 1) self.assertEqual(Site.objects.count(), 1)
self.assertEqual(Site.objects.first().tags.count(), 2)
# Verify that a job was queued for the object creation webhook # Verify that a job was queued for the object creation webhook
self.assertEqual(self.queue.count, 1) self.assertEqual(self.queue.count, 1)
job = self.queue.jobs[0] job = self.queue.jobs[0]
self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_create=True)) self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_create=True))
self.assertEqual(job.kwargs['data']['id'], response.data['id'])
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE) self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], response.data['id'])
self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags']))
self.assertEqual(job.kwargs['snapshots']['postchange']['name'], 'Site 1')
self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Bar', 'Foo'])
def test_enqueue_webhook_bulk_create(self):
# Create multiple objects via the REST API
data = [
{
'name': 'Site 1',
'slug': 'site-1',
'tags': [
{'name': 'Foo'},
{'name': 'Bar'},
]
},
{
'name': 'Site 2',
'slug': 'site-2',
'tags': [
{'name': 'Foo'},
{'name': 'Bar'},
]
},
{
'name': 'Site 3',
'slug': 'site-3',
'tags': [
{'name': 'Foo'},
{'name': 'Bar'},
]
},
]
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Site.objects.count(), 3)
self.assertEqual(Site.objects.first().tags.count(), 2)
# Verify that a webhook was queued for each object
self.assertEqual(self.queue.count, 3)
for i, job in enumerate(self.queue.jobs):
self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_create=True))
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], response.data[i]['id'])
self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags']))
self.assertEqual(job.kwargs['snapshots']['postchange']['name'], response.data[i]['name'])
self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Bar', 'Foo'])
def test_enqueue_webhook_update(self): def test_enqueue_webhook_update(self):
# Update an object via the REST API
site = Site.objects.create(name='Site 1', slug='site-1') site = Site.objects.create(name='Site 1', slug='site-1')
site.tags.set(*Tag.objects.filter(name__in=['Foo', 'Bar']))
# Update an object via the REST API
data = { data = {
'name': 'Site X',
'comments': 'Updated the site', 'comments': 'Updated the site',
'tags': [
{'name': 'Baz'}
]
} }
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
self.add_permissions('dcim.change_site') self.add_permissions('dcim.change_site')
@ -76,13 +141,72 @@ class WebhookTest(APITestCase):
self.assertEqual(self.queue.count, 1) self.assertEqual(self.queue.count, 1)
job = self.queue.jobs[0] job = self.queue.jobs[0]
self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_update=True)) self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_update=True))
self.assertEqual(job.kwargs['data']['id'], site.pk)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE) self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], site.pk)
self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags']))
self.assertEqual(job.kwargs['snapshots']['prechange']['name'], 'Site 1')
self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
self.assertEqual(job.kwargs['snapshots']['postchange']['name'], 'Site X')
self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Baz'])
def test_enqueue_webhook_bulk_update(self):
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
)
Site.objects.bulk_create(sites)
for site in sites:
site.tags.set(*Tag.objects.filter(name__in=['Foo', 'Bar']))
# Update three objects via the REST API
data = [
{
'id': sites[0].pk,
'name': 'Site X',
'tags': [
{'name': 'Baz'}
]
},
{
'id': sites[1].pk,
'name': 'Site Y',
'tags': [
{'name': 'Baz'}
]
},
{
'id': sites[2].pk,
'name': 'Site Z',
'tags': [
{'name': 'Baz'}
]
},
]
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.change_site')
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
# Verify that a job was queued for the object update webhook
self.assertEqual(self.queue.count, 3)
for i, job in enumerate(self.queue.jobs):
self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_update=True))
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], data[i]['id'])
self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags']))
self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name)
self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
self.assertEqual(job.kwargs['snapshots']['postchange']['name'], response.data[i]['name'])
self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Baz'])
def test_enqueue_webhook_delete(self): def test_enqueue_webhook_delete(self):
# Delete an object via the REST API
site = Site.objects.create(name='Site 1', slug='site-1') site = Site.objects.create(name='Site 1', slug='site-1')
site.tags.set(*Tag.objects.filter(name__in=['Foo', 'Bar']))
# Delete an object via the REST API
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
self.add_permissions('dcim.delete_site') self.add_permissions('dcim.delete_site')
response = self.client.delete(url, **self.header) response = self.client.delete(url, **self.header)
@ -92,9 +216,40 @@ class WebhookTest(APITestCase):
self.assertEqual(self.queue.count, 1) self.assertEqual(self.queue.count, 1)
job = self.queue.jobs[0] job = self.queue.jobs[0]
self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_delete=True)) self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_delete=True))
self.assertEqual(job.kwargs['data']['id'], site.pk)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE) self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], site.pk)
self.assertEqual(job.kwargs['snapshots']['prechange']['name'], 'Site 1')
self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
def test_enqueue_webhook_bulk_delete(self):
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
)
Site.objects.bulk_create(sites)
for site in sites:
site.tags.set(*Tag.objects.filter(name__in=['Foo', 'Bar']))
# Delete three objects via the REST API
data = [
{'id': site.pk} for site in sites
]
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.delete_site')
response = self.client.delete(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
# Verify that a job was queued for the object update webhook
self.assertEqual(self.queue.count, 3)
for i, job in enumerate(self.queue.jobs):
self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_delete=True))
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], sites[i].pk)
self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name)
self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
def test_webhooks_worker(self): def test_webhooks_worker(self):
@ -125,13 +280,16 @@ class WebhookTest(APITestCase):
return HttpResponse() return HttpResponse()
# Enqueue a webhook for processing # Enqueue a webhook for processing
webhooks_queue = []
site = Site.objects.create(name='Site 1', slug='site-1') site = Site.objects.create(name='Site 1', slug='site-1')
enqueue_webhooks( enqueue_object(
webhooks_queue,
instance=site, instance=site,
user=self.user, user=self.user,
request_id=request_id, request_id=request_id,
action=ObjectChangeActionChoices.ACTION_CREATE action=ObjectChangeActionChoices.ACTION_CREATE
) )
flush_webhooks(webhooks_queue)
# Retrieve the job from queue # Retrieve the job from queue
job = self.queue.jobs[0] job = self.queue.jobs[0]

View File

@ -1,5 +1,6 @@
import hashlib import hashlib
import hmac import hmac
from collections import defaultdict
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils import timezone from django.utils import timezone
@ -12,6 +13,26 @@ from .models import Webhook
from .registry import registry from .registry import registry
def serialize_for_webhook(instance):
"""
Return a serialized representation of the given instance suitable for use in a webhook.
"""
serializer_class = get_serializer_for_model(instance.__class__)
serializer_context = {
'request': None,
}
serializer = serializer_class(instance, context=serializer_context)
return serializer.data
def get_snapshots(instance, action):
return {
'prechange': getattr(instance, '_prechange_snapshot', None),
'postchange': serialize_object(instance) if action != ObjectChangeActionChoices.ACTION_DELETE else None,
}
def generate_signature(request_body, secret): def generate_signature(request_body, secret):
""" """
Return a cryptographic signature that can be used to verify the authenticity of webhook data. Return a cryptographic signature that can be used to verify the authenticity of webhook data.
@ -24,10 +45,10 @@ def generate_signature(request_body, secret):
return hmac_prep.hexdigest() return hmac_prep.hexdigest()
def enqueue_webhooks(instance, user, request_id, action): def enqueue_object(queue, instance, user, request_id, action):
""" """
Find Webhook(s) assigned to this instance + action and enqueue them Enqueue a serialized representation of a created/updated/deleted object for the processing of
to be processed webhooks once the request has completed.
""" """
# Determine whether this type of object supports webhooks # Determine whether this type of object supports webhooks
app_label = instance._meta.app_label app_label = instance._meta.app_label
@ -35,41 +56,55 @@ def enqueue_webhooks(instance, user, request_id, action):
if model_name not in registry['model_features']['webhooks'].get(app_label, []): if model_name not in registry['model_features']['webhooks'].get(app_label, []):
return return
# Retrieve any applicable Webhooks queue.append({
content_type = ContentType.objects.get_for_model(instance) 'content_type': ContentType.objects.get_for_model(instance),
action_flag = { 'object_id': instance.pk,
ObjectChangeActionChoices.ACTION_CREATE: 'type_create', 'event': action,
ObjectChangeActionChoices.ACTION_UPDATE: 'type_update', 'data': serialize_for_webhook(instance),
ObjectChangeActionChoices.ACTION_DELETE: 'type_delete', 'snapshots': get_snapshots(instance, action),
}[action] 'username': user.username,
webhooks = Webhook.objects.filter(content_types=content_type, enabled=True, **{action_flag: True}) 'request_id': request_id
})
if webhooks.exists():
# Get the Model's API serializer class and serialize the object def flush_webhooks(queue):
serializer_class = get_serializer_for_model(instance.__class__) """
serializer_context = { Flush a list of object representation to RQ for webhook processing.
'request': None, """
} rq_queue = get_queue('default')
serializer = serializer_class(instance, context=serializer_context) webhooks_cache = {
'type_create': {},
'type_update': {},
'type_delete': {},
}
# Gather pre- and post-change snapshots for data in queue:
snapshots = {
'prechange': getattr(instance, '_prechange_snapshot', None), action_flag = {
'postchange': serialize_object(instance) if action != ObjectChangeActionChoices.ACTION_DELETE else None, ObjectChangeActionChoices.ACTION_CREATE: 'type_create',
} ObjectChangeActionChoices.ACTION_UPDATE: 'type_update',
ObjectChangeActionChoices.ACTION_DELETE: 'type_delete',
}[data['event']]
content_type = data['content_type']
# Cache applicable Webhooks
if content_type not in webhooks_cache[action_flag]:
webhooks_cache[action_flag][content_type] = Webhook.objects.filter(
**{action_flag: True},
content_types=content_type,
enabled=True
)
webhooks = webhooks_cache[action_flag][content_type]
# Enqueue the webhooks
webhook_queue = get_queue('default')
for webhook in webhooks: for webhook in webhooks:
webhook_queue.enqueue( rq_queue.enqueue(
"extras.webhooks_worker.process_webhook", "extras.webhooks_worker.process_webhook",
webhook=webhook, webhook=webhook,
model_name=instance._meta.model_name, model_name=content_type.model,
event=action, event=data['event'],
data=serializer.data, data=data['data'],
snapshots=snapshots, snapshots=data['snapshots'],
timestamp=str(timezone.now()), timestamp=str(timezone.now()),
username=user.username, username=data['username'],
request_id=request_id request_id=data['request_id']
) )

View File

@ -102,10 +102,11 @@ class NestedVLANSerializer(WritableNestedSerializer):
class NestedPrefixSerializer(WritableNestedSerializer): class NestedPrefixSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
family = serializers.IntegerField(read_only=True) family = serializers.IntegerField(read_only=True)
_depth = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = models.Prefix model = models.Prefix
fields = ['id', 'url', 'display', 'family', 'prefix'] fields = ['id', 'url', 'display', 'family', 'prefix', '_depth']
# #

View File

@ -197,12 +197,14 @@ class PrefixSerializer(PrimaryModelSerializer):
vlan = NestedVLANSerializer(required=False, allow_null=True) vlan = NestedVLANSerializer(required=False, allow_null=True)
status = ChoiceField(choices=PrefixStatusChoices, required=False) status = ChoiceField(choices=PrefixStatusChoices, required=False)
role = NestedRoleSerializer(required=False, allow_null=True) role = NestedRoleSerializer(required=False, allow_null=True)
children = serializers.IntegerField(read_only=True)
_depth = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Prefix model = Prefix
fields = [ fields = [
'id', 'url', 'display', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'id', 'url', 'display', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'children', '_depth',
] ]
read_only_fields = ['family'] read_only_fields = ['family']
@ -272,7 +274,7 @@ class IPAddressSerializer(PrimaryModelSerializer):
) )
assigned_object = serializers.SerializerMethodField(read_only=True) assigned_object = serializers.SerializerMethodField(read_only=True)
nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
nat_outside = NestedIPAddressSerializer(read_only=True) nat_outside = NestedIPAddressSerializer(required=False, read_only=True)
class Meta: class Meta:
model = IPAddress model = IPAddress
@ -281,7 +283,7 @@ class IPAddressSerializer(PrimaryModelSerializer):
'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags', 'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'custom_fields', 'created', 'last_updated',
] ]
read_only_fields = ['family'] read_only_fields = ['family', 'nat_outside']
@swagger_serializer_method(serializer_or_field=serializers.DictField) @swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_assigned_object(self, obj): def get_assigned_object(self, obj):

View File

@ -209,6 +209,12 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
method='search_contains', method='search_contains',
label='Prefixes which contain this prefix or IP', label='Prefixes which contain this prefix or IP',
) )
depth = MultiValueNumberFilter(
field_name='_depth'
)
children = MultiValueNumberFilter(
field_name='_children'
)
mask_length = django_filters.NumberFilter( mask_length = django_filters.NumberFilter(
field_name='prefix', field_name='prefix',
lookup_expr='net_mask_length' lookup_expr='net_mask_length'

View File

View File

@ -0,0 +1,27 @@
from django.core.management.base import BaseCommand
from ipam.models import Prefix, VRF
from ipam.utils import rebuild_prefixes
class Command(BaseCommand):
help = "Rebuild the prefix hierarchy (depth and children counts)"
def handle(self, *model_names, **options):
self.stdout.write(f'Rebuilding {Prefix.objects.count()} prefixes...')
# Reset existing counts
Prefix.objects.update(_depth=0, _children=0)
# Rebuild the global table
global_count = Prefix.objects.filter(vrf__isnull=True).count()
self.stdout.write(f'Global: {global_count} prefixes...')
rebuild_prefixes(None)
# Rebuild each VRF
for vrf in VRF.objects.all():
vrf_count = Prefix.objects.filter(vrf=vrf).count()
self.stdout.write(f'VRF {vrf}: {vrf_count} prefixes...')
rebuild_prefixes(vrf)
self.stdout.write(self.style.SUCCESS('Finished.'))

View File

@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ipam', '0046_set_vlangroup_scope_types'),
]
operations = [
migrations.AddField(
model_name='prefix',
name='_children',
field=models.PositiveBigIntegerField(default=0, editable=False),
),
migrations.AddField(
model_name='prefix',
name='_depth',
field=models.PositiveSmallIntegerField(default=0, editable=False),
),
]

View File

@ -0,0 +1,37 @@
import sys
from django.db import migrations
from ipam.utils import rebuild_prefixes
def populate_prefix_hierarchy(apps, schema_editor):
"""
Populate _depth and _children attrs for all Prefixes.
"""
Prefix = apps.get_model('ipam', 'Prefix')
VRF = apps.get_model('ipam', 'VRF')
total_count = Prefix.objects.count()
if 'test' not in sys.argv:
print(f'\nUpdating {total_count} prefixes...')
# Rebuild the global table
rebuild_prefixes(None)
# Iterate through all VRFs, rebuilding each
for vrf in VRF.objects.all():
rebuild_prefixes(vrf)
class Migration(migrations.Migration):
dependencies = [
('ipam', '0047_prefix_depth_children'),
]
operations = [
migrations.RunPython(
code=populate_prefix_hierarchy,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -293,6 +293,16 @@ class Prefix(PrimaryModel):
blank=True blank=True
) )
# Cached depth & child counts
_depth = models.PositiveSmallIntegerField(
default=0,
editable=False
)
_children = models.PositiveBigIntegerField(
default=0,
editable=False
)
objects = PrefixQuerySet.as_manager() objects = PrefixQuerySet.as_manager()
csv_headers = [ csv_headers = [
@ -306,6 +316,13 @@ class Prefix(PrimaryModel):
ordering = (F('vrf').asc(nulls_first=True), 'prefix', 'pk') # (vrf, prefix) may be non-unique ordering = (F('vrf').asc(nulls_first=True), 'prefix', 'pk') # (vrf, prefix) may be non-unique
verbose_name_plural = 'prefixes' verbose_name_plural = 'prefixes'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Cache the original prefix and VRF so we can check if they have changed on post_save
self._prefix = self.prefix
self._vrf = self.vrf
def __str__(self): def __str__(self):
return str(self.prefix) return str(self.prefix)
@ -373,6 +390,14 @@ class Prefix(PrimaryModel):
return self.prefix.version return self.prefix.version
return None return None
@property
def depth(self):
return self._depth
@property
def children(self):
return self._children
def _set_prefix_length(self, value): def _set_prefix_length(self, value):
""" """
Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly, Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly,
@ -385,6 +410,26 @@ class Prefix(PrimaryModel):
def get_status_class(self): def get_status_class(self):
return PrefixStatusChoices.CSS_CLASSES.get(self.status) return PrefixStatusChoices.CSS_CLASSES.get(self.status)
def get_parents(self, include_self=False):
"""
Return all containing Prefixes in the hierarchy.
"""
lookup = 'net_contains_or_equals' if include_self else 'net_contains'
return Prefix.objects.filter(**{
'vrf': self.vrf,
f'prefix__{lookup}': self.prefix
})
def get_children(self, include_self=False):
"""
Return all covered Prefixes in the hierarchy.
"""
lookup = 'net_contained_or_equal' if include_self else 'net_contained'
return Prefix.objects.filter(**{
'vrf': self.vrf,
f'prefix__{lookup}': self.prefix
})
def get_duplicates(self): def get_duplicates(self):
return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk) return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk)

View File

@ -1,27 +1,32 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
from django.db.models.expressions import RawSQL
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
class PrefixQuerySet(RestrictedQuerySet): class PrefixQuerySet(RestrictedQuerySet):
def annotate_tree(self): def annotate_hierarchy(self):
""" """
Annotate the number of parent and child prefixes for each Prefix. Raw SQL is needed for these subqueries Annotate the depth and number of child prefixes for each Prefix. Cast null VRF values to zero for
because we need to cast NULL VRF values to integers for comparison. (NULL != NULL). comparison. (NULL != NULL).
""" """
return self.extra( return self.annotate(
select={ hierarchy_depth=RawSQL(
'parents': 'SELECT COUNT(U0."prefix") AS "c" ' 'SELECT COUNT(DISTINCT U0."prefix") AS "c" '
'FROM "ipam_prefix" U0 ' 'FROM "ipam_prefix" U0 '
'WHERE (U0."prefix" >> "ipam_prefix"."prefix" ' 'WHERE (U0."prefix" >> "ipam_prefix"."prefix" '
'AND COALESCE(U0."vrf_id", 0) = COALESCE("ipam_prefix"."vrf_id", 0))', 'AND COALESCE(U0."vrf_id", 0) = COALESCE("ipam_prefix"."vrf_id", 0))',
'children': 'SELECT COUNT(U1."prefix") AS "c" ' ()
'FROM "ipam_prefix" U1 ' ),
'WHERE (U1."prefix" << "ipam_prefix"."prefix" ' hierarchy_children=RawSQL(
'AND COALESCE(U1."vrf_id", 0) = COALESCE("ipam_prefix"."vrf_id", 0))', 'SELECT COUNT(U1."prefix") AS "c" '
} 'FROM "ipam_prefix" U1 '
'WHERE (U1."prefix" << "ipam_prefix"."prefix" '
'AND COALESCE(U1."vrf_id", 0) = COALESCE("ipam_prefix"."vrf_id", 0))',
()
)
) )

View File

@ -1,9 +1,52 @@
from django.db.models.signals import pre_delete from django.db.models.signals import post_delete, post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from dcim.models import Device from dcim.models import Device
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from .models import IPAddress from .models import IPAddress, Prefix
def update_parents_children(prefix):
"""
Update depth on prefix & containing prefixes
"""
parents = prefix.get_parents(include_self=True).annotate_hierarchy()
for parent in parents:
parent._children = parent.hierarchy_children
Prefix.objects.bulk_update(parents, ['_children'], batch_size=100)
def update_children_depth(prefix):
"""
Update children count on prefix & contained prefixes
"""
children = prefix.get_children(include_self=True).annotate_hierarchy()
for child in children:
child._depth = child.hierarchy_depth
Prefix.objects.bulk_update(children, ['_depth'], batch_size=100)
@receiver(post_save, sender=Prefix)
def handle_prefix_saved(instance, created, **kwargs):
# Prefix has changed (or new instance has been created)
if created or instance.vrf != instance._vrf or instance.prefix != instance._prefix:
update_parents_children(instance)
update_children_depth(instance)
# If this is not a new prefix, clean up parent/children of previous prefix
if not created:
old_prefix = Prefix(vrf=instance._vrf, prefix=instance._prefix)
update_parents_children(old_prefix)
update_children_depth(old_prefix)
@receiver(post_delete, sender=Prefix)
def handle_prefix_deleted(instance, **kwargs):
update_parents_children(instance)
update_children_depth(instance)
@receiver(pre_delete, sender=IPAddress) @receiver(pre_delete, sender=IPAddress)

View File

@ -15,7 +15,7 @@ AVAILABLE_LABEL = mark_safe('<span class="label label-success">Available</span>'
PREFIX_LINK = """ PREFIX_LINK = """
{% load helpers %} {% load helpers %}
{% for i in record.parents|as_range %} {% for i in record.depth|as_range %}
<i class="mdi mdi-circle-small"></i> <i class="mdi mdi-circle-small"></i>
{% endfor %} {% endfor %}
<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a> <a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
@ -262,6 +262,24 @@ class PrefixTable(BaseTable):
template_code=PREFIX_LINK, template_code=PREFIX_LINK,
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
prefix_flat = tables.Column(
accessor=Accessor('prefix'),
linkify=True,
verbose_name='Prefix (Flat)'
)
depth = tables.Column(
accessor=Accessor('_depth'),
verbose_name='Depth'
)
children = LinkedCountColumn(
accessor=Accessor('_children'),
viewname='ipam:prefix_list',
url_params={
'vrf_id': 'vrf_id',
'within': 'prefix',
},
verbose_name='Children'
)
status = ChoiceFieldColumn( status = ChoiceFieldColumn(
default=AVAILABLE_LABEL default=AVAILABLE_LABEL
) )
@ -287,7 +305,8 @@ class PrefixTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Prefix model = Prefix
fields = ( fields = (
'pk', 'prefix', 'status', 'children', 'vrf', 'tenant', 'site', 'vlan', 'role', 'is_pool', 'description', 'pk', 'prefix', 'prefix_flat', 'status', 'depth', 'children', 'vrf', 'tenant', 'site', 'vlan', 'role',
'is_pool', 'description',
) )
default_columns = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description') default_columns = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
row_attrs = { row_attrs = {
@ -300,15 +319,14 @@ class PrefixDetailTable(PrefixTable):
accessor='get_utilization', accessor='get_utilization',
orderable=False orderable=False
) )
tenant = TenantColumn()
tags = TagColumn( tags = TagColumn(
url_name='ipam:prefix_list' url_name='ipam:prefix_list'
) )
class Meta(PrefixTable.Meta): class Meta(PrefixTable.Meta):
fields = ( fields = (
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'is_pool', 'pk', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role',
'description', 'tags', 'is_pool', 'description', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description', 'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',

View File

@ -186,7 +186,7 @@ class RoleTest(APIViewTestCases.APIViewTestCase):
class PrefixTest(APIViewTestCases.APIViewTestCase): class PrefixTest(APIViewTestCases.APIViewTestCase):
model = Prefix model = Prefix
brief_fields = ['display', 'family', 'id', 'prefix', 'url'] brief_fields = ['_depth', 'display', 'family', 'id', 'prefix', 'url']
create_data = [ create_data = [
{ {
'prefix': '192.168.4.0/24', 'prefix': '192.168.4.0/24',

View File

@ -400,7 +400,8 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
Prefix(prefix='10.0.0.0/16'), Prefix(prefix='10.0.0.0/16'),
Prefix(prefix='2001:db8::/32'), Prefix(prefix='2001:db8::/32'),
) )
Prefix.objects.bulk_create(prefixes) for prefix in prefixes:
prefix.save()
def test_family(self): def test_family(self):
params = {'family': '6'} params = {'family': '6'}
@ -431,6 +432,18 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'contains': '2001:db8:0:1::/64'} params = {'contains': '2001:db8:0:1::/64'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_depth(self):
params = {'depth': '0'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
params = {'depth__gt': '0'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_children(self):
params = {'children': '0'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
params = {'children__gt': '0'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_mask_length(self): def test_mask_length(self):
params = {'mask_length': '24'} params = {'mask_length': '24'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)

View File

@ -1,4 +1,4 @@
import netaddr from netaddr import IPNetwork, IPSet
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
@ -10,27 +10,27 @@ class TestAggregate(TestCase):
def test_get_utilization(self): def test_get_utilization(self):
rir = RIR.objects.create(name='RIR 1', slug='rir-1') rir = RIR.objects.create(name='RIR 1', slug='rir-1')
aggregate = Aggregate(prefix=netaddr.IPNetwork('10.0.0.0/8'), rir=rir) aggregate = Aggregate(prefix=IPNetwork('10.0.0.0/8'), rir=rir)
aggregate.save() aggregate.save()
# 25% utilization # 25% utilization
Prefix.objects.bulk_create(( Prefix.objects.bulk_create((
Prefix(prefix=netaddr.IPNetwork('10.0.0.0/12')), Prefix(prefix=IPNetwork('10.0.0.0/12')),
Prefix(prefix=netaddr.IPNetwork('10.16.0.0/12')), Prefix(prefix=IPNetwork('10.16.0.0/12')),
Prefix(prefix=netaddr.IPNetwork('10.32.0.0/12')), Prefix(prefix=IPNetwork('10.32.0.0/12')),
Prefix(prefix=netaddr.IPNetwork('10.48.0.0/12')), Prefix(prefix=IPNetwork('10.48.0.0/12')),
)) ))
self.assertEqual(aggregate.get_utilization(), 25) self.assertEqual(aggregate.get_utilization(), 25)
# 50% utilization # 50% utilization
Prefix.objects.bulk_create(( Prefix.objects.bulk_create((
Prefix(prefix=netaddr.IPNetwork('10.64.0.0/10')), Prefix(prefix=IPNetwork('10.64.0.0/10')),
)) ))
self.assertEqual(aggregate.get_utilization(), 50) self.assertEqual(aggregate.get_utilization(), 50)
# 100% utilization # 100% utilization
Prefix.objects.bulk_create(( Prefix.objects.bulk_create((
Prefix(prefix=netaddr.IPNetwork('10.128.0.0/9')), Prefix(prefix=IPNetwork('10.128.0.0/9')),
)) ))
self.assertEqual(aggregate.get_utilization(), 100) self.assertEqual(aggregate.get_utilization(), 100)
@ -39,9 +39,9 @@ class TestPrefix(TestCase):
def test_get_duplicates(self): def test_get_duplicates(self):
prefixes = Prefix.objects.bulk_create(( prefixes = Prefix.objects.bulk_create((
Prefix(prefix=netaddr.IPNetwork('192.0.2.0/24')), Prefix(prefix=IPNetwork('192.0.2.0/24')),
Prefix(prefix=netaddr.IPNetwork('192.0.2.0/24')), Prefix(prefix=IPNetwork('192.0.2.0/24')),
Prefix(prefix=netaddr.IPNetwork('192.0.2.0/24')), Prefix(prefix=IPNetwork('192.0.2.0/24')),
)) ))
duplicate_prefix_pks = [p.pk for p in prefixes[0].get_duplicates()] duplicate_prefix_pks = [p.pk for p in prefixes[0].get_duplicates()]
@ -54,11 +54,11 @@ class TestPrefix(TestCase):
VRF(name='VRF 3'), VRF(name='VRF 3'),
)) ))
prefixes = Prefix.objects.bulk_create(( prefixes = Prefix.objects.bulk_create((
Prefix(prefix=netaddr.IPNetwork('10.0.0.0/16'), status=PrefixStatusChoices.STATUS_CONTAINER), Prefix(prefix=IPNetwork('10.0.0.0/16'), status=PrefixStatusChoices.STATUS_CONTAINER),
Prefix(prefix=netaddr.IPNetwork('10.0.0.0/24'), vrf=None), Prefix(prefix=IPNetwork('10.0.0.0/24'), vrf=None),
Prefix(prefix=netaddr.IPNetwork('10.0.1.0/24'), vrf=vrfs[0]), Prefix(prefix=IPNetwork('10.0.1.0/24'), vrf=vrfs[0]),
Prefix(prefix=netaddr.IPNetwork('10.0.2.0/24'), vrf=vrfs[1]), Prefix(prefix=IPNetwork('10.0.2.0/24'), vrf=vrfs[1]),
Prefix(prefix=netaddr.IPNetwork('10.0.3.0/24'), vrf=vrfs[2]), Prefix(prefix=IPNetwork('10.0.3.0/24'), vrf=vrfs[2]),
)) ))
child_prefix_pks = {p.pk for p in prefixes[0].get_child_prefixes()} child_prefix_pks = {p.pk for p in prefixes[0].get_child_prefixes()}
@ -79,13 +79,13 @@ class TestPrefix(TestCase):
VRF(name='VRF 3'), VRF(name='VRF 3'),
)) ))
parent_prefix = Prefix.objects.create( parent_prefix = Prefix.objects.create(
prefix=netaddr.IPNetwork('10.0.0.0/16'), status=PrefixStatusChoices.STATUS_CONTAINER prefix=IPNetwork('10.0.0.0/16'), status=PrefixStatusChoices.STATUS_CONTAINER
) )
ips = IPAddress.objects.bulk_create(( ips = IPAddress.objects.bulk_create((
IPAddress(address=netaddr.IPNetwork('10.0.0.1/24'), vrf=None), IPAddress(address=IPNetwork('10.0.0.1/24'), vrf=None),
IPAddress(address=netaddr.IPNetwork('10.0.1.1/24'), vrf=vrfs[0]), IPAddress(address=IPNetwork('10.0.1.1/24'), vrf=vrfs[0]),
IPAddress(address=netaddr.IPNetwork('10.0.2.1/24'), vrf=vrfs[1]), IPAddress(address=IPNetwork('10.0.2.1/24'), vrf=vrfs[1]),
IPAddress(address=netaddr.IPNetwork('10.0.3.1/24'), vrf=vrfs[2]), IPAddress(address=IPNetwork('10.0.3.1/24'), vrf=vrfs[2]),
)) ))
child_ip_pks = {p.pk for p in parent_prefix.get_child_ips()} child_ip_pks = {p.pk for p in parent_prefix.get_child_ips()}
@ -102,16 +102,16 @@ class TestPrefix(TestCase):
def test_get_available_prefixes(self): def test_get_available_prefixes(self):
prefixes = Prefix.objects.bulk_create(( prefixes = Prefix.objects.bulk_create((
Prefix(prefix=netaddr.IPNetwork('10.0.0.0/16')), # Parent prefix Prefix(prefix=IPNetwork('10.0.0.0/16')), # Parent prefix
Prefix(prefix=netaddr.IPNetwork('10.0.0.0/20')), Prefix(prefix=IPNetwork('10.0.0.0/20')),
Prefix(prefix=netaddr.IPNetwork('10.0.32.0/20')), Prefix(prefix=IPNetwork('10.0.32.0/20')),
Prefix(prefix=netaddr.IPNetwork('10.0.128.0/18')), Prefix(prefix=IPNetwork('10.0.128.0/18')),
)) ))
missing_prefixes = netaddr.IPSet([ missing_prefixes = IPSet([
netaddr.IPNetwork('10.0.16.0/20'), IPNetwork('10.0.16.0/20'),
netaddr.IPNetwork('10.0.48.0/20'), IPNetwork('10.0.48.0/20'),
netaddr.IPNetwork('10.0.64.0/18'), IPNetwork('10.0.64.0/18'),
netaddr.IPNetwork('10.0.192.0/18'), IPNetwork('10.0.192.0/18'),
]) ])
available_prefixes = prefixes[0].get_available_prefixes() available_prefixes = prefixes[0].get_available_prefixes()
@ -119,17 +119,17 @@ class TestPrefix(TestCase):
def test_get_available_ips(self): def test_get_available_ips(self):
parent_prefix = Prefix.objects.create(prefix=netaddr.IPNetwork('10.0.0.0/28')) parent_prefix = Prefix.objects.create(prefix=IPNetwork('10.0.0.0/28'))
IPAddress.objects.bulk_create(( IPAddress.objects.bulk_create((
IPAddress(address=netaddr.IPNetwork('10.0.0.1/26')), IPAddress(address=IPNetwork('10.0.0.1/26')),
IPAddress(address=netaddr.IPNetwork('10.0.0.3/26')), IPAddress(address=IPNetwork('10.0.0.3/26')),
IPAddress(address=netaddr.IPNetwork('10.0.0.5/26')), IPAddress(address=IPNetwork('10.0.0.5/26')),
IPAddress(address=netaddr.IPNetwork('10.0.0.7/26')), IPAddress(address=IPNetwork('10.0.0.7/26')),
IPAddress(address=netaddr.IPNetwork('10.0.0.9/26')), IPAddress(address=IPNetwork('10.0.0.9/26')),
IPAddress(address=netaddr.IPNetwork('10.0.0.11/26')), IPAddress(address=IPNetwork('10.0.0.11/26')),
IPAddress(address=netaddr.IPNetwork('10.0.0.13/26')), IPAddress(address=IPNetwork('10.0.0.13/26')),
)) ))
missing_ips = netaddr.IPSet([ missing_ips = IPSet([
'10.0.0.2/32', '10.0.0.2/32',
'10.0.0.4/32', '10.0.0.4/32',
'10.0.0.6/32', '10.0.0.6/32',
@ -145,39 +145,39 @@ class TestPrefix(TestCase):
def test_get_first_available_prefix(self): def test_get_first_available_prefix(self):
prefixes = Prefix.objects.bulk_create(( prefixes = Prefix.objects.bulk_create((
Prefix(prefix=netaddr.IPNetwork('10.0.0.0/16')), # Parent prefix Prefix(prefix=IPNetwork('10.0.0.0/16')), # Parent prefix
Prefix(prefix=netaddr.IPNetwork('10.0.0.0/24')), Prefix(prefix=IPNetwork('10.0.0.0/24')),
Prefix(prefix=netaddr.IPNetwork('10.0.1.0/24')), Prefix(prefix=IPNetwork('10.0.1.0/24')),
Prefix(prefix=netaddr.IPNetwork('10.0.2.0/24')), Prefix(prefix=IPNetwork('10.0.2.0/24')),
)) ))
self.assertEqual(prefixes[0].get_first_available_prefix(), netaddr.IPNetwork('10.0.3.0/24')) self.assertEqual(prefixes[0].get_first_available_prefix(), IPNetwork('10.0.3.0/24'))
Prefix.objects.create(prefix=netaddr.IPNetwork('10.0.3.0/24')) Prefix.objects.create(prefix=IPNetwork('10.0.3.0/24'))
self.assertEqual(prefixes[0].get_first_available_prefix(), netaddr.IPNetwork('10.0.4.0/22')) self.assertEqual(prefixes[0].get_first_available_prefix(), IPNetwork('10.0.4.0/22'))
def test_get_first_available_ip(self): def test_get_first_available_ip(self):
parent_prefix = Prefix.objects.create(prefix=netaddr.IPNetwork('10.0.0.0/24')) parent_prefix = Prefix.objects.create(prefix=IPNetwork('10.0.0.0/24'))
IPAddress.objects.bulk_create(( IPAddress.objects.bulk_create((
IPAddress(address=netaddr.IPNetwork('10.0.0.1/24')), IPAddress(address=IPNetwork('10.0.0.1/24')),
IPAddress(address=netaddr.IPNetwork('10.0.0.2/24')), IPAddress(address=IPNetwork('10.0.0.2/24')),
IPAddress(address=netaddr.IPNetwork('10.0.0.3/24')), IPAddress(address=IPNetwork('10.0.0.3/24')),
)) ))
self.assertEqual(parent_prefix.get_first_available_ip(), '10.0.0.4/24') self.assertEqual(parent_prefix.get_first_available_ip(), '10.0.0.4/24')
IPAddress.objects.create(address=netaddr.IPNetwork('10.0.0.4/24')) IPAddress.objects.create(address=IPNetwork('10.0.0.4/24'))
self.assertEqual(parent_prefix.get_first_available_ip(), '10.0.0.5/24') self.assertEqual(parent_prefix.get_first_available_ip(), '10.0.0.5/24')
def test_get_utilization(self): def test_get_utilization(self):
# Container Prefix # Container Prefix
prefix = Prefix.objects.create( prefix = Prefix.objects.create(
prefix=netaddr.IPNetwork('10.0.0.0/24'), prefix=IPNetwork('10.0.0.0/24'),
status=PrefixStatusChoices.STATUS_CONTAINER status=PrefixStatusChoices.STATUS_CONTAINER
) )
Prefix.objects.bulk_create(( Prefix.objects.bulk_create((
Prefix(prefix=netaddr.IPNetwork('10.0.0.0/26')), Prefix(prefix=IPNetwork('10.0.0.0/26')),
Prefix(prefix=netaddr.IPNetwork('10.0.0.128/26')), Prefix(prefix=IPNetwork('10.0.0.128/26')),
)) ))
self.assertEqual(prefix.get_utilization(), 50) self.assertEqual(prefix.get_utilization(), 50)
@ -186,7 +186,7 @@ class TestPrefix(TestCase):
prefix.save() prefix.save()
IPAddress.objects.bulk_create( IPAddress.objects.bulk_create(
# Create 32 IPAddresses within the Prefix # Create 32 IPAddresses within the Prefix
[IPAddress(address=netaddr.IPNetwork('10.0.0.{}/24'.format(i))) for i in range(1, 33)] [IPAddress(address=IPNetwork('10.0.0.{}/24'.format(i))) for i in range(1, 33)]
) )
self.assertEqual(prefix.get_utilization(), 12) # ~= 12% self.assertEqual(prefix.get_utilization(), 12) # ~= 12%
@ -196,36 +196,234 @@ class TestPrefix(TestCase):
@override_settings(ENFORCE_GLOBAL_UNIQUE=False) @override_settings(ENFORCE_GLOBAL_UNIQUE=False)
def test_duplicate_global(self): def test_duplicate_global(self):
Prefix.objects.create(prefix=netaddr.IPNetwork('192.0.2.0/24')) Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'))
duplicate_prefix = Prefix(prefix=netaddr.IPNetwork('192.0.2.0/24')) duplicate_prefix = Prefix(prefix=IPNetwork('192.0.2.0/24'))
self.assertIsNone(duplicate_prefix.clean()) self.assertIsNone(duplicate_prefix.clean())
@override_settings(ENFORCE_GLOBAL_UNIQUE=True) @override_settings(ENFORCE_GLOBAL_UNIQUE=True)
def test_duplicate_global_unique(self): def test_duplicate_global_unique(self):
Prefix.objects.create(prefix=netaddr.IPNetwork('192.0.2.0/24')) Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'))
duplicate_prefix = Prefix(prefix=netaddr.IPNetwork('192.0.2.0/24')) duplicate_prefix = Prefix(prefix=IPNetwork('192.0.2.0/24'))
self.assertRaises(ValidationError, duplicate_prefix.clean) self.assertRaises(ValidationError, duplicate_prefix.clean)
def test_duplicate_vrf(self): def test_duplicate_vrf(self):
vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=False) vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=False)
Prefix.objects.create(vrf=vrf, prefix=netaddr.IPNetwork('192.0.2.0/24')) Prefix.objects.create(vrf=vrf, prefix=IPNetwork('192.0.2.0/24'))
duplicate_prefix = Prefix(vrf=vrf, prefix=netaddr.IPNetwork('192.0.2.0/24')) duplicate_prefix = Prefix(vrf=vrf, prefix=IPNetwork('192.0.2.0/24'))
self.assertIsNone(duplicate_prefix.clean()) self.assertIsNone(duplicate_prefix.clean())
def test_duplicate_vrf_unique(self): def test_duplicate_vrf_unique(self):
vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=True) vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=True)
Prefix.objects.create(vrf=vrf, prefix=netaddr.IPNetwork('192.0.2.0/24')) Prefix.objects.create(vrf=vrf, prefix=IPNetwork('192.0.2.0/24'))
duplicate_prefix = Prefix(vrf=vrf, prefix=netaddr.IPNetwork('192.0.2.0/24')) duplicate_prefix = Prefix(vrf=vrf, prefix=IPNetwork('192.0.2.0/24'))
self.assertRaises(ValidationError, duplicate_prefix.clean) self.assertRaises(ValidationError, duplicate_prefix.clean)
class TestPrefixHierarchy(TestCase):
"""
Test the automatic updating of depth and child count in response to changes made within
the prefix hierarchy.
"""
@classmethod
def setUpTestData(cls):
prefixes = (
# IPv4
Prefix(prefix='10.0.0.0/8', _depth=0, _children=2),
Prefix(prefix='10.0.0.0/16', _depth=1, _children=1),
Prefix(prefix='10.0.0.0/24', _depth=2, _children=0),
# IPv6
Prefix(prefix='2001:db8::/32', _depth=0, _children=2),
Prefix(prefix='2001:db8::/40', _depth=1, _children=1),
Prefix(prefix='2001:db8::/48', _depth=2, _children=0),
)
Prefix.objects.bulk_create(prefixes)
def test_create_prefix4(self):
# Create 10.0.0.0/12
Prefix(prefix='10.0.0.0/12').save()
prefixes = Prefix.objects.filter(prefix__family=4)
self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/8'))
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 3)
self.assertEqual(prefixes[1].prefix, IPNetwork('10.0.0.0/12'))
self.assertEqual(prefixes[1]._depth, 1)
self.assertEqual(prefixes[1]._children, 2)
self.assertEqual(prefixes[2].prefix, IPNetwork('10.0.0.0/16'))
self.assertEqual(prefixes[2]._depth, 2)
self.assertEqual(prefixes[2]._children, 1)
self.assertEqual(prefixes[3].prefix, IPNetwork('10.0.0.0/24'))
self.assertEqual(prefixes[3]._depth, 3)
self.assertEqual(prefixes[3]._children, 0)
def test_create_prefix6(self):
# Create 2001:db8::/36
Prefix(prefix='2001:db8::/36').save()
prefixes = Prefix.objects.filter(prefix__family=6)
self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/32'))
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 3)
self.assertEqual(prefixes[1].prefix, IPNetwork('2001:db8::/36'))
self.assertEqual(prefixes[1]._depth, 1)
self.assertEqual(prefixes[1]._children, 2)
self.assertEqual(prefixes[2].prefix, IPNetwork('2001:db8::/40'))
self.assertEqual(prefixes[2]._depth, 2)
self.assertEqual(prefixes[2]._children, 1)
self.assertEqual(prefixes[3].prefix, IPNetwork('2001:db8::/48'))
self.assertEqual(prefixes[3]._depth, 3)
self.assertEqual(prefixes[3]._children, 0)
def test_update_prefix4(self):
# Change 10.0.0.0/24 to 10.0.0.0/12
p = Prefix.objects.get(prefix='10.0.0.0/24')
p.prefix = '10.0.0.0/12'
p.save()
prefixes = Prefix.objects.filter(prefix__family=4)
self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/8'))
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 2)
self.assertEqual(prefixes[1].prefix, IPNetwork('10.0.0.0/12'))
self.assertEqual(prefixes[1]._depth, 1)
self.assertEqual(prefixes[1]._children, 1)
self.assertEqual(prefixes[2].prefix, IPNetwork('10.0.0.0/16'))
self.assertEqual(prefixes[2]._depth, 2)
self.assertEqual(prefixes[2]._children, 0)
def test_update_prefix6(self):
# Change 2001:db8::/48 to 2001:db8::/36
p = Prefix.objects.get(prefix='2001:db8::/48')
p.prefix = '2001:db8::/36'
p.save()
prefixes = Prefix.objects.filter(prefix__family=6)
self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/32'))
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 2)
self.assertEqual(prefixes[1].prefix, IPNetwork('2001:db8::/36'))
self.assertEqual(prefixes[1]._depth, 1)
self.assertEqual(prefixes[1]._children, 1)
self.assertEqual(prefixes[2].prefix, IPNetwork('2001:db8::/40'))
self.assertEqual(prefixes[2]._depth, 2)
self.assertEqual(prefixes[2]._children, 0)
def test_update_prefix_vrf4(self):
vrf = VRF(name='VRF A')
vrf.save()
# Move 10.0.0.0/16 to a VRF
p = Prefix.objects.get(prefix='10.0.0.0/16')
p.vrf = vrf
p.save()
prefixes = Prefix.objects.filter(vrf__isnull=True, prefix__family=4)
self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/8'))
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 1)
self.assertEqual(prefixes[1].prefix, IPNetwork('10.0.0.0/24'))
self.assertEqual(prefixes[1]._depth, 1)
self.assertEqual(prefixes[1]._children, 0)
prefixes = Prefix.objects.filter(vrf=vrf)
self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/16'))
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 0)
def test_update_prefix_vrf6(self):
vrf = VRF(name='VRF A')
vrf.save()
# Move 2001:db8::/40 to a VRF
p = Prefix.objects.get(prefix='2001:db8::/40')
p.vrf = vrf
p.save()
prefixes = Prefix.objects.filter(vrf__isnull=True, prefix__family=6)
self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/32'))
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 1)
self.assertEqual(prefixes[1].prefix, IPNetwork('2001:db8::/48'))
self.assertEqual(prefixes[1]._depth, 1)
self.assertEqual(prefixes[1]._children, 0)
prefixes = Prefix.objects.filter(vrf=vrf)
self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/40'))
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 0)
def test_delete_prefix4(self):
# Delete 10.0.0.0/16
Prefix.objects.filter(prefix='10.0.0.0/16').delete()
prefixes = Prefix.objects.filter(prefix__family=4)
self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/8'))
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 1)
self.assertEqual(prefixes[1].prefix, IPNetwork('10.0.0.0/24'))
self.assertEqual(prefixes[1]._depth, 1)
self.assertEqual(prefixes[1]._children, 0)
def test_delete_prefix6(self):
# Delete 2001:db8::/40
Prefix.objects.filter(prefix='2001:db8::/40').delete()
prefixes = Prefix.objects.filter(prefix__family=6)
self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/32'))
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 1)
self.assertEqual(prefixes[1].prefix, IPNetwork('2001:db8::/48'))
self.assertEqual(prefixes[1]._depth, 1)
self.assertEqual(prefixes[1]._children, 0)
def test_duplicate_prefix4(self):
# Duplicate 10.0.0.0/16
Prefix(prefix='10.0.0.0/16').save()
prefixes = Prefix.objects.filter(prefix__family=4)
self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/8'))
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 3)
self.assertEqual(prefixes[1].prefix, IPNetwork('10.0.0.0/16'))
self.assertEqual(prefixes[1]._depth, 1)
self.assertEqual(prefixes[1]._children, 1)
self.assertEqual(prefixes[2].prefix, IPNetwork('10.0.0.0/16'))
self.assertEqual(prefixes[2]._depth, 1)
self.assertEqual(prefixes[2]._children, 1)
self.assertEqual(prefixes[3].prefix, IPNetwork('10.0.0.0/24'))
self.assertEqual(prefixes[3]._depth, 2)
self.assertEqual(prefixes[3]._children, 0)
def test_duplicate_prefix6(self):
# Duplicate 2001:db8::/40
Prefix(prefix='2001:db8::/40').save()
prefixes = Prefix.objects.filter(prefix__family=6)
self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/32'))
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 3)
self.assertEqual(prefixes[1].prefix, IPNetwork('2001:db8::/40'))
self.assertEqual(prefixes[1]._depth, 1)
self.assertEqual(prefixes[1]._children, 1)
self.assertEqual(prefixes[2].prefix, IPNetwork('2001:db8::/40'))
self.assertEqual(prefixes[2]._depth, 1)
self.assertEqual(prefixes[2]._children, 1)
self.assertEqual(prefixes[3].prefix, IPNetwork('2001:db8::/48'))
self.assertEqual(prefixes[3]._depth, 2)
self.assertEqual(prefixes[3]._children, 0)
class TestIPAddress(TestCase): class TestIPAddress(TestCase):
def test_get_duplicates(self): def test_get_duplicates(self):
ips = IPAddress.objects.bulk_create(( ips = IPAddress.objects.bulk_create((
IPAddress(address=netaddr.IPNetwork('192.0.2.1/24')), IPAddress(address=IPNetwork('192.0.2.1/24')),
IPAddress(address=netaddr.IPNetwork('192.0.2.1/24')), IPAddress(address=IPNetwork('192.0.2.1/24')),
IPAddress(address=netaddr.IPNetwork('192.0.2.1/24')), IPAddress(address=IPNetwork('192.0.2.1/24')),
)) ))
duplicate_ip_pks = [p.pk for p in ips[0].get_duplicates()] duplicate_ip_pks = [p.pk for p in ips[0].get_duplicates()]
@ -237,44 +435,44 @@ class TestIPAddress(TestCase):
@override_settings(ENFORCE_GLOBAL_UNIQUE=False) @override_settings(ENFORCE_GLOBAL_UNIQUE=False)
def test_duplicate_global(self): def test_duplicate_global(self):
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24')) IPAddress.objects.create(address=IPNetwork('192.0.2.1/24'))
duplicate_ip = IPAddress(address=netaddr.IPNetwork('192.0.2.1/24')) duplicate_ip = IPAddress(address=IPNetwork('192.0.2.1/24'))
self.assertIsNone(duplicate_ip.clean()) self.assertIsNone(duplicate_ip.clean())
@override_settings(ENFORCE_GLOBAL_UNIQUE=True) @override_settings(ENFORCE_GLOBAL_UNIQUE=True)
def test_duplicate_global_unique(self): def test_duplicate_global_unique(self):
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24')) IPAddress.objects.create(address=IPNetwork('192.0.2.1/24'))
duplicate_ip = IPAddress(address=netaddr.IPNetwork('192.0.2.1/24')) duplicate_ip = IPAddress(address=IPNetwork('192.0.2.1/24'))
self.assertRaises(ValidationError, duplicate_ip.clean) self.assertRaises(ValidationError, duplicate_ip.clean)
def test_duplicate_vrf(self): def test_duplicate_vrf(self):
vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=False) vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=False)
IPAddress.objects.create(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24')) IPAddress.objects.create(vrf=vrf, address=IPNetwork('192.0.2.1/24'))
duplicate_ip = IPAddress(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24')) duplicate_ip = IPAddress(vrf=vrf, address=IPNetwork('192.0.2.1/24'))
self.assertIsNone(duplicate_ip.clean()) self.assertIsNone(duplicate_ip.clean())
def test_duplicate_vrf_unique(self): def test_duplicate_vrf_unique(self):
vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=True) vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=True)
IPAddress.objects.create(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24')) IPAddress.objects.create(vrf=vrf, address=IPNetwork('192.0.2.1/24'))
duplicate_ip = IPAddress(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24')) duplicate_ip = IPAddress(vrf=vrf, address=IPNetwork('192.0.2.1/24'))
self.assertRaises(ValidationError, duplicate_ip.clean) self.assertRaises(ValidationError, duplicate_ip.clean)
@override_settings(ENFORCE_GLOBAL_UNIQUE=True) @override_settings(ENFORCE_GLOBAL_UNIQUE=True)
def test_duplicate_nonunique_nonrole_role(self): def test_duplicate_nonunique_nonrole_role(self):
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24')) IPAddress.objects.create(address=IPNetwork('192.0.2.1/24'))
duplicate_ip = IPAddress(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP) duplicate_ip = IPAddress(address=IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP)
self.assertRaises(ValidationError, duplicate_ip.clean) self.assertRaises(ValidationError, duplicate_ip.clean)
@override_settings(ENFORCE_GLOBAL_UNIQUE=True) @override_settings(ENFORCE_GLOBAL_UNIQUE=True)
def test_duplicate_nonunique_role_nonrole(self): def test_duplicate_nonunique_role_nonrole(self):
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP) IPAddress.objects.create(address=IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP)
duplicate_ip = IPAddress(address=netaddr.IPNetwork('192.0.2.1/24')) duplicate_ip = IPAddress(address=IPNetwork('192.0.2.1/24'))
self.assertRaises(ValidationError, duplicate_ip.clean) self.assertRaises(ValidationError, duplicate_ip.clean)
@override_settings(ENFORCE_GLOBAL_UNIQUE=True) @override_settings(ENFORCE_GLOBAL_UNIQUE=True)
def test_duplicate_nonunique_role(self): def test_duplicate_nonunique_role(self):
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP) IPAddress.objects.create(address=IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP)
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP) IPAddress.objects.create(address=IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP)
class TestVLANGroup(TestCase): class TestVLANGroup(TestCase):

View File

@ -91,3 +91,63 @@ def add_available_vlans(vlan_group, vlans):
vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid']) vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid'])
return vlans return vlans
def rebuild_prefixes(vrf):
"""
Rebuild the prefix hierarchy for all prefixes in the specified VRF (or global table).
"""
def contains(parent, child):
return child in parent and child != parent
def push_to_stack(prefix):
# Increment child count on parent nodes
for n in stack:
n['children'] += 1
stack.append({
'pk': [prefix['pk']],
'prefix': prefix['prefix'],
'children': 0,
})
stack = []
update_queue = []
prefixes = Prefix.objects.filter(vrf=vrf).values('pk', 'prefix')
# Iterate through all Prefixes in the VRF, growing and shrinking the stack as we go
for i, p in enumerate(prefixes):
# Grow the stack if this is a child of the most recent prefix
if not stack or contains(stack[-1]['prefix'], p['prefix']):
push_to_stack(p)
# Handle duplicate prefixes
elif stack[-1]['prefix'] == p['prefix']:
stack[-1]['pk'].append(p['pk'])
# If this is a sibling or parent of the most recent prefix, pop nodes from the
# stack until we reach a parent prefix (or the root)
else:
while stack and not contains(stack[-1]['prefix'], p['prefix']):
node = stack.pop()
for pk in node['pk']:
update_queue.append(
Prefix(pk=pk, _depth=len(stack), _children=node['children'])
)
push_to_stack(p)
# Flush the update queue once it reaches 100 Prefixes
if len(update_queue) >= 100:
Prefix.objects.bulk_update(update_queue, ['_depth', '_children'])
update_queue = []
# Clear out any prefixes remaining in the stack
while stack:
node = stack.pop()
for pk in node['pk']:
update_queue.append(
Prefix(pk=pk, _depth=len(stack), _children=node['children'])
)
# Final flush of any remaining Prefixes
Prefix.objects.bulk_update(update_queue, ['_depth', '_children'])

View File

@ -238,7 +238,7 @@ class AggregateView(generic.ObjectView):
'site', 'role' 'site', 'role'
).order_by( ).order_by(
'prefix' 'prefix'
).annotate_tree() )
# Add available prefixes to the table if requested # Add available prefixes to the table if requested
if request.GET.get('show_available', 'true') == 'true': if request.GET.get('show_available', 'true') == 'true':
@ -352,7 +352,7 @@ class RoleBulkDeleteView(generic.BulkDeleteView):
# #
class PrefixListView(generic.ObjectListView): class PrefixListView(generic.ObjectListView):
queryset = Prefix.objects.annotate_tree() queryset = Prefix.objects.all()
filterset = filtersets.PrefixFilterSet filterset = filtersets.PrefixFilterSet
filterset_form = forms.PrefixFilterForm filterset_form = forms.PrefixFilterForm
table = tables.PrefixDetailTable table = tables.PrefixDetailTable
@ -377,7 +377,7 @@ class PrefixView(generic.ObjectView):
prefix__net_contains=str(instance.prefix) prefix__net_contains=str(instance.prefix)
).prefetch_related( ).prefetch_related(
'site', 'role' 'site', 'role'
).annotate_tree() )
parent_prefix_table = tables.PrefixTable(list(parent_prefixes), orderable=False) parent_prefix_table = tables.PrefixTable(list(parent_prefixes), orderable=False)
parent_prefix_table.exclude = ('vrf',) parent_prefix_table.exclude = ('vrf',)
@ -407,7 +407,7 @@ class PrefixPrefixesView(generic.ObjectView):
# Child prefixes table # Child prefixes table
child_prefixes = instance.get_child_prefixes().restrict(request.user, 'view').prefetch_related( child_prefixes = instance.get_child_prefixes().restrict(request.user, 'view').prefetch_related(
'site', 'vlan', 'role', 'site', 'vlan', 'role',
).annotate_tree() )
# Add available prefixes to the table if requested # Add available prefixes to the table if requested
if child_prefixes and request.GET.get('show_available', 'true') == 'true': if child_prefixes and request.GET.get('show_available', 'true') == 'true':
@ -551,6 +551,7 @@ class IPAddressView(generic.ObjectView):
vrf=instance.vrf, address__net_contained_or_equal=str(instance.address) vrf=instance.vrf, address__net_contained_or_equal=str(instance.address)
) )
related_ips_table = tables.IPAddressTable(related_ips, orderable=False) related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
paginate_table(related_ips_table, request)
return { return {
'parent_prefixes_table': parent_prefixes_table, 'parent_prefixes_table': parent_prefixes_table,

View File

@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup # Environment setup
# #
VERSION = '2.11.4' VERSION = '2.11.5'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -29,10 +29,10 @@ if platform.python_version_tuple() < ('3', '6'):
raise RuntimeError( raise RuntimeError(
"NetBox requires Python 3.6 or higher (current: Python {})".format(platform.python_version()) "NetBox requires Python 3.6 or higher (current: Python {})".format(platform.python_version())
) )
# TODO: Remove in NetBox v2.12 # TODO: Remove in NetBox v3.0
if platform.python_version_tuple() < ('3', '7'): if platform.python_version_tuple() < ('3', '7'):
warnings.warn( warnings.warn(
"Support for Python 3.6 will be dropped in NetBox v2.12. Please upgrade to Python 3.7 or later at your " "Support for Python 3.6 will be dropped in NetBox v3.0. Please upgrade to Python 3.7 or later at your "
"earliest convenience." "earliest convenience."
) )

View File

@ -774,9 +774,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
# If we are editing *all* objects in the queryset, replace the PK list with all matched objects. # If we are editing *all* objects in the queryset, replace the PK list with all matched objects.
if request.POST.get('_all') and self.filterset is not None: if request.POST.get('_all') and self.filterset is not None:
pk_list = [ pk_list = self.filterset(request.GET, self.queryset.values_list('pk', flat=True)).qs
obj.pk for obj in self.filterset(request.GET, self.queryset.only('pk')).qs
]
else: else:
pk_list = request.POST.getlist('pk') pk_list = request.POST.getlist('pk')

View File

@ -92,7 +92,7 @@ def inject_deprecation_warning(request):
""" """
messages.warning( messages.warning(
request, request,
mark_safe('<i class="mdi mdi-alert"></i> The secrets functionality will be moved to a plugin in NetBox v2.12. ' mark_safe('<i class="mdi mdi-alert"></i> The secrets functionality will be moved to a plugin in NetBox v3.0. '
'Please see <a href="https://github.com/netbox-community/netbox/issues/5278">issue #5278</a> for ' 'Please see <a href="https://github.com/netbox-community/netbox/issues/5278">issue #5278</a> for '
'more information.') 'more information.')
) )

View File

@ -74,7 +74,7 @@
<i class="mdi mdi-book-open-page-variant text-primary"></i> <a href="https://netbox.readthedocs.io/">Docs</a> &middot; <i class="mdi mdi-book-open-page-variant text-primary"></i> <a href="https://netbox.readthedocs.io/">Docs</a> &middot;
<i class="mdi mdi-cloud-braces text-primary"></i> <a href="{% url 'api_docs' %}">API</a> &middot; <i class="mdi mdi-cloud-braces text-primary"></i> <a href="{% url 'api_docs' %}">API</a> &middot;
<i class="mdi mdi-xml text-primary"></i> <a href="https://github.com/netbox-community/netbox">Code</a> &middot; <i class="mdi mdi-xml text-primary"></i> <a href="https://github.com/netbox-community/netbox">Code</a> &middot;
<i class="mdi mdi-lifebuoy text-primary"></i> <a href="https://github.com/netbox-community/netbox/wiki">Help</a> <i class="mdi mdi-slack text-primary"></i> <a href="https://netdev.chat/">Community</a>
</p> </p>
</div> </div>
</div> </div>

View File

@ -35,13 +35,13 @@
<div class="form-group"> <div class="form-group">
<label class="col-md-3 control-label required">Region</label> <label class="col-md-3 control-label required">Region</label>
<div class="col-md-9"> <div class="col-md-9">
<p class="form-control-static">{{ termination_a.device.site.region }}</p> <p class="form-control-static">{{ termination_a.device.site.region|placeholder }}</p>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="col-md-3 control-label required">Site Group</label> <label class="col-md-3 control-label required">Site Group</label>
<div class="col-md-9"> <div class="col-md-9">
<p class="form-control-static">{{ termination_a.device.site.group }}</p> <p class="form-control-static">{{ termination_a.device.site.group|placeholder }}</p>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -50,10 +50,16 @@
<p class="form-control-static">{{ termination_a.device.site }}</p> <p class="form-control-static">{{ termination_a.device.site }}</p>
</div> </div>
</div> </div>
<div class="form-group">
<label class="col-md-3 control-label required">Location</label>
<div class="col-md-9">
<p class="form-control-static">{{ termination_a.device.location|placeholder }}</p>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label class="col-md-3 control-label required">Rack</label> <label class="col-md-3 control-label required">Rack</label>
<div class="col-md-9"> <div class="col-md-9">
<p class="form-control-static">{{ termination_a.device.rack|default:"None" }}</p> <p class="form-control-static">{{ termination_a.device.rack|placeholder }}</p>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@ -29,7 +29,7 @@
{% endif %} {% endif %}
<h1 class="title">{{ report.name }}</h1> <h1 class="title">{{ report.name }}</h1>
{% if report.description %} {% if report.description %}
<p class="lead">{{ report.description }}</p> <p class="lead">{{ report.description|render_markdown }}</p>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -29,7 +29,7 @@
<td> <td>
{% include 'extras/inc/job_label.html' with result=report.result %} {% include 'extras/inc/job_label.html' with result=report.result %}
</td> </td>
<td>{{ report.description|placeholder }}</td> <td class="rendered-markdown">{{ report.description|render_markdown|placeholder }}</td>
<td class="text-right"> <td class="text-right">
{% if report.result %} {% if report.result %}
<a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">{{ report.result.created }}</a> <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">{{ report.result.created }}</a>

View File

@ -29,58 +29,58 @@
{% block sidebar %}{% endblock %} {% block sidebar %}{% endblock %}
</div> </div>
{% endif %} {% endif %}
{% with bulk_edit_url=content_type.model_class|validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|validated_viewname:"bulk_delete" %} <div class="table-responsive">
{% if permissions.change or permissions.delete %} {% with bulk_edit_url=content_type.model_class|validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|validated_viewname:"bulk_delete" %}
<form method="post" class="form form-horizontal"> {% if permissions.change or permissions.delete %}
{% csrf_token %} <form method="post" class="form form-horizontal">
<input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" /> {% csrf_token %}
{% if table.paginator.num_pages > 1 %} <input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
<div id="select_all_box" class="hidden panel panel-default noprint"> {% if table.paginator.num_pages > 1 %}
<div class="panel-body"> <div id="select_all_box" class="hidden panel panel-default noprint">
<div class="checkbox-inline"> <div class="panel-body">
<label for="select_all"> <div class="checkbox-inline">
<input type="checkbox" id="select_all" name="_all" /> <label for="select_all">
Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query <input type="checkbox" id="select_all" name="_all" />
</label> Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
</div> </label>
<div class="pull-right"> </div>
{% if bulk_edit_url and permissions.change %} <div class="pull-right">
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled"> {% if bulk_edit_url and permissions.change %}
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit All <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled">
</button> <span class="mdi mdi-pencil" aria-hidden="true"></span> Edit All
{% endif %} </button>
{% if bulk_delete_url and permissions.delete %} {% endif %}
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled="disabled"> {% if bulk_delete_url and permissions.delete %}
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete All <button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled="disabled">
</button> <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete All
{% endif %} </button>
{% endif %}
</div>
</div> </div>
</div> </div>
{% endif %}
{% render_table table 'inc/table.html' %}
<div class="pull-left noprint">
{% block bulk_buttons %}{% endblock %}
{% if bulk_edit_url and permissions.change %}
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit Selected
</button>
{% endif %}
{% if bulk_delete_url and permissions.delete %}
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete Selected
</button>
{% endif %}
</div> </div>
{% endif %} </form>
{% else %}
<div class="table-responsive"> <div class="table-responsive">
{% render_table table 'inc/table.html' %} {% render_table table 'inc/table.html' %}
</div> </div>
<div class="pull-left noprint"> {% endif %}
{% block bulk_buttons %}{% endblock %} {% endwith %}
{% if bulk_edit_url and permissions.change %} </div>
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit Selected
</button>
{% endif %}
{% if bulk_delete_url and permissions.delete %}
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete Selected
</button>
{% endif %}
</div>
</form>
{% else %}
<div class="table-responsive">
{% render_table table 'inc/table.html' %}
</div>
{% endif %}
{% endwith %}
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
<div class="clearfix"></div> <div class="clearfix"></div>
</div> </div>

View File

@ -2,6 +2,26 @@
{% load helpers %} {% load helpers %}
{% block buttons %} {% block buttons %}
<div class="btn-group" role="group">
<div class="dropdown">
<button class="btn btn-default dropdown-toggle" type="button" id="max_length" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
Max Depth{% if "depth__lte" in request.GET %}: {{ request.GET.depth__lte }}{% endif %}
<span class="caret"></span>
</button>
<ul class="dropdown-menu" aria-labelledby="max_length">
{% if request.GET.depth__lte %}
<li>
<a href="{% url 'ipam:prefix_list' %}{% querystring request depth__lte=None page=1 %}">Clear</a>
</li>
{% endif %}
{% for i in 16|as_range %}
<li><a href="{% url 'ipam:prefix_list' %}{% querystring request depth__lte=i page=1 %}">
{{ i }} {% if request.GET.depth__lte == i %}<i class="mdi mdi-check-bold"></i>{% endif %}
</a></li>
{% endfor %}
</ul>
</div>
</div>
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<div class="dropdown"> <div class="dropdown">
<button class="btn btn-default dropdown-toggle" type="button" id="max_length" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true"> <button class="btn btn-default dropdown-toggle" type="button" id="max_length" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">

View File

@ -7,7 +7,7 @@ from django.core.exceptions import FieldError, ValidationError
from utilities.forms.fields import ContentTypeMultipleChoiceField from utilities.forms.fields import ContentTypeMultipleChoiceField
from .constants import * from .constants import *
from .models import AdminGroup, AdminUser, ObjectPermission, Token, UserConfig from .models import ObjectPermission, Token, UserConfig
# #
@ -39,11 +39,11 @@ class ObjectPermissionInline(admin.TabularInline):
class GroupObjectPermissionInline(ObjectPermissionInline): class GroupObjectPermissionInline(ObjectPermissionInline):
model = AdminGroup.object_permissions.through model = Group.object_permissions.through
class UserObjectPermissionInline(ObjectPermissionInline): class UserObjectPermissionInline(ObjectPermissionInline):
model = AdminUser.object_permissions.through model = User.object_permissions.through
class UserConfigInline(admin.TabularInline): class UserConfigInline(admin.TabularInline):
@ -62,7 +62,7 @@ admin.site.unregister(Group)
admin.site.unregister(User) admin.site.unregister(User)
@admin.register(AdminGroup) @admin.register(Group)
class GroupAdmin(admin.ModelAdmin): class GroupAdmin(admin.ModelAdmin):
fields = ('name',) fields = ('name',)
list_display = ('name', 'user_count') list_display = ('name', 'user_count')
@ -75,7 +75,7 @@ class GroupAdmin(admin.ModelAdmin):
return obj.user_set.count() return obj.user_set.count()
@admin.register(AdminUser) @admin.register(User)
class UserAdmin(UserAdmin_): class UserAdmin(UserAdmin_):
list_display = [ list_display = [
'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active'

View File

@ -17,8 +17,6 @@ from .constants import *
__all__ = ( __all__ = (
'AdminGroup',
'AdminUser',
'ObjectPermission', 'ObjectPermission',
'Token', 'Token',
'UserConfig', 'UserConfig',
@ -163,7 +161,6 @@ class UserConfig(models.Model):
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
@receiver(post_save, sender=AdminUser)
def create_userconfig(instance, created, **kwargs): def create_userconfig(instance, created, **kwargs):
""" """
Automatically create a new UserConfig when a new User is created. Automatically create a new UserConfig when a new User is created.

View File

@ -338,7 +338,7 @@ class DynamicModelChoiceMixin:
filter = django_filters.ModelChoiceFilter filter = django_filters.ModelChoiceFilter
widget = widgets.APISelect widget = widgets.APISelect
# TODO: Remove display_field in v2.12 # TODO: Remove display_field in v3.0
def __init__(self, display_field='display', query_params=None, initial_params=None, null_option=None, def __init__(self, display_field='display', query_params=None, initial_params=None, null_option=None,
disabled_indicator=None, *args, **kwargs): disabled_indicator=None, *args, **kwargs):
self.display_field = display_field self.display_field = display_field

View File

@ -1,4 +1,5 @@
import django_tables2 as tables import django_tables2 as tables
from django.conf import settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -284,7 +285,10 @@ class LinkedCountColumn(tables.Column):
if value: if value:
url = reverse(self.viewname, kwargs=self.view_kwargs) url = reverse(self.viewname, kwargs=self.view_kwargs)
if self.url_params: if self.url_params:
url += '?' + '&'.join([f'{k}={getattr(record, v)}' for k, v in self.url_params.items()]) url += '?' + '&'.join([
f'{k}={getattr(record, v) or settings.FILTERS_NULL_CHOICE_VALUE}'
for k, v in self.url_params.items()
])
return mark_safe(f'<a href="{url}">{value}</a>') return mark_safe(f'<a href="{url}">{value}</a>')
return value return value

View File

@ -105,7 +105,7 @@ def serialize_object(obj, extra=None):
# Include any tags. Check for tags cached on the instance; fall back to using the manager. # Include any tags. Check for tags cached on the instance; fall back to using the manager.
if is_taggable(obj): if is_taggable(obj):
tags = getattr(obj, '_tags', obj.tags.all()) tags = getattr(obj, '_tags', None) or obj.tags.all()
data['tags'] = [tag.name for tag in tags] data['tags'] = [tag.name for tag in tags]
# Append any extra data # Append any extra data

View File

@ -1,4 +1,4 @@
Django==3.2.3 Django==3.2.4
django-cacheops==6.0 django-cacheops==6.0
django-cors-headers==3.7.0 django-cors-headers==3.7.0
django-debug-toolbar==3.2.1 django-debug-toolbar==3.2.1

View File

@ -15,7 +15,7 @@ else
fi fi
# Create a new virtual environment # Create a new virtual environment
COMMAND="/usr/bin/python3 -m venv ${VIRTUALENV}" COMMAND="python3 -m venv ${VIRTUALENV}"
echo "Creating a new virtual environment at ${VIRTUALENV}..." echo "Creating a new virtual environment at ${VIRTUALENV}..."
eval $COMMAND || { eval $COMMAND || {
echo "--------------------------------------------------------------------" echo "--------------------------------------------------------------------"