mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 09:51:22 -06:00
Merge v3.1.4
This commit is contained in:
commit
0978777eec
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -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
|
||||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -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
1
.gitignore
vendored
@ -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/*
|
||||||
|
37
README.md
37
README.md
@ -5,11 +5,46 @@
|
|||||||

|

|
||||||
|
|
||||||
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).
|
||||||
|
@ -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.
|
||||||
|
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
|
@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -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",
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
@ -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'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -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',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
21
netbox/extras/migrations/0067_customfield_min_max_values.py
Normal file
21
netbox/extras/migrations/0067_customfield_min_max_values.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
@ -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 = [
|
@ -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',
|
||||||
|
@ -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)
|
||||||
|
@ -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,
|
||||||
|
@ -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).
|
||||||
|
@ -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 {
|
||||||
|
@ -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',
|
||||||
|
@ -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 %}
|
||||||
|
@ -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 %}
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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 %}
|
||||||
|
@ -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")
|
|
125
netbox/users/admin/__init__.py
Normal file
125
netbox/users/admin/__init__.py
Normal 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")
|
42
netbox/users/admin/filters.py
Normal file
42
netbox/users/admin/filters.py
Normal 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
132
netbox/users/admin/forms.py
Normal 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}'
|
||||||
|
})
|
49
netbox/users/admin/inlines.py
Normal file
49
netbox/users/admin/inlines.py
Normal 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'
|
@ -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">—</span>
|
<span class="text-muted">—</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
36
netbox/utilities/tests/test_tables.py
Normal file
36
netbox/utilities/tests/test_tables.py
Normal 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)
|
@ -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 %}
|
||||||
|
Loading…
Reference in New Issue
Block a user