mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41: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:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.1.3
|
||||
placeholder: v3.1.4
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.1.3
|
||||
placeholder: v3.1.4
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,6 +8,7 @@ yarn-error.log*
|
||||
!/netbox/project-static/docs/.info
|
||||
/netbox/netbox/configuration.py
|
||||
/netbox/netbox/ldap_config.py
|
||||
/netbox/local/*
|
||||
/netbox/reports/*
|
||||
!/netbox/reports/__init__.py
|
||||
/netbox/scripts/*
|
||||
|
37
README.md
37
README.md
@ -5,11 +5,46 @@
|
||||

|
||||
|
||||
NetBox is an infrastructure resource modeling (IRM) tool designed to empower
|
||||
network automation. Initially conceived by the network engineering team at
|
||||
network automation, used by thousands of organizations around the world.
|
||||
Initially conceived by the network engineering team at
|
||||
[DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically
|
||||
to address the needs of network and infrastructure engineers. It is intended to
|
||||
function as a domain-specific source of truth for network operations.
|
||||
|
||||
Myriad infrastructure components can be modeled in NetBox, including:
|
||||
|
||||
* Hierarchical regions, site groups, sites, and locations
|
||||
* Racks, devices, and device components
|
||||
* Cables and wireless connections
|
||||
* Power distribution
|
||||
* Data circuits and providers
|
||||
* Virtual machines and clusters
|
||||
* IP prefixes, ranges, and addresses
|
||||
* VRFs and route targets
|
||||
* FHRP groups (VRRP, HSRP, etc.)
|
||||
* AS numbers
|
||||
* VLANs and scoped VLAN groups
|
||||
* Organizational tenants and contacts
|
||||
|
||||
In addition to its extensive built-in models and functionality, NetBox can be
|
||||
customized and extended through the use of:
|
||||
|
||||
* Custom fields
|
||||
* Custom links
|
||||
* Configuration contexts
|
||||
* Custom model validation rules
|
||||
* Reports
|
||||
* Custom scripts
|
||||
* Export templates
|
||||
* Conditional webhooks
|
||||
* Plugins
|
||||
* Single sign-on (SSO) authentication
|
||||
* NAPALM integration
|
||||
* Detailed change logging
|
||||
|
||||
NetBox also features a complete REST API as well as a GraphQL API for easily
|
||||
integrating with other tools and systems.
|
||||
|
||||
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
|
||||
Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a
|
||||
complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox).
|
||||
|
@ -37,23 +37,32 @@ Most models will need view classes created in `views.py` to serve the following
|
||||
|
||||
Add the relevant URL path for each view created in the previous step to `urls.py`.
|
||||
|
||||
## 6. Create the FilterSet
|
||||
## 6. Add relevant forms
|
||||
|
||||
Depending on the type of model being added, you may need to define several types of form classes. These include:
|
||||
|
||||
* A base model form (for creating/editing individual objects)
|
||||
* A bulk edit form
|
||||
* A bulk import form (for CSV-based import)
|
||||
* A filterset form (for filtering the object list view)
|
||||
|
||||
## 7. Create the FilterSet
|
||||
|
||||
Each model should have a corresponding FilterSet class defined. This is used to filter UI and API queries. Subclass the appropriate class from `netbox.filtersets` that matches the model's parent class.
|
||||
|
||||
## 7. Create the table class
|
||||
## 8. Create the table class
|
||||
|
||||
Create a table class for the model in `tables.py` by subclassing `utilities.tables.BaseTable`. Under the table's `Meta` class, be sure to list both the fields and default columns.
|
||||
|
||||
## 8. Create the object template
|
||||
## 9. Create the object template
|
||||
|
||||
Create the HTML template for the object view. (The other views each typically employ a generic template.) This template should extend `generic/object.html`.
|
||||
|
||||
## 9. Add the model to the navigation menu
|
||||
## 10. Add the model to the navigation menu
|
||||
|
||||
Add the relevant navigation menu items in `netbox/netbox/navigation_menu.py`.
|
||||
|
||||
## 10. REST API components
|
||||
## 11. REST API components
|
||||
|
||||
Create the following for each model:
|
||||
|
||||
@ -62,13 +71,13 @@ Create the following for each model:
|
||||
* API view in `api/views.py`
|
||||
* Endpoint route in `api/urls.py`
|
||||
|
||||
## 11. GraphQL API components
|
||||
## 12. GraphQL API components
|
||||
|
||||
Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`.
|
||||
|
||||
Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention.
|
||||
|
||||
## 12. Add tests
|
||||
## 13. Add tests
|
||||
|
||||
Add tests for the following:
|
||||
|
||||
@ -76,7 +85,7 @@ Add tests for the following:
|
||||
* API views
|
||||
* Filter sets
|
||||
|
||||
## 13. Documentation
|
||||
## 14. Documentation
|
||||
|
||||
Create a new documentation page for the model in `docs/models/<app_label>/<model_name>.md`. Include this file under the "features" documentation where appropriate.
|
||||
|
||||
|
@ -152,7 +152,7 @@ LOGGING = {
|
||||
'netbox_auth_log': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': '/opt/netbox/logs/django-ldap-debug.log',
|
||||
'filename': '/opt/netbox/local/logs/django-ldap-debug.log',
|
||||
'maxBytes': 1024 * 500,
|
||||
'backupCount': 5,
|
||||
},
|
||||
|
@ -1,6 +1,24 @@
|
||||
# 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/ \
|
||||
--data '{
|
||||
"username": "hankhill",
|
||||
"password: "I<3C3H8",
|
||||
"password": "I<3C3H8",
|
||||
}'
|
||||
```
|
||||
|
||||
|
@ -27,7 +27,7 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm):
|
||||
label='Region',
|
||||
required=False
|
||||
)
|
||||
termination_b_site_group = DynamicModelChoiceField(
|
||||
termination_b_sitegroup = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
label='Site group',
|
||||
required=False
|
||||
@ -38,7 +38,7 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm):
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$termination_b_region',
|
||||
'group_id': '$termination_b_site_group',
|
||||
'group_id': '$termination_b_sitegroup',
|
||||
}
|
||||
)
|
||||
termination_b_location = DynamicModelChoiceField(
|
||||
@ -78,9 +78,9 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm):
|
||||
class Meta:
|
||||
model = Cable
|
||||
fields = [
|
||||
'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device',
|
||||
'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
|
||||
'tags',
|
||||
'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_rack',
|
||||
'termination_b_device', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
|
||||
'length', 'length_unit', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'status': StaticSelect,
|
||||
@ -182,7 +182,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
|
||||
label='Region',
|
||||
required=False
|
||||
)
|
||||
termination_b_site_group = DynamicModelChoiceField(
|
||||
termination_b_sitegroup = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
label='Site group',
|
||||
required=False
|
||||
@ -193,7 +193,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$termination_b_region',
|
||||
'group_id': '$termination_b_site_group',
|
||||
'group_id': '$termination_b_sitegroup',
|
||||
}
|
||||
)
|
||||
termination_b_circuit = DynamicModelChoiceField(
|
||||
@ -219,9 +219,9 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
|
||||
|
||||
class Meta(ConnectCableToDeviceForm.Meta):
|
||||
fields = [
|
||||
'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
|
||||
'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
|
||||
'tags',
|
||||
'termination_b_provider', 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site',
|
||||
'termination_b_circuit', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
|
||||
'length', 'length_unit', 'tags',
|
||||
]
|
||||
|
||||
def clean_termination_b_id(self):
|
||||
@ -235,7 +235,7 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm):
|
||||
label='Region',
|
||||
required=False
|
||||
)
|
||||
termination_b_site_group = DynamicModelChoiceField(
|
||||
termination_b_sitegroup = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
label='Site group',
|
||||
required=False
|
||||
@ -246,7 +246,7 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm):
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$termination_b_region',
|
||||
'group_id': '$termination_b_site_group',
|
||||
'group_id': '$termination_b_sitegroup',
|
||||
}
|
||||
)
|
||||
termination_b_location = DynamicModelChoiceField(
|
||||
@ -281,8 +281,9 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm):
|
||||
|
||||
class Meta(ConnectCableToDeviceForm.Meta):
|
||||
fields = [
|
||||
'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group',
|
||||
'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
|
||||
'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_location',
|
||||
'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label',
|
||||
'color', 'length', 'length_unit', 'tags',
|
||||
]
|
||||
|
||||
def clean_termination_b_id(self):
|
||||
|
@ -8,7 +8,7 @@ import utilities.fields
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0067_configcontext_cluster_types'),
|
||||
('extras', '0068_configcontext_cluster_types'),
|
||||
('dcim', '0145_modules'),
|
||||
]
|
||||
|
||||
|
@ -92,7 +92,7 @@ class RackTable(BaseTable):
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
|
||||
'get_utilization', 'get_power_utilization',
|
||||
'get_utilization',
|
||||
)
|
||||
|
||||
|
||||
|
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 = [
|
||||
('virtualization', '0026_vminterface_bridge'),
|
||||
('extras', '0066_customfield_name_validation'),
|
||||
('extras', '0067_customfield_min_max_values'),
|
||||
]
|
||||
|
||||
operations = [
|
@ -96,13 +96,13 @@ class CustomField(ChangeLoggedModel):
|
||||
default=100,
|
||||
help_text='Fields with higher weights appear lower in a form.'
|
||||
)
|
||||
validation_minimum = models.PositiveIntegerField(
|
||||
validation_minimum = models.IntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Minimum value',
|
||||
help_text='Minimum allowed value (for numeric fields)'
|
||||
)
|
||||
validation_maximum = models.PositiveIntegerField(
|
||||
validation_maximum = models.IntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Maximum value',
|
||||
|
@ -25,49 +25,68 @@ class CustomFieldTest(TestCase):
|
||||
def test_simple_fields(self):
|
||||
DATA = (
|
||||
{
|
||||
'field_type': CustomFieldTypeChoices.TYPE_TEXT,
|
||||
'field_value': 'Foobar!',
|
||||
'empty_value': '',
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_TEXT,
|
||||
},
|
||||
'value': 'Foobar!',
|
||||
},
|
||||
{
|
||||
'field_type': CustomFieldTypeChoices.TYPE_LONGTEXT,
|
||||
'field_value': 'Text with **Markdown**',
|
||||
'empty_value': '',
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_LONGTEXT,
|
||||
},
|
||||
'value': 'Text with **Markdown**',
|
||||
},
|
||||
{
|
||||
'field_type': CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
'field_value': 0,
|
||||
'empty_value': None,
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
},
|
||||
'value': 0,
|
||||
},
|
||||
{
|
||||
'field_type': CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
'field_value': 42,
|
||||
'empty_value': None,
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
'validation_minimum': 1,
|
||||
'validation_maximum': 100,
|
||||
},
|
||||
'value': 42,
|
||||
},
|
||||
{
|
||||
'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN,
|
||||
'field_value': True,
|
||||
'empty_value': None,
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
'validation_minimum': -100,
|
||||
'validation_maximum': -1,
|
||||
},
|
||||
'value': -42,
|
||||
},
|
||||
{
|
||||
'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN,
|
||||
'field_value': False,
|
||||
'empty_value': None,
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_BOOLEAN,
|
||||
},
|
||||
'value': True,
|
||||
},
|
||||
{
|
||||
'field_type': CustomFieldTypeChoices.TYPE_DATE,
|
||||
'field_value': '2016-06-23',
|
||||
'empty_value': None,
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_BOOLEAN,
|
||||
},
|
||||
'value': False,
|
||||
},
|
||||
{
|
||||
'field_type': CustomFieldTypeChoices.TYPE_URL,
|
||||
'field_value': 'http://example.com/',
|
||||
'empty_value': '',
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_DATE,
|
||||
},
|
||||
'value': '2016-06-23',
|
||||
},
|
||||
{
|
||||
'field_type': CustomFieldTypeChoices.TYPE_JSON,
|
||||
'field_value': '{"foo": 1, "bar": 2}',
|
||||
'empty_value': 'null',
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_URL,
|
||||
},
|
||||
'value': 'http://example.com/',
|
||||
},
|
||||
{
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_JSON,
|
||||
},
|
||||
'value': '{"foo": 1, "bar": 2}',
|
||||
},
|
||||
)
|
||||
|
||||
@ -76,7 +95,7 @@ class CustomFieldTest(TestCase):
|
||||
for data in DATA:
|
||||
|
||||
# Create a custom field
|
||||
cf = CustomField(type=data['field_type'], name='my_field', required=False)
|
||||
cf = CustomField(name='my_field', required=False, **data['field'])
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
@ -85,12 +104,12 @@ class CustomFieldTest(TestCase):
|
||||
self.assertIsNone(site.custom_field_data[cf.name])
|
||||
|
||||
# Assign a value to the first Site
|
||||
site.custom_field_data[cf.name] = data['field_value']
|
||||
site.custom_field_data[cf.name] = data['value']
|
||||
site.save()
|
||||
|
||||
# Retrieve the stored value
|
||||
site.refresh_from_db()
|
||||
self.assertEqual(site.custom_field_data[cf.name], data['field_value'])
|
||||
self.assertEqual(site.custom_field_data[cf.name], data['value'])
|
||||
|
||||
# Delete the stored value
|
||||
site.custom_field_data.pop(cf.name)
|
||||
|
@ -302,7 +302,8 @@ class IPAddressBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
)
|
||||
dns_name = forms.CharField(
|
||||
max_length=255,
|
||||
required=False
|
||||
required=False,
|
||||
label='DNS name'
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=100,
|
||||
|
@ -32,6 +32,28 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class GetAvailablePrefixesMixin:
|
||||
|
||||
def get_available_prefixes(self):
|
||||
"""
|
||||
Return all available Prefixes within this aggregate as an IPSet.
|
||||
"""
|
||||
prefix = netaddr.IPSet(self.prefix)
|
||||
child_prefixes = netaddr.IPSet([child.prefix for child in self.get_child_prefixes()])
|
||||
available_prefixes = prefix - child_prefixes
|
||||
|
||||
return available_prefixes
|
||||
|
||||
def get_first_available_prefix(self):
|
||||
"""
|
||||
Return the first available child prefix within the prefix (or None).
|
||||
"""
|
||||
available_prefixes = self.get_available_prefixes()
|
||||
if not available_prefixes:
|
||||
return None
|
||||
return available_prefixes.iter_cidrs()[0]
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class RIR(OrganizationalModel):
|
||||
"""
|
||||
@ -110,7 +132,7 @@ class ASN(PrimaryModel):
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class Aggregate(PrimaryModel):
|
||||
class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
|
||||
"""
|
||||
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
|
||||
the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR.
|
||||
@ -245,7 +267,7 @@ class Role(OrganizationalModel):
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class Prefix(PrimaryModel):
|
||||
class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
|
||||
"""
|
||||
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
|
||||
VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be
|
||||
@ -458,16 +480,6 @@ class Prefix(PrimaryModel):
|
||||
else:
|
||||
return IPAddress.objects.filter(address__net_host_contained=str(self.prefix), vrf=self.vrf)
|
||||
|
||||
def get_available_prefixes(self):
|
||||
"""
|
||||
Return all available Prefixes within this prefix as an IPSet.
|
||||
"""
|
||||
prefix = netaddr.IPSet(self.prefix)
|
||||
child_prefixes = netaddr.IPSet([child.prefix for child in self.get_child_prefixes()])
|
||||
available_prefixes = prefix - child_prefixes
|
||||
|
||||
return available_prefixes
|
||||
|
||||
def get_available_ips(self):
|
||||
"""
|
||||
Return all available IPs within this prefix as an IPSet.
|
||||
@ -494,15 +506,6 @@ class Prefix(PrimaryModel):
|
||||
|
||||
return available_ips
|
||||
|
||||
def get_first_available_prefix(self):
|
||||
"""
|
||||
Return the first available child prefix within the prefix (or None).
|
||||
"""
|
||||
available_prefixes = self.get_available_prefixes()
|
||||
if not available_prefixes:
|
||||
return None
|
||||
return available_prefixes.iter_cidrs()[0]
|
||||
|
||||
def get_first_available_ip(self):
|
||||
"""
|
||||
Return the first available IP within the prefix (or None).
|
||||
|
@ -299,6 +299,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
|
||||
return {
|
||||
'bulk_querystring': f'within={instance.prefix}',
|
||||
'active_tab': 'prefixes',
|
||||
'first_available_prefix': instance.get_first_available_prefix(),
|
||||
'show_available': bool(request.GET.get('show_available', 'true') == 'true'),
|
||||
'show_assigned': bool(request.GET.get('show_assigned', 'true') == 'true'),
|
||||
}
|
||||
@ -455,7 +456,9 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
|
||||
template_name = 'ipam/prefix/prefixes.html'
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return parent.get_child_prefixes().restrict(request.user, 'view')
|
||||
return parent.get_child_prefixes().restrict(request.user, 'view').prefetch_related(
|
||||
'site', 'vrf', 'vlan', 'role', 'tenant',
|
||||
)
|
||||
|
||||
def prep_table_data(self, request, queryset, parent):
|
||||
# Determine whether to show assigned prefixes, available prefixes, or both
|
||||
@ -482,7 +485,9 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
|
||||
template_name = 'ipam/prefix/ip_ranges.html'
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return parent.get_child_ranges().restrict(request.user, 'view')
|
||||
return parent.get_child_ranges().restrict(request.user, 'view').prefetch_related(
|
||||
'vrf', 'role', 'tenant',
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@ -500,7 +505,9 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
|
||||
template_name = 'ipam/prefix/ip_addresses.html'
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return parent.get_child_ips().restrict(request.user, 'view')
|
||||
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related(
|
||||
'vrf', 'role', 'tenant',
|
||||
)
|
||||
|
||||
def prep_table_data(self, request, queryset, parent):
|
||||
show_available = bool(request.GET.get('show_available', 'true') == 'true')
|
||||
@ -569,7 +576,9 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
|
||||
template_name = 'ipam/iprange/ip_addresses.html'
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return parent.get_child_ips().restrict(request.user, 'view')
|
||||
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related(
|
||||
'vrf', 'role', 'tenant',
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
|
@ -180,7 +180,7 @@ CONNECTIONS_MENU = Menu(
|
||||
label='Connections',
|
||||
items=(
|
||||
get_model_item('dcim', 'cable', 'Cables', actions=['import']),
|
||||
get_model_item('wireless', 'wirelesslink', 'Wirelesss Links', actions=['import']),
|
||||
get_model_item('wireless', 'wirelesslink', 'Wireless Links', actions=['import']),
|
||||
MenuItem(
|
||||
link='dcim:interface_connections_list',
|
||||
link_text='Interface Connections',
|
||||
|
@ -5,6 +5,14 @@
|
||||
|
||||
{% 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 %}
|
||||
<div class="tab-content">
|
||||
{% with termination_a=form.instance.termination_a %}
|
||||
@ -27,6 +35,12 @@
|
||||
<input class="form-control" value="{{ termination_a.device.site.region }}" disabled />
|
||||
</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">
|
||||
<label class="col-sm-3 col-form-label text-lg-end">Site</label>
|
||||
<div class="col">
|
||||
@ -115,6 +129,9 @@
|
||||
{% if 'termination_b_region' in form.fields %}
|
||||
{% render_field form.termination_b_region %}
|
||||
{% endif %}
|
||||
{% if 'termination_b_sitegroup' in form.fields %}
|
||||
{% render_field form.termination_b_sitegroup %}
|
||||
{% endif %}
|
||||
{% if 'termination_b_site' in form.fields %}
|
||||
{% render_field form.termination_b_site %}
|
||||
{% endif %}
|
||||
|
@ -3,6 +3,11 @@
|
||||
|
||||
{% block extra_controls %}
|
||||
{% 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 }}
|
||||
{% endblock %}
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
{% extends 'ipam/prefix/base.html' %}
|
||||
{% load humanize %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
|
||||
@ -124,9 +125,18 @@
|
||||
<a href="{% url 'ipam:prefix_ipaddresses' pk=object.pk %}">{{ child_ip_count }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% with available_count=object.get_available_ips.size %}
|
||||
<tr>
|
||||
<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>
|
||||
{% endwith %}
|
||||
<tr>
|
||||
|
@ -3,7 +3,7 @@
|
||||
|
||||
{% block extra_controls %}
|
||||
{% 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
|
||||
</a>
|
||||
{% 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.
|
||||
"""
|
||||
template_code = """
|
||||
{% load helpers %}
|
||||
{% for tag in value.all %}
|
||||
{% include 'utilities/templatetags/tag.html' %}
|
||||
{% tag tag url_name=url_name %}
|
||||
{% empty %}
|
||||
<span class="text-muted">—</span>
|
||||
{% 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 = """
|
||||
{% 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>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
Loading…
Reference in New Issue
Block a user