Merge v3.1.4

This commit is contained in:
jeremystretch 2022-01-03 11:20:58 -05:00
commit 0978777eec
31 changed files with 628 additions and 388 deletions

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: v3.1.3 placeholder: v3.1.4
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

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: v3.1.3 placeholder: v3.1.4
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

1
.gitignore vendored
View File

@ -8,6 +8,7 @@ yarn-error.log*
!/netbox/project-static/docs/.info !/netbox/project-static/docs/.info
/netbox/netbox/configuration.py /netbox/netbox/configuration.py
/netbox/netbox/ldap_config.py /netbox/netbox/ldap_config.py
/netbox/local/*
/netbox/reports/* /netbox/reports/*
!/netbox/reports/__init__.py !/netbox/reports/__init__.py
/netbox/scripts/* /netbox/scripts/*

View File

@ -5,11 +5,46 @@
![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) ![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 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 [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically
to address the needs of network and infrastructure engineers. It is intended to to address the needs of network and infrastructure engineers. It is intended to
function as a domain-specific source of truth for network operations. 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/) 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 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). complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox).

View File

@ -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`. 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. 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. 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`. 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`. 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: Create the following for each model:
@ -62,13 +71,13 @@ Create the following for each model:
* API view in `api/views.py` * API view in `api/views.py`
* Endpoint route in `api/urls.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`. 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. 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: Add tests for the following:
@ -76,7 +85,7 @@ Add tests for the following:
* API views * API views
* Filter sets * Filter sets
## 13. Documentation ## 14. Documentation
Create a new documentation page for the model in `docs/models/<app_label>/<model_name>.md`. Include this file under the "features" documentation where appropriate. Create a new documentation page for the model in `docs/models/<app_label>/<model_name>.md`. Include this file under the "features" documentation where appropriate.

View File

@ -152,7 +152,7 @@ LOGGING = {
'netbox_auth_log': { 'netbox_auth_log': {
'level': 'DEBUG', 'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler', 'class': 'logging.handlers.RotatingFileHandler',
'filename': '/opt/netbox/logs/django-ldap-debug.log', 'filename': '/opt/netbox/local/logs/django-ldap-debug.log',
'maxBytes': 1024 * 500, 'maxBytes': 1024 * 500,
'backupCount': 5, 'backupCount': 5,
}, },

View File

@ -1,6 +1,24 @@
# NetBox v3.1 # NetBox v3.1
## v3.1.4 (FUTURE) ## v3.1.5 (FUTURE)
---
## 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
--- ---

View File

@ -42,7 +42,7 @@ $ curl -X POST \
https://netbox/api/users/tokens/provision/ \ https://netbox/api/users/tokens/provision/ \
--data '{ --data '{
"username": "hankhill", "username": "hankhill",
"password: "I<3C3H8", "password": "I<3C3H8",
}' }'
``` ```

View File

@ -27,7 +27,7 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm):
label='Region', label='Region',
required=False required=False
) )
termination_b_site_group = DynamicModelChoiceField( termination_b_sitegroup = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
label='Site group', label='Site group',
required=False required=False
@ -38,7 +38,7 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm):
required=False, required=False,
query_params={ query_params={
'region_id': '$termination_b_region', 'region_id': '$termination_b_region',
'group_id': '$termination_b_site_group', 'group_id': '$termination_b_sitegroup',
} }
) )
termination_b_location = DynamicModelChoiceField( termination_b_location = DynamicModelChoiceField(
@ -78,9 +78,9 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm):
class Meta: class Meta:
model = Cable model = Cable
fields = [ fields = [
'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_rack',
'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'termination_b_device', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
'tags', 'length', 'length_unit', 'tags',
] ]
widgets = { widgets = {
'status': StaticSelect, 'status': StaticSelect,
@ -182,7 +182,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
label='Region', label='Region',
required=False required=False
) )
termination_b_site_group = DynamicModelChoiceField( termination_b_sitegroup = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
label='Site group', label='Site group',
required=False required=False
@ -193,7 +193,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
required=False, required=False,
query_params={ query_params={
'region_id': '$termination_b_region', 'region_id': '$termination_b_region',
'group_id': '$termination_b_site_group', 'group_id': '$termination_b_sitegroup',
} }
) )
termination_b_circuit = DynamicModelChoiceField( termination_b_circuit = DynamicModelChoiceField(
@ -219,9 +219,9 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
class Meta(ConnectCableToDeviceForm.Meta): class Meta(ConnectCableToDeviceForm.Meta):
fields = [ fields = [
'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit', 'termination_b_provider', 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site',
'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'termination_b_circuit', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
'tags', 'length', 'length_unit', 'tags',
] ]
def clean_termination_b_id(self): def clean_termination_b_id(self):
@ -235,7 +235,7 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm):
label='Region', label='Region',
required=False required=False
) )
termination_b_site_group = DynamicModelChoiceField( termination_b_sitegroup = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
label='Site group', label='Site group',
required=False required=False
@ -246,7 +246,7 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm):
required=False, required=False,
query_params={ query_params={
'region_id': '$termination_b_region', 'region_id': '$termination_b_region',
'group_id': '$termination_b_site_group', 'group_id': '$termination_b_sitegroup',
} }
) )
termination_b_location = DynamicModelChoiceField( termination_b_location = DynamicModelChoiceField(
@ -281,8 +281,9 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm):
class Meta(ConnectCableToDeviceForm.Meta): class Meta(ConnectCableToDeviceForm.Meta):
fields = [ fields = [
'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group', 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_location',
'tenant', 'label', 'color', 'length', 'length_unit', 'tags', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label',
'color', 'length', 'length_unit', 'tags',
] ]
def clean_termination_b_id(self): def clean_termination_b_id(self):

View File

@ -8,7 +8,7 @@ import utilities.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('extras', '0067_configcontext_cluster_types'), ('extras', '0068_configcontext_cluster_types'),
('dcim', '0145_modules'), ('dcim', '0145_modules'),
] ]

View File

@ -92,7 +92,7 @@ class RackTable(BaseTable):
) )
default_columns = ( default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
'get_utilization', 'get_power_utilization', 'get_utilization',
) )

View File

@ -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),
),
]

View File

@ -5,7 +5,7 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
('virtualization', '0026_vminterface_bridge'), ('virtualization', '0026_vminterface_bridge'),
('extras', '0066_customfield_name_validation'), ('extras', '0067_customfield_min_max_values'),
] ]
operations = [ operations = [

View File

@ -96,13 +96,13 @@ class CustomField(ChangeLoggedModel):
default=100, default=100,
help_text='Fields with higher weights appear lower in a form.' help_text='Fields with higher weights appear lower in a form.'
) )
validation_minimum = models.PositiveIntegerField( validation_minimum = models.IntegerField(
blank=True, blank=True,
null=True, null=True,
verbose_name='Minimum value', verbose_name='Minimum value',
help_text='Minimum allowed value (for numeric fields)' help_text='Minimum allowed value (for numeric fields)'
) )
validation_maximum = models.PositiveIntegerField( validation_maximum = models.IntegerField(
blank=True, blank=True,
null=True, null=True,
verbose_name='Maximum value', verbose_name='Maximum value',

View File

@ -25,49 +25,68 @@ class CustomFieldTest(TestCase):
def test_simple_fields(self): def test_simple_fields(self):
DATA = ( DATA = (
{ {
'field_type': CustomFieldTypeChoices.TYPE_TEXT, 'field': {
'field_value': 'Foobar!', 'type': CustomFieldTypeChoices.TYPE_TEXT,
'empty_value': '', },
'value': 'Foobar!',
}, },
{ {
'field_type': CustomFieldTypeChoices.TYPE_LONGTEXT, 'field': {
'field_value': 'Text with **Markdown**', 'type': CustomFieldTypeChoices.TYPE_LONGTEXT,
'empty_value': '', },
'value': 'Text with **Markdown**',
}, },
{ {
'field_type': CustomFieldTypeChoices.TYPE_INTEGER, 'field': {
'field_value': 0, 'type': CustomFieldTypeChoices.TYPE_INTEGER,
'empty_value': None, },
'value': 0,
}, },
{ {
'field_type': CustomFieldTypeChoices.TYPE_INTEGER, 'field': {
'field_value': 42, 'type': CustomFieldTypeChoices.TYPE_INTEGER,
'empty_value': None, 'validation_minimum': 1,
'validation_maximum': 100,
},
'value': 42,
}, },
{ {
'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, 'field': {
'field_value': True, 'type': CustomFieldTypeChoices.TYPE_INTEGER,
'empty_value': None, 'validation_minimum': -100,
'validation_maximum': -1,
},
'value': -42,
}, },
{ {
'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, 'field': {
'field_value': False, 'type': CustomFieldTypeChoices.TYPE_BOOLEAN,
'empty_value': None, },
'value': True,
}, },
{ {
'field_type': CustomFieldTypeChoices.TYPE_DATE, 'field': {
'field_value': '2016-06-23', 'type': CustomFieldTypeChoices.TYPE_BOOLEAN,
'empty_value': None, },
'value': False,
}, },
{ {
'field_type': CustomFieldTypeChoices.TYPE_URL, 'field': {
'field_value': 'http://example.com/', 'type': CustomFieldTypeChoices.TYPE_DATE,
'empty_value': '', },
'value': '2016-06-23',
}, },
{ {
'field_type': CustomFieldTypeChoices.TYPE_JSON, 'field': {
'field_value': '{"foo": 1, "bar": 2}', 'type': CustomFieldTypeChoices.TYPE_URL,
'empty_value': 'null', },
'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: for data in DATA:
# Create a custom field # 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.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
@ -85,12 +104,12 @@ class CustomFieldTest(TestCase):
self.assertIsNone(site.custom_field_data[cf.name]) self.assertIsNone(site.custom_field_data[cf.name])
# Assign a value to the first Site # 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() site.save()
# Retrieve the stored value # Retrieve the stored value
site.refresh_from_db() 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 # Delete the stored value
site.custom_field_data.pop(cf.name) site.custom_field_data.pop(cf.name)

View File

@ -302,7 +302,8 @@ class IPAddressBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
) )
dns_name = forms.CharField( dns_name = forms.CharField(
max_length=255, max_length=255,
required=False required=False,
label='DNS name'
) )
description = forms.CharField( description = forms.CharField(
max_length=100, max_length=100,

View File

@ -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') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RIR(OrganizationalModel): class RIR(OrganizationalModel):
""" """
@ -110,7 +132,7 @@ class ASN(PrimaryModel):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') @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 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. 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') @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 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 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: else:
return IPAddress.objects.filter(address__net_host_contained=str(self.prefix), vrf=self.vrf) 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): def get_available_ips(self):
""" """
Return all available IPs within this prefix as an IPSet. Return all available IPs within this prefix as an IPSet.
@ -494,15 +506,6 @@ class Prefix(PrimaryModel):
return available_ips 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): def get_first_available_ip(self):
""" """
Return the first available IP within the prefix (or None). Return the first available IP within the prefix (or None).

View File

@ -299,6 +299,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
return { return {
'bulk_querystring': f'within={instance.prefix}', 'bulk_querystring': f'within={instance.prefix}',
'active_tab': 'prefixes', 'active_tab': 'prefixes',
'first_available_prefix': instance.get_first_available_prefix(),
'show_available': bool(request.GET.get('show_available', 'true') == 'true'), 'show_available': bool(request.GET.get('show_available', 'true') == 'true'),
'show_assigned': bool(request.GET.get('show_assigned', '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' template_name = 'ipam/prefix/prefixes.html'
def get_children(self, request, parent): 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): def prep_table_data(self, request, queryset, parent):
# Determine whether to show assigned prefixes, available prefixes, or both # 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' template_name = 'ipam/prefix/ip_ranges.html'
def get_children(self, request, parent): 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): def get_extra_context(self, request, instance):
return { return {
@ -500,7 +505,9 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
template_name = 'ipam/prefix/ip_addresses.html' template_name = 'ipam/prefix/ip_addresses.html'
def get_children(self, request, parent): 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): def prep_table_data(self, request, queryset, parent):
show_available = bool(request.GET.get('show_available', 'true') == 'true') 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' template_name = 'ipam/iprange/ip_addresses.html'
def get_children(self, request, parent): 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): def get_extra_context(self, request, instance):
return { return {

View File

@ -180,7 +180,7 @@ CONNECTIONS_MENU = Menu(
label='Connections', label='Connections',
items=( items=(
get_model_item('dcim', 'cable', 'Cables', actions=['import']), 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( MenuItem(
link='dcim:interface_connections_list', link='dcim:interface_connections_list',
link_text='Interface Connections', link_text='Interface Connections',

View File

@ -5,6 +5,14 @@
{% block title %}Connect {{ form.instance.termination_a.device }} {{ form.instance.termination_a }} to {{ termination_b_type|bettertitle }}{% endblock %} {% block title %}Connect {{ form.instance.termination_a.device }} {{ form.instance.termination_a }} to {{ termination_b_type|bettertitle }}{% endblock %}
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<a href="#" role="tab" data-bs-toggle="tab" class="nav-link active">Connect Cable</a>
</li>
</ul>
{% endblock %}
{% block content-wrapper %} {% block content-wrapper %}
<div class="tab-content"> <div class="tab-content">
{% with termination_a=form.instance.termination_a %} {% with termination_a=form.instance.termination_a %}
@ -27,6 +35,12 @@
<input class="form-control" value="{{ termination_a.device.site.region }}" disabled /> <input class="form-control" value="{{ termination_a.device.site.region }}" disabled />
</div> </div>
</div> </div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">Site Group</label>
<div class="col">
<input class="form-control" value="{{ termination_a.device.site.group }}" disabled />
</div>
</div>
<div class="row mb-3"> <div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">Site</label> <label class="col-sm-3 col-form-label text-lg-end">Site</label>
<div class="col"> <div class="col">
@ -115,6 +129,9 @@
{% if 'termination_b_region' in form.fields %} {% if 'termination_b_region' in form.fields %}
{% render_field form.termination_b_region %} {% render_field form.termination_b_region %}
{% endif %} {% endif %}
{% if 'termination_b_sitegroup' in form.fields %}
{% render_field form.termination_b_sitegroup %}
{% endif %}
{% if 'termination_b_site' in form.fields %} {% if 'termination_b_site' in form.fields %}
{% render_field form.termination_b_site %} {% render_field form.termination_b_site %}
{% endif %} {% endif %}

View File

@ -3,6 +3,11 @@
{% block extra_controls %} {% block extra_controls %}
{% include 'ipam/inc/toggle_available.html' %} {% include 'ipam/inc/toggle_available.html' %}
{% if perms.ipam.add_prefix and first_available_prefix %}
<a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}" class="btn btn-sm btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Prefix
</a>
{% endif %}
{{ block.super }} {{ block.super }}
{% endblock %} {% endblock %}

View File

@ -1,4 +1,5 @@
{% extends 'ipam/prefix/base.html' %} {% extends 'ipam/prefix/base.html' %}
{% load humanize %}
{% load helpers %} {% load helpers %}
{% load plugins %} {% load plugins %}
@ -124,9 +125,18 @@
<a href="{% url 'ipam:prefix_ipaddresses' pk=object.pk %}">{{ child_ip_count }}</a> <a href="{% url 'ipam:prefix_ipaddresses' pk=object.pk %}">{{ child_ip_count }}</a>
</td> </td>
</tr> </tr>
{% endwith %}
{% with available_count=object.get_available_ips.size %}
<tr> <tr>
<th scope="row">Available IPs</th> <th scope="row">Available IPs</th>
<td>{{ object.get_available_ips|length }}</td> <td>
{# Use human-friendly words for counts greater than one million #}
{% if available_count > 1000000 %}
{{ available_count|intword }}
{% else %}
{{ available_count|intcomma }}
{% endif %}
</td>
</tr> </tr>
{% endwith %} {% endwith %}
<tr> <tr>

View File

@ -3,7 +3,7 @@
{% block extra_controls %} {% block extra_controls %}
{% if perms.ipam.add_iprange and first_available_ip %} {% if perms.ipam.add_iprange and first_available_ip %}
<a href="{% url 'ipam:iprange_add' %}?start_address={{ first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}&return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-sm btn-primary"> <a href="{% url 'ipam:iprange_add' %}?start_address={{ first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}&return_url={% url 'ipam:prefix_ipranges' pk=object.pk %}" class="btn btn-sm btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add IP Range <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add IP Range
</a> </a>
{% endif %} {% endif %}

View File

@ -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")

View File

@ -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")

View File

@ -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())

132
netbox/users/admin/forms.py Normal file
View File

@ -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}'
})

View File

@ -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'

View File

@ -381,8 +381,9 @@ class TagColumn(tables.TemplateColumn):
Display a list of tags assigned to the object. Display a list of tags assigned to the object.
""" """
template_code = """ template_code = """
{% load helpers %}
{% for tag in value.all %} {% for tag in value.all %}
{% include 'utilities/templatetags/tag.html' %} {% tag tag url_name=url_name %}
{% empty %} {% empty %}
<span class="text-muted">&mdash;</span> <span class="text-muted">&mdash;</span>
{% endfor %} {% endfor %}

View File

@ -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)

View File

@ -18,7 +18,7 @@ __all__ = (
VMINTERFACE_BUTTONS = """ VMINTERFACE_BUTTONS = """
{% if perms.ipam.add_ipaddress %} {% if perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?vminterface={{ record.pk }}&return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-sm btn-success" title="Add IP Address"> <a href="{% url 'ipam:ipaddress_add' %}?vminterface={{ record.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-sm btn-success" title="Add IP Address">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}