diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 23d5b8182..17533e2c9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.1.3 + placeholder: v3.1.4 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 00b464515..1c31f0c29 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.1.3 + placeholder: v3.1.4 validations: required: true - type: dropdown diff --git a/.gitignore b/.gitignore index 0ce9a20a8..93954fd41 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ yarn-error.log* !/netbox/project-static/docs/.info /netbox/netbox/configuration.py /netbox/netbox/ldap_config.py +/netbox/local/* /netbox/reports/* !/netbox/reports/__init__.py /netbox/scripts/* diff --git a/README.md b/README.md index 39981a2b0..888b881ab 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,46 @@ ![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) NetBox is an infrastructure resource modeling (IRM) tool designed to empower -network automation. Initially conceived by the network engineering team at +network automation, used by thousands of organizations around the world. +Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. It is intended to function as a domain-specific source of truth for network operations. +Myriad infrastructure components can be modeled in NetBox, including: + +* Hierarchical regions, site groups, sites, and locations +* Racks, devices, and device components +* Cables and wireless connections +* Power distribution +* Data circuits and providers +* Virtual machines and clusters +* IP prefixes, ranges, and addresses +* VRFs and route targets +* FHRP groups (VRRP, HSRP, etc.) +* AS numbers +* VLANs and scoped VLAN groups +* Organizational tenants and contacts + +In addition to its extensive built-in models and functionality, NetBox can be +customized and extended through the use of: + +* Custom fields +* Custom links +* Configuration contexts +* Custom model validation rules +* Reports +* Custom scripts +* Export templates +* Conditional webhooks +* Plugins +* Single sign-on (SSO) authentication +* NAPALM integration +* Detailed change logging + +NetBox also features a complete REST API as well as a GraphQL API for easily +integrating with other tools and systems. + NetBox runs as a web application atop the [Django](https://www.djangoproject.com/) Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox). diff --git a/docs/development/adding-models.md b/docs/development/adding-models.md index 156a8ba97..d55afb2f2 100644 --- a/docs/development/adding-models.md +++ b/docs/development/adding-models.md @@ -37,23 +37,32 @@ Most models will need view classes created in `views.py` to serve the following Add the relevant URL path for each view created in the previous step to `urls.py`. -## 6. Create the FilterSet +## 6. Add relevant forms + +Depending on the type of model being added, you may need to define several types of form classes. These include: + +* A base model form (for creating/editing individual objects) +* A bulk edit form +* A bulk import form (for CSV-based import) +* A filterset form (for filtering the object list view) + +## 7. Create the FilterSet Each model should have a corresponding FilterSet class defined. This is used to filter UI and API queries. Subclass the appropriate class from `netbox.filtersets` that matches the model's parent class. -## 7. Create the table class +## 8. Create the table class Create a table class for the model in `tables.py` by subclassing `utilities.tables.BaseTable`. Under the table's `Meta` class, be sure to list both the fields and default columns. -## 8. Create the object template +## 9. Create the object template Create the HTML template for the object view. (The other views each typically employ a generic template.) This template should extend `generic/object.html`. -## 9. Add the model to the navigation menu +## 10. Add the model to the navigation menu Add the relevant navigation menu items in `netbox/netbox/navigation_menu.py`. -## 10. REST API components +## 11. REST API components Create the following for each model: @@ -62,13 +71,13 @@ Create the following for each model: * API view in `api/views.py` * Endpoint route in `api/urls.py` -## 11. GraphQL API components +## 12. GraphQL API components Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`. Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention. -## 12. Add tests +## 13. Add tests Add tests for the following: @@ -76,7 +85,7 @@ Add tests for the following: * API views * Filter sets -## 13. Documentation +## 14. Documentation Create a new documentation page for the model in `docs/models//.md`. Include this file under the "features" documentation where appropriate. diff --git a/docs/installation/6-ldap.md b/docs/installation/6-ldap.md index 86114dfb0..281554f75 100644 --- a/docs/installation/6-ldap.md +++ b/docs/installation/6-ldap.md @@ -152,7 +152,7 @@ LOGGING = { 'netbox_auth_log': { 'level': 'DEBUG', 'class': 'logging.handlers.RotatingFileHandler', - 'filename': '/opt/netbox/logs/django-ldap-debug.log', + 'filename': '/opt/netbox/local/logs/django-ldap-debug.log', 'maxBytes': 1024 * 500, 'backupCount': 5, }, diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index d27c3f76f..670cc4cce 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -1,5 +1,23 @@ # NetBox v3.1 +## v3.1.4 (2022-01-03) + +### Enhancements + +* [#8192](https://github.com/netbox-community/netbox/issues/8192) - Add "add prefix" button to aggregate child prefixes view +* [#8194](https://github.com/netbox-community/netbox/issues/8194) - Enable bulk user assignment to groups under admin UI +* [#8197](https://github.com/netbox-community/netbox/issues/8197) - Allow filtering sites by group when connecting a cable +* [#8210](https://github.com/netbox-community/netbox/issues/8210) - Establish `netbox/local/` as a path for local resources + +### Bug Fixes + +* [#8187](https://github.com/netbox-community/netbox/issues/8187) - Fix rendering of tags column in object tables +* [#8191](https://github.com/netbox-community/netbox/issues/8191) - Fix return URL when adding IP addresses to VM interfaces +* [#8196](https://github.com/netbox-community/netbox/issues/8196) - Fix IndexError exception when viewing large IPv6 prefixes in UI +* [#8201](https://github.com/netbox-community/netbox/issues/8201) - Custom integer fields should allow negative integers as minimum/maximum values + +--- + ## v3.1.3 (2021-12-29) ### Enhancements diff --git a/docs/rest-api/authentication.md b/docs/rest-api/authentication.md index 1571f15fa..11b8cd6bf 100644 --- a/docs/rest-api/authentication.md +++ b/docs/rest-api/authentication.md @@ -42,7 +42,7 @@ $ curl -X POST \ https://netbox/api/users/tokens/provision/ \ --data '{ "username": "hankhill", - "password: "I<3C3H8", + "password": "I<3C3H8", }' ``` diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index 771ff38bc..6a7a09023 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -27,7 +27,7 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm): label='Region', required=False ) - termination_b_site_group = DynamicModelChoiceField( + termination_b_sitegroup = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), label='Site group', required=False @@ -38,7 +38,7 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm): required=False, query_params={ 'region_id': '$termination_b_region', - 'group_id': '$termination_b_site_group', + 'group_id': '$termination_b_sitegroup', } ) termination_b_location = DynamicModelChoiceField( @@ -78,9 +78,9 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm): class Meta: model = Cable fields = [ - 'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device', - 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', - 'tags', + 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_rack', + 'termination_b_device', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', + 'length', 'length_unit', 'tags', ] widgets = { 'status': StaticSelect, @@ -182,7 +182,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm): label='Region', required=False ) - termination_b_site_group = DynamicModelChoiceField( + termination_b_sitegroup = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), label='Site group', required=False @@ -193,7 +193,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm): required=False, query_params={ 'region_id': '$termination_b_region', - 'group_id': '$termination_b_site_group', + 'group_id': '$termination_b_sitegroup', } ) termination_b_circuit = DynamicModelChoiceField( @@ -219,9 +219,9 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm): class Meta(ConnectCableToDeviceForm.Meta): fields = [ - 'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit', - 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', - 'tags', + 'termination_b_provider', 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', + 'termination_b_circuit', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', + 'length', 'length_unit', 'tags', ] def clean_termination_b_id(self): @@ -235,7 +235,7 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm): label='Region', required=False ) - termination_b_site_group = DynamicModelChoiceField( + termination_b_sitegroup = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), label='Site group', required=False @@ -246,7 +246,7 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm): required=False, query_params={ 'region_id': '$termination_b_region', - 'group_id': '$termination_b_site_group', + 'group_id': '$termination_b_sitegroup', } ) termination_b_location = DynamicModelChoiceField( @@ -281,8 +281,9 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm): class Meta(ConnectCableToDeviceForm.Meta): fields = [ - 'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group', - 'tenant', 'label', 'color', 'length', 'length_unit', 'tags', + 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_location', + 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', + 'color', 'length', 'length_unit', 'tags', ] def clean_termination_b_id(self): diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 30c560d88..14bbe3589 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -92,7 +92,7 @@ class RackTable(BaseTable): ) default_columns = ( 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', - 'get_utilization', 'get_power_utilization', + 'get_utilization', ) diff --git a/netbox/extras/migrations/0067_customfield_min_max_values.py b/netbox/extras/migrations/0067_customfield_min_max_values.py new file mode 100644 index 000000000..cec4f6ae0 --- /dev/null +++ b/netbox/extras/migrations/0067_customfield_min_max_values.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0066_customfield_name_validation'), + ] + + operations = [ + migrations.AlterField( + model_name='customfield', + name='validation_maximum', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='customfield', + name='validation_minimum', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 713ef6c93..8c817ad33 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -96,13 +96,13 @@ class CustomField(ChangeLoggedModel): default=100, help_text='Fields with higher weights appear lower in a form.' ) - validation_minimum = models.PositiveIntegerField( + validation_minimum = models.IntegerField( blank=True, null=True, verbose_name='Minimum value', help_text='Minimum allowed value (for numeric fields)' ) - validation_maximum = models.PositiveIntegerField( + validation_maximum = models.IntegerField( blank=True, null=True, verbose_name='Maximum value', diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 5a9c4257f..fdabe0fcf 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -25,49 +25,68 @@ class CustomFieldTest(TestCase): def test_simple_fields(self): DATA = ( { - 'field_type': CustomFieldTypeChoices.TYPE_TEXT, - 'field_value': 'Foobar!', - 'empty_value': '', + 'field': { + 'type': CustomFieldTypeChoices.TYPE_TEXT, + }, + 'value': 'Foobar!', }, { - 'field_type': CustomFieldTypeChoices.TYPE_LONGTEXT, - 'field_value': 'Text with **Markdown**', - 'empty_value': '', + 'field': { + 'type': CustomFieldTypeChoices.TYPE_LONGTEXT, + }, + 'value': 'Text with **Markdown**', }, { - 'field_type': CustomFieldTypeChoices.TYPE_INTEGER, - 'field_value': 0, - 'empty_value': None, + 'field': { + 'type': CustomFieldTypeChoices.TYPE_INTEGER, + }, + 'value': 0, }, { - 'field_type': CustomFieldTypeChoices.TYPE_INTEGER, - 'field_value': 42, - 'empty_value': None, + 'field': { + 'type': CustomFieldTypeChoices.TYPE_INTEGER, + 'validation_minimum': 1, + 'validation_maximum': 100, + }, + 'value': 42, }, { - 'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, - 'field_value': True, - 'empty_value': None, + 'field': { + 'type': CustomFieldTypeChoices.TYPE_INTEGER, + 'validation_minimum': -100, + 'validation_maximum': -1, + }, + 'value': -42, }, { - 'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, - 'field_value': False, - 'empty_value': None, + 'field': { + 'type': CustomFieldTypeChoices.TYPE_BOOLEAN, + }, + 'value': True, }, { - 'field_type': CustomFieldTypeChoices.TYPE_DATE, - 'field_value': '2016-06-23', - 'empty_value': None, + 'field': { + 'type': CustomFieldTypeChoices.TYPE_BOOLEAN, + }, + 'value': False, }, { - 'field_type': CustomFieldTypeChoices.TYPE_URL, - 'field_value': 'http://example.com/', - 'empty_value': '', + 'field': { + 'type': CustomFieldTypeChoices.TYPE_DATE, + }, + 'value': '2016-06-23', }, { - 'field_type': CustomFieldTypeChoices.TYPE_JSON, - 'field_value': '{"foo": 1, "bar": 2}', - 'empty_value': 'null', + 'field': { + 'type': CustomFieldTypeChoices.TYPE_URL, + }, + 'value': 'http://example.com/', + }, + { + 'field': { + 'type': CustomFieldTypeChoices.TYPE_JSON, + }, + 'value': '{"foo": 1, "bar": 2}', }, ) @@ -76,7 +95,7 @@ class CustomFieldTest(TestCase): for data in DATA: # Create a custom field - cf = CustomField(type=data['field_type'], name='my_field', required=False) + cf = CustomField(name='my_field', required=False, **data['field']) cf.save() cf.content_types.set([obj_type]) @@ -85,12 +104,12 @@ class CustomFieldTest(TestCase): self.assertIsNone(site.custom_field_data[cf.name]) # Assign a value to the first Site - site.custom_field_data[cf.name] = data['field_value'] + site.custom_field_data[cf.name] = data['value'] site.save() # Retrieve the stored value site.refresh_from_db() - self.assertEqual(site.custom_field_data[cf.name], data['field_value']) + self.assertEqual(site.custom_field_data[cf.name], data['value']) # Delete the stored value site.custom_field_data.pop(cf.name) diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index edb14a25c..162328e97 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -302,7 +302,8 @@ class IPAddressBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): ) dns_name = forms.CharField( max_length=255, - required=False + required=False, + label='DNS name' ) description = forms.CharField( max_length=100, diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index aeb71e70f..9c00a9068 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -32,6 +32,28 @@ __all__ = ( ) +class GetAvailablePrefixesMixin: + + def get_available_prefixes(self): + """ + Return all available Prefixes within this aggregate as an IPSet. + """ + prefix = netaddr.IPSet(self.prefix) + child_prefixes = netaddr.IPSet([child.prefix for child in self.get_child_prefixes()]) + available_prefixes = prefix - child_prefixes + + return available_prefixes + + def get_first_available_prefix(self): + """ + Return the first available child prefix within the prefix (or None). + """ + available_prefixes = self.get_available_prefixes() + if not available_prefixes: + return None + return available_prefixes.iter_cidrs()[0] + + @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RIR(OrganizationalModel): """ @@ -110,7 +132,7 @@ class ASN(PrimaryModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Aggregate(PrimaryModel): +class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR. @@ -245,7 +267,7 @@ class Role(OrganizationalModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Prefix(PrimaryModel): +class Prefix(GetAvailablePrefixesMixin, PrimaryModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be @@ -458,16 +480,6 @@ class Prefix(PrimaryModel): else: return IPAddress.objects.filter(address__net_host_contained=str(self.prefix), vrf=self.vrf) - def get_available_prefixes(self): - """ - Return all available Prefixes within this prefix as an IPSet. - """ - prefix = netaddr.IPSet(self.prefix) - child_prefixes = netaddr.IPSet([child.prefix for child in self.get_child_prefixes()]) - available_prefixes = prefix - child_prefixes - - return available_prefixes - def get_available_ips(self): """ Return all available IPs within this prefix as an IPSet. @@ -494,15 +506,6 @@ class Prefix(PrimaryModel): return available_ips - def get_first_available_prefix(self): - """ - Return the first available child prefix within the prefix (or None). - """ - available_prefixes = self.get_available_prefixes() - if not available_prefixes: - return None - return available_prefixes.iter_cidrs()[0] - def get_first_available_ip(self): """ Return the first available IP within the prefix (or None). diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 317caeaf2..55ac284d1 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -299,6 +299,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView): return { 'bulk_querystring': f'within={instance.prefix}', 'active_tab': 'prefixes', + 'first_available_prefix': instance.get_first_available_prefix(), 'show_available': bool(request.GET.get('show_available', 'true') == 'true'), 'show_assigned': bool(request.GET.get('show_assigned', 'true') == 'true'), } @@ -455,7 +456,9 @@ class PrefixPrefixesView(generic.ObjectChildrenView): template_name = 'ipam/prefix/prefixes.html' def get_children(self, request, parent): - return parent.get_child_prefixes().restrict(request.user, 'view') + return parent.get_child_prefixes().restrict(request.user, 'view').prefetch_related( + 'site', 'vrf', 'vlan', 'role', 'tenant', + ) def prep_table_data(self, request, queryset, parent): # Determine whether to show assigned prefixes, available prefixes, or both @@ -482,7 +485,9 @@ class PrefixIPRangesView(generic.ObjectChildrenView): template_name = 'ipam/prefix/ip_ranges.html' def get_children(self, request, parent): - return parent.get_child_ranges().restrict(request.user, 'view') + return parent.get_child_ranges().restrict(request.user, 'view').prefetch_related( + 'vrf', 'role', 'tenant', + ) def get_extra_context(self, request, instance): return { @@ -500,7 +505,9 @@ class PrefixIPAddressesView(generic.ObjectChildrenView): template_name = 'ipam/prefix/ip_addresses.html' def get_children(self, request, parent): - return parent.get_child_ips().restrict(request.user, 'view') + return parent.get_child_ips().restrict(request.user, 'view').prefetch_related( + 'vrf', 'role', 'tenant', + ) def prep_table_data(self, request, queryset, parent): show_available = bool(request.GET.get('show_available', 'true') == 'true') @@ -569,7 +576,9 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView): template_name = 'ipam/iprange/ip_addresses.html' def get_children(self, request, parent): - return parent.get_child_ips().restrict(request.user, 'view') + return parent.get_child_ips().restrict(request.user, 'view').prefetch_related( + 'vrf', 'role', 'tenant', + ) def get_extra_context(self, request, instance): return { diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 488fa163d..130d2dee8 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -176,7 +176,7 @@ CONNECTIONS_MENU = Menu( label='Connections', items=( get_model_item('dcim', 'cable', 'Cables', actions=['import']), - get_model_item('wireless', 'wirelesslink', 'Wirelesss Links', actions=['import']), + get_model_item('wireless', 'wirelesslink', 'Wireless Links', actions=['import']), MenuItem( link='dcim:interface_connections_list', link_text='Interface Connections', diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 9c2fb0174..c22443275 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -19,7 +19,7 @@ from netbox.config import PARAMS # Environment setup # -VERSION = '3.1.3' +VERSION = '3.1.4' # Hostname HOSTNAME = platform.node() diff --git a/netbox/templates/dcim/cable_connect.html b/netbox/templates/dcim/cable_connect.html index 03dcaa2e4..1d50040c7 100644 --- a/netbox/templates/dcim/cable_connect.html +++ b/netbox/templates/dcim/cable_connect.html @@ -5,6 +5,14 @@ {% block title %}Connect {{ form.instance.termination_a.device }} {{ form.instance.termination_a }} to {{ termination_b_type|bettertitle }}{% endblock %} +{% block tabs %} + +{% endblock %} + {% block content-wrapper %}
{% with termination_a=form.instance.termination_a %} @@ -27,6 +35,12 @@
+
+ +
+ +
+
@@ -115,6 +129,9 @@ {% if 'termination_b_region' in form.fields %} {% render_field form.termination_b_region %} {% endif %} + {% if 'termination_b_sitegroup' in form.fields %} + {% render_field form.termination_b_sitegroup %} + {% endif %} {% if 'termination_b_site' in form.fields %} {% render_field form.termination_b_site %} {% endif %} diff --git a/netbox/templates/ipam/aggregate/prefixes.html b/netbox/templates/ipam/aggregate/prefixes.html index 22a74bd45..3f805fc2e 100644 --- a/netbox/templates/ipam/aggregate/prefixes.html +++ b/netbox/templates/ipam/aggregate/prefixes.html @@ -3,6 +3,11 @@ {% block extra_controls %} {% include 'ipam/inc/toggle_available.html' %} + {% if perms.ipam.add_prefix and first_available_prefix %} + + Add Prefix + + {% endif %} {{ block.super }} {% endblock %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index c3d3bdedd..cb62e56ae 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -1,4 +1,5 @@ {% extends 'ipam/prefix/base.html' %} +{% load humanize %} {% load helpers %} {% load plugins %} @@ -124,9 +125,18 @@ {{ child_ip_count }} + {% endwith %} + {% with available_count=object.get_available_ips.size %} Available IPs - {{ object.get_available_ips|length }} + + {# Use human-friendly words for counts greater than one million #} + {% if available_count > 1000000 %} + {{ available_count|intword }} + {% else %} + {{ available_count|intcomma }} + {% endif %} + {% endwith %} diff --git a/netbox/templates/ipam/prefix/ip_ranges.html b/netbox/templates/ipam/prefix/ip_ranges.html index f8b70f39a..af80578a0 100644 --- a/netbox/templates/ipam/prefix/ip_ranges.html +++ b/netbox/templates/ipam/prefix/ip_ranges.html @@ -3,7 +3,7 @@ {% block extra_controls %} {% if perms.ipam.add_iprange and first_available_ip %} - + Add IP Range {% endif %} diff --git a/netbox/users/admin.py b/netbox/users/admin.py deleted file mode 100644 index 740d1558d..000000000 --- a/netbox/users/admin.py +++ /dev/null @@ -1,294 +0,0 @@ -from django import forms -from django.contrib import admin -from django.contrib.auth.admin import UserAdmin as UserAdmin_ -from django.contrib.auth.models import Group, User -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldError, ValidationError - -from utilities.forms.fields import ContentTypeMultipleChoiceField -from .constants import * -from .models import ObjectPermission, Token, UserConfig - - -# -# Inline models -# - -class ObjectPermissionInline(admin.TabularInline): - exclude = None - extra = 3 - readonly_fields = ['object_types', 'actions', 'constraints'] - verbose_name = 'Permission' - verbose_name_plural = 'Permissions' - - def get_queryset(self, request): - return super().get_queryset(request).prefetch_related('objectpermission__object_types') - - @staticmethod - def object_types(instance): - # Don't call .values_list() here because we want to reference the pre-fetched object_types - return ', '.join([ot.name for ot in instance.objectpermission.object_types.all()]) - - @staticmethod - def actions(instance): - return ', '.join(instance.objectpermission.actions) - - @staticmethod - def constraints(instance): - return instance.objectpermission.constraints - - -class GroupObjectPermissionInline(ObjectPermissionInline): - model = Group.object_permissions.through - - -class UserObjectPermissionInline(ObjectPermissionInline): - model = User.object_permissions.through - - -class UserConfigInline(admin.TabularInline): - model = UserConfig - readonly_fields = ('data',) - can_delete = False - verbose_name = 'Preferences' - - -# -# Users & groups -# - -# Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below -admin.site.unregister(Group) -admin.site.unregister(User) - - -@admin.register(Group) -class GroupAdmin(admin.ModelAdmin): - fields = ('name',) - list_display = ('name', 'user_count') - ordering = ('name',) - search_fields = ('name',) - inlines = [GroupObjectPermissionInline] - - @staticmethod - def user_count(obj): - return obj.user_set.count() - - -@admin.register(User) -class UserAdmin(UserAdmin_): - list_display = [ - 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' - ] - fieldsets = ( - (None, {'fields': ('username', 'password', 'first_name', 'last_name', 'email')}), - ('Groups', {'fields': ('groups',)}), - ('Status', { - 'fields': ('is_active', 'is_staff', 'is_superuser'), - }), - ('Important dates', {'fields': ('last_login', 'date_joined')}), - ) - filter_horizontal = ('groups',) - list_filter = ('is_active', 'is_staff', 'is_superuser', 'groups__name') - - def get_inlines(self, request, obj): - if obj is not None: - return (UserObjectPermissionInline, UserConfigInline) - return () - - -# -# REST API tokens -# - -class TokenAdminForm(forms.ModelForm): - key = forms.CharField( - required=False, - help_text="If no key is provided, one will be generated automatically." - ) - - class Meta: - fields = [ - 'user', 'key', 'write_enabled', 'expires', 'description' - ] - model = Token - - -@admin.register(Token) -class TokenAdmin(admin.ModelAdmin): - form = TokenAdminForm - list_display = [ - 'key', 'user', 'created', 'expires', 'write_enabled', 'description' - ] - - -# -# Permissions -# - -class ObjectPermissionForm(forms.ModelForm): - object_types = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES - ) - can_view = forms.BooleanField(required=False) - can_add = forms.BooleanField(required=False) - can_change = forms.BooleanField(required=False) - can_delete = forms.BooleanField(required=False) - - class Meta: - model = ObjectPermission - exclude = [] - help_texts = { - 'actions': 'Actions granted in addition to those listed above', - 'constraints': 'JSON expression of a queryset filter that will return only permitted objects. Leave null ' - 'to match all objects of this type. A list of multiple objects will result in a logical OR ' - 'operation.' - } - labels = { - 'actions': 'Additional actions' - } - widgets = { - 'constraints': forms.Textarea(attrs={'class': 'vLargeTextField'}) - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Make the actions field optional since the admin form uses it only for non-CRUD actions - self.fields['actions'].required = False - - # Order group and user fields - self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name') - self.fields['users'].queryset = self.fields['users'].queryset.order_by('username') - - # Check the appropriate checkboxes when editing an existing ObjectPermission - if self.instance.pk: - for action in ['view', 'add', 'change', 'delete']: - if action in self.instance.actions: - self.fields[f'can_{action}'].initial = True - self.instance.actions.remove(action) - - def clean(self): - super().clean() - - object_types = self.cleaned_data.get('object_types') - constraints = self.cleaned_data.get('constraints') - - # Append any of the selected CRUD checkboxes to the actions list - if not self.cleaned_data.get('actions'): - self.cleaned_data['actions'] = list() - for action in ['view', 'add', 'change', 'delete']: - if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']: - self.cleaned_data['actions'].append(action) - - # At least one action must be specified - if not self.cleaned_data['actions']: - raise ValidationError("At least one action must be selected.") - - # Validate the specified model constraints by attempting to execute a query. We don't care whether the query - # returns anything; we just want to make sure the specified constraints are valid. - if object_types and constraints: - # Normalize the constraints to a list of dicts - if type(constraints) is not list: - constraints = [constraints] - for ct in object_types: - model = ct.model_class() - try: - model.objects.filter(*[Q(**c) for c in constraints]).exists() - except FieldError as e: - raise ValidationError({ - 'constraints': f'Invalid filter for {model}: {e}' - }) - - -class ActionListFilter(admin.SimpleListFilter): - title = 'action' - parameter_name = 'action' - - def lookups(self, request, model_admin): - options = set() - for action_list in ObjectPermission.objects.values_list('actions', flat=True).distinct(): - options.update(action_list) - return [ - (action, action) for action in sorted(options) - ] - - def queryset(self, request, queryset): - if self.value(): - return queryset.filter(actions=[self.value()]) - - -class ObjectTypeListFilter(admin.SimpleListFilter): - title = 'object type' - parameter_name = 'object_type' - - def lookups(self, request, model_admin): - object_types = ObjectPermission.objects.values_list('object_types__pk', flat=True).distinct() - content_types = ContentType.objects.filter(pk__in=object_types).order_by('app_label', 'model') - return [ - (ct.pk, ct) for ct in content_types - ] - - def queryset(self, request, queryset): - if self.value(): - return queryset.filter(object_types=self.value()) - - -@admin.register(ObjectPermission) -class ObjectPermissionAdmin(admin.ModelAdmin): - actions = ('enable', 'disable') - fieldsets = ( - (None, { - 'fields': ('name', 'description', 'enabled') - }), - ('Actions', { - 'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions') - }), - ('Objects', { - 'fields': ('object_types',) - }), - ('Assignment', { - 'fields': ('groups', 'users') - }), - ('Constraints', { - 'fields': ('constraints',), - 'classes': ('monospace',) - }), - ) - filter_horizontal = ('object_types', 'groups', 'users') - form = ObjectPermissionForm - list_display = [ - 'name', 'enabled', 'list_models', 'list_users', 'list_groups', 'actions', 'constraints', 'description', - ] - list_filter = [ - 'enabled', ActionListFilter, ObjectTypeListFilter, 'groups', 'users' - ] - search_fields = ['actions', 'constraints', 'description', 'name'] - - def get_queryset(self, request): - return super().get_queryset(request).prefetch_related('object_types', 'users', 'groups') - - def list_models(self, obj): - return ', '.join([f"{ct}" for ct in obj.object_types.all()]) - list_models.short_description = 'Models' - - def list_users(self, obj): - return ', '.join([u.username for u in obj.users.all()]) - list_users.short_description = 'Users' - - def list_groups(self, obj): - return ', '.join([g.name for g in obj.groups.all()]) - list_groups.short_description = 'Groups' - - # - # Admin actions - # - - def enable(self, request, queryset): - updated = queryset.update(enabled=True) - self.message_user(request, f"Enabled {updated} permissions") - - def disable(self, request, queryset): - updated = queryset.update(enabled=False) - self.message_user(request, f"Disabled {updated} permissions") diff --git a/netbox/users/admin/__init__.py b/netbox/users/admin/__init__.py new file mode 100644 index 000000000..1b163ed06 --- /dev/null +++ b/netbox/users/admin/__init__.py @@ -0,0 +1,125 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as UserAdmin_ +from django.contrib.auth.models import Group, User + +from users.models import ObjectPermission, Token +from . import filters, forms, inlines + + +# +# Users & groups +# + +# Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below +admin.site.unregister(Group) +admin.site.unregister(User) + + +@admin.register(Group) +class GroupAdmin(admin.ModelAdmin): + form = forms.GroupAdminForm + list_display = ('name', 'user_count') + ordering = ('name',) + search_fields = ('name',) + inlines = [inlines.GroupObjectPermissionInline] + + @staticmethod + def user_count(obj): + return obj.user_set.count() + + +@admin.register(User) +class UserAdmin(UserAdmin_): + list_display = [ + 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' + ] + fieldsets = ( + (None, {'fields': ('username', 'password', 'first_name', 'last_name', 'email')}), + ('Groups', {'fields': ('groups',)}), + ('Status', { + 'fields': ('is_active', 'is_staff', 'is_superuser'), + }), + ('Important dates', {'fields': ('last_login', 'date_joined')}), + ) + filter_horizontal = ('groups',) + list_filter = ('is_active', 'is_staff', 'is_superuser', 'groups__name') + + def get_inlines(self, request, obj): + if obj is not None: + return (inlines.UserObjectPermissionInline, inlines.UserConfigInline) + return () + + +# +# REST API tokens +# + +@admin.register(Token) +class TokenAdmin(admin.ModelAdmin): + form = forms.TokenAdminForm + list_display = [ + 'key', 'user', 'created', 'expires', 'write_enabled', 'description' + ] + + +# +# Permissions +# + +@admin.register(ObjectPermission) +class ObjectPermissionAdmin(admin.ModelAdmin): + actions = ('enable', 'disable') + fieldsets = ( + (None, { + 'fields': ('name', 'description', 'enabled') + }), + ('Actions', { + 'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions') + }), + ('Objects', { + 'fields': ('object_types',) + }), + ('Assignment', { + 'fields': ('groups', 'users') + }), + ('Constraints', { + 'fields': ('constraints',), + 'classes': ('monospace',) + }), + ) + filter_horizontal = ('object_types', 'groups', 'users') + form = forms.ObjectPermissionForm + list_display = [ + 'name', 'enabled', 'list_models', 'list_users', 'list_groups', 'actions', 'constraints', 'description', + ] + list_filter = [ + 'enabled', filters.ActionListFilter, filters.ObjectTypeListFilter, 'groups', 'users' + ] + search_fields = ['actions', 'constraints', 'description', 'name'] + + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related('object_types', 'users', 'groups') + + def list_models(self, obj): + return ', '.join([f"{ct}" for ct in obj.object_types.all()]) + list_models.short_description = 'Models' + + def list_users(self, obj): + return ', '.join([u.username for u in obj.users.all()]) + list_users.short_description = 'Users' + + def list_groups(self, obj): + return ', '.join([g.name for g in obj.groups.all()]) + list_groups.short_description = 'Groups' + + # + # Admin actions + # + + def enable(self, request, queryset): + updated = queryset.update(enabled=True) + self.message_user(request, f"Enabled {updated} permissions") + + def disable(self, request, queryset): + updated = queryset.update(enabled=False) + self.message_user(request, f"Disabled {updated} permissions") diff --git a/netbox/users/admin/filters.py b/netbox/users/admin/filters.py new file mode 100644 index 000000000..b71761fa2 --- /dev/null +++ b/netbox/users/admin/filters.py @@ -0,0 +1,42 @@ +from django.contrib import admin +from django.contrib.contenttypes.models import ContentType + +from users.models import ObjectPermission + +__all__ = ( + 'ActionListFilter', + 'ObjectTypeListFilter', +) + + +class ActionListFilter(admin.SimpleListFilter): + title = 'action' + parameter_name = 'action' + + def lookups(self, request, model_admin): + options = set() + for action_list in ObjectPermission.objects.values_list('actions', flat=True).distinct(): + options.update(action_list) + return [ + (action, action) for action in sorted(options) + ] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(actions=[self.value()]) + + +class ObjectTypeListFilter(admin.SimpleListFilter): + title = 'object type' + parameter_name = 'object_type' + + def lookups(self, request, model_admin): + object_types = ObjectPermission.objects.values_list('object_types__pk', flat=True).distinct() + content_types = ContentType.objects.filter(pk__in=object_types).order_by('app_label', 'model') + return [ + (ct.pk, ct) for ct in content_types + ] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(object_types=self.value()) diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py new file mode 100644 index 000000000..7d0212441 --- /dev/null +++ b/netbox/users/admin/forms.py @@ -0,0 +1,132 @@ +from django import forms +from django.contrib.auth.models import Group, User +from django.contrib.admin.widgets import FilteredSelectMultiple +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import FieldError, ValidationError +from django.db.models import Q + +from users.constants import OBJECTPERMISSION_OBJECT_TYPES +from users.models import ObjectPermission, Token +from utilities.forms.fields import ContentTypeMultipleChoiceField + +__all__ = ( + 'GroupAdminForm', + 'ObjectPermissionForm', + 'TokenAdminForm', +) + + +class GroupAdminForm(forms.ModelForm): + users = forms.ModelMultipleChoiceField( + queryset=User.objects.all(), + required=False, + widget=FilteredSelectMultiple('users', False) + ) + + class Meta: + model = Group + fields = ('name', 'users') + + def __init__(self, *args, **kwargs): + super(GroupAdminForm, self).__init__(*args, **kwargs) + + if self.instance.pk: + self.fields['users'].initial = self.instance.user_set.all() + + def save_m2m(self): + self.instance.user_set.set(self.cleaned_data['users']) + + def save(self, *args, **kwargs): + instance = super(GroupAdminForm, self).save() + self.save_m2m() + + return instance + + +class TokenAdminForm(forms.ModelForm): + key = forms.CharField( + required=False, + help_text="If no key is provided, one will be generated automatically." + ) + + class Meta: + fields = [ + 'user', 'key', 'write_enabled', 'expires', 'description' + ] + model = Token + + +class ObjectPermissionForm(forms.ModelForm): + object_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES + ) + can_view = forms.BooleanField(required=False) + can_add = forms.BooleanField(required=False) + can_change = forms.BooleanField(required=False) + can_delete = forms.BooleanField(required=False) + + class Meta: + model = ObjectPermission + exclude = [] + help_texts = { + 'actions': 'Actions granted in addition to those listed above', + 'constraints': 'JSON expression of a queryset filter that will return only permitted objects. Leave null ' + 'to match all objects of this type. A list of multiple objects will result in a logical OR ' + 'operation.' + } + labels = { + 'actions': 'Additional actions' + } + widgets = { + 'constraints': forms.Textarea(attrs={'class': 'vLargeTextField'}) + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Make the actions field optional since the admin form uses it only for non-CRUD actions + self.fields['actions'].required = False + + # Order group and user fields + self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name') + self.fields['users'].queryset = self.fields['users'].queryset.order_by('username') + + # Check the appropriate checkboxes when editing an existing ObjectPermission + if self.instance.pk: + for action in ['view', 'add', 'change', 'delete']: + if action in self.instance.actions: + self.fields[f'can_{action}'].initial = True + self.instance.actions.remove(action) + + def clean(self): + super().clean() + + object_types = self.cleaned_data.get('object_types') + constraints = self.cleaned_data.get('constraints') + + # Append any of the selected CRUD checkboxes to the actions list + if not self.cleaned_data.get('actions'): + self.cleaned_data['actions'] = list() + for action in ['view', 'add', 'change', 'delete']: + if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']: + self.cleaned_data['actions'].append(action) + + # At least one action must be specified + if not self.cleaned_data['actions']: + raise ValidationError("At least one action must be selected.") + + # Validate the specified model constraints by attempting to execute a query. We don't care whether the query + # returns anything; we just want to make sure the specified constraints are valid. + if object_types and constraints: + # Normalize the constraints to a list of dicts + if type(constraints) is not list: + constraints = [constraints] + for ct in object_types: + model = ct.model_class() + try: + model.objects.filter(*[Q(**c) for c in constraints]).exists() + except FieldError as e: + raise ValidationError({ + 'constraints': f'Invalid filter for {model}: {e}' + }) diff --git a/netbox/users/admin/inlines.py b/netbox/users/admin/inlines.py new file mode 100644 index 000000000..cd192ecf8 --- /dev/null +++ b/netbox/users/admin/inlines.py @@ -0,0 +1,49 @@ +from django.contrib import admin +from django.contrib.auth.models import Group, User + +from users.models import UserConfig + +__all__ = ( + 'GroupObjectPermissionInline', + 'UserConfigInline', + 'UserObjectPermissionInline', +) + + +class ObjectPermissionInline(admin.TabularInline): + exclude = None + extra = 3 + readonly_fields = ['object_types', 'actions', 'constraints'] + verbose_name = 'Permission' + verbose_name_plural = 'Permissions' + + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related('objectpermission__object_types') + + @staticmethod + def object_types(instance): + # Don't call .values_list() here because we want to reference the pre-fetched object_types + return ', '.join([ot.name for ot in instance.objectpermission.object_types.all()]) + + @staticmethod + def actions(instance): + return ', '.join(instance.objectpermission.actions) + + @staticmethod + def constraints(instance): + return instance.objectpermission.constraints + + +class GroupObjectPermissionInline(ObjectPermissionInline): + model = Group.object_permissions.through + + +class UserObjectPermissionInline(ObjectPermissionInline): + model = User.object_permissions.through + + +class UserConfigInline(admin.TabularInline): + model = UserConfig + readonly_fields = ('data',) + can_delete = False + verbose_name = 'Preferences' diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 183d64023..9000af110 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -381,8 +381,9 @@ class TagColumn(tables.TemplateColumn): Display a list of tags assigned to the object. """ template_code = """ + {% load helpers %} {% for tag in value.all %} - {% include 'utilities/templatetags/tag.html' %} + {% tag tag url_name=url_name %} {% empty %} {% endfor %} diff --git a/netbox/utilities/tests/test_tables.py b/netbox/utilities/tests/test_tables.py new file mode 100644 index 000000000..119587ff8 --- /dev/null +++ b/netbox/utilities/tests/test_tables.py @@ -0,0 +1,36 @@ +from django.template import Context, Template +from django.test import TestCase + +from dcim.models import Site +from utilities.tables import BaseTable, TagColumn +from utilities.testing import create_tags + + +class TagColumnTable(BaseTable): + tags = TagColumn(url_name='dcim:site_list') + + class Meta(BaseTable.Meta): + model = Site + fields = ('pk', 'name', 'tags',) + default_columns = fields + + +class TagColumnTest(TestCase): + + @classmethod + def setUpTestData(cls): + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + sites = [ + Site(name=f'Site {i}', slug=f'site-{i}') for i in range(1, 6) + ] + Site.objects.bulk_create(sites) + for site in sites: + site.tags.add(*tags) + + def test_tagcolumn(self): + template = Template('{% load render_table from django_tables2 %}{% render_table table %}') + context = Context({ + 'table': TagColumnTable(Site.objects.all(), orderable=False) + }) + template.render(context) diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index b07259e5c..818b09d33 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -18,7 +18,7 @@ __all__ = ( VMINTERFACE_BUTTONS = """ {% if perms.ipam.add_ipaddress %} - + {% endif %}