Compare commits

..

44 Commits

Author SHA1 Message Date
Jeremy Stretch
d3e2241ff7 Merge pull request #8257 from netbox-community/develop
Release v3.1.5
2022-01-06 09:52:54 -05:00
jeremystretch
e90b9f6c19 Release v3.1.5 2022-01-06 09:24:28 -05:00
jeremystretch
4c1199e009 Fixes #8255: Fix bulk editing of authentication parameters for wireless LANs and links 2022-01-06 08:54:05 -05:00
jeremystretch
65471068b6 Closes #8252: Linkify type and group columns in clusters table 2022-01-05 21:36:20 -05:00
jeremystretch
c6467a824b #8228: Always add a blank choice 2022-01-05 17:10:59 -05:00
jeremystretch
b1d1f3c6b2 Fixes #8228: Optional ChoiceVar fields should not force a selection 2022-01-05 15:46:04 -05:00
jeremystretch
574c2e2770 Closes #8244: Add length & length unit fields to cable filter form 2022-01-05 15:32:34 -05:00
jeremystretch
aec2d233c9 Changelog for #8231 2022-01-05 15:18:49 -05:00
Jeremy Stretch
39418f2bbe Merge pull request #8247 from netbox-community/8231-htmx-confirmation-dialogs
Closes #8231: Use HTMX for object deletion confirmations
2022-01-05 15:14:51 -05:00
jeremystretch
ccda73494f Center modal dialog vertically 2022-01-05 14:57:56 -05:00
jeremystretch
443b4ccc57 Initial work on #8231 2022-01-05 14:06:56 -05:00
jeremystretch
511aedd5db Omit table configuration form from rack elevations view 2022-01-05 11:39:58 -05:00
jeremystretch
2524290099 Introduce modals template block 2022-01-05 09:21:48 -05:00
jeremystretch
01e8017265 Clean up template blocks 2022-01-05 09:09:39 -05:00
jeremystretch
8338fc405f Simplify theme color palette 2022-01-04 20:51:10 -05:00
jeremystretch
0a22b3990f #7450: Clean up footer and navbar styles 2022-01-04 20:42:44 -05:00
jeremystretch
662cafe416 Form widgets & style cleanup 2022-01-04 15:01:16 -05:00
jeremystretch
ea961ba8f2 Fixes #8224: Fix KeyError exception when creating FHRP group with IP address and protocol "other" 2022-01-04 13:49:07 -05:00
jeremystretch
8c8774cd2f Fixes #8226: Honor return URL after populating a device bay 2022-01-04 13:24:15 -05:00
jeremystretch
2fe02ddb1f Add tests for IPAM object children views 2022-01-04 09:32:41 -05:00
jeremystretch
e11e8a5d64 Fixes #8213: Fix ValueError exception under prefix IP addresses view 2022-01-04 09:15:25 -05:00
jeremystretch
79bebf7c9b PRVB 2022-01-03 11:18:46 -05:00
Jeremy Stretch
8d3b660ce0 Merge pull request #8212 from netbox-community/develop
Release v3.1.4
2022-01-03 11:16:27 -05:00
jeremystretch
9de53fe070 Release v3.1.4 2022-01-03 11:00:23 -05:00
jeremystretch
ecb9fc65b7 Closes #8197: Allow filtering sites by group when connecting a cable 2022-01-03 10:41:43 -05:00
Jeremy Stretch
7b25d0379f Merge pull request #8202 from netbja/patch-1
Small syntax error
2022-01-03 10:39:56 -05:00
jeremystretch
05d4176d34 Fixes #8201: Custom integer fields should allow negative integers as minimum/maximum values 2022-01-03 10:07:19 -05:00
jeremystretch
7b0dff88ae Closes #8210: Establish netbox/local/ as a path for local resources 2022-01-03 09:45:30 -05:00
jeremystretch
1c7604e0fe Fixes #8200: Correct typo in navigation menu 2022-01-03 09:20:26 -05:00
jeremystretch
e18dc43aae Fixes #8196: Fix IndexError exception when viewing large IPv6 prefixes in UI 2022-01-03 09:17:15 -05:00
netbja
caaad684a4 Small syntax error
No double quotes after password.
2021-12-31 11:25:12 +01:00
jeremystretch
cdd51aee75 Closes #8194: Enable bulk user assignment to groups under admin UI 2021-12-30 13:19:18 -05:00
jeremystretch
51851f6c99 Refactor users.admin 2021-12-30 13:08:09 -05:00
jeremystretch
ab98aa489c Related objects should be prefetched for Prefix/IPRange child object views 2021-12-30 12:43:37 -05:00
jeremystretch
5829985ca8 Remove power utilization as default column from racks table 2021-12-30 12:02:20 -05:00
jeremystretch
2fa8e27f05 Fixes #8192: Add "add prefix" button to aggregate child prefixes view 2021-12-30 12:00:37 -05:00
jeremystretch
68f92dfd5d Fix redirection URL for prefix IP ranges view 2021-12-30 11:47:21 -05:00
jeremystretch
67aeb380e7 Fix DNS name label in IP address bulk edit form 2021-12-30 11:46:09 -05:00
jeremystretch
f7d91b7139 Extend "Adding models" documentation 2021-12-30 10:12:28 -05:00
jeremystretch
b6e157f393 Add features summary to README 2021-12-30 10:08:31 -05:00
jeremystretch
2319fce092 Add tab to cable connect view 2021-12-30 09:51:30 -05:00
jeremystretch
a5f1707662 Fixes #8191: Fix return URL when adding IP addresses to VM interfaces 2021-12-30 09:46:02 -05:00
jeremystretch
6cda55da06 Fixes #8187: Fix rendering of tags column in object tables 2021-12-30 09:41:35 -05:00
jeremystretch
c3f2fee633 PRVB 2021-12-29 12:40:04 -05:00
81 changed files with 1090 additions and 691 deletions

View File

@@ -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.5
validations:
required: true
- type: dropdown

View File

@@ -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.5
validations:
required: true
- type: dropdown

1
.gitignore vendored
View File

@@ -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/*

View File

@@ -5,11 +5,46 @@
![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
NetBox is an infrastructure resource modeling (IRM) tool designed to empower
network automation. Initially conceived by the network engineering team at
network automation, used by thousands of organizations around the world.
Initially conceived by the network engineering team at
[DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically
to address the needs of network and infrastructure engineers. It is intended to
function as a domain-specific source of truth for network operations.
Myriad infrastructure components can be modeled in NetBox, including:
* Hierarchical regions, site groups, sites, and locations
* Racks, devices, and device components
* Cables and wireless connections
* Power distribution
* Data circuits and providers
* Virtual machines and clusters
* IP prefixes, ranges, and addresses
* VRFs and route targets
* FHRP groups (VRRP, HSRP, etc.)
* AS numbers
* VLANs and scoped VLAN groups
* Organizational tenants and contacts
In addition to its extensive built-in models and functionality, NetBox can be
customized and extended through the use of:
* Custom fields
* Custom links
* Configuration contexts
* Custom model validation rules
* Reports
* Custom scripts
* Export templates
* Conditional webhooks
* Plugins
* Single sign-on (SSO) authentication
* NAPALM integration
* Detailed change logging
NetBox also features a complete REST API as well as a GraphQL API for easily
integrating with other tools and systems.
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a
complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox).

View File

@@ -37,23 +37,32 @@ Most models will need view classes created in `views.py` to serve the following
Add the relevant URL path for each view created in the previous step to `urls.py`.
## 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.

View File

@@ -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,
},

View File

@@ -1,5 +1,41 @@
# NetBox v3.1
## v3.1.5 (2022-01-06)
### Enhancements
* [#8231](https://github.com/netbox-community/netbox/issues/8231) - Use in-page dialogs for confirming object deletion
* [#8244](https://github.com/netbox-community/netbox/issues/8244) - Add length & length unit fields to cable filter form
* [#8252](https://github.com/netbox-community/netbox/issues/8252) - Linkify type and group columns in clusters table
### Bug Fixes
* [#8213](https://github.com/netbox-community/netbox/issues/8213) - Fix ValueError exception under prefix IP addresses view
* [#8224](https://github.com/netbox-community/netbox/issues/8224) - Fix KeyError exception when creating FHRP group with IP address and protocol "other"
* [#8226](https://github.com/netbox-community/netbox/issues/8226) - Honor return URL after populating a device bay
* [#8228](https://github.com/netbox-community/netbox/issues/8228) - Optional ChoiceVar fields should not force a selection
* [#8255](https://github.com/netbox-community/netbox/issues/8255) - Fix bulk editing of authentication parameters for wireless LANs and links
---
## v3.1.4 (2022-01-03)
### Enhancements
* [#8192](https://github.com/netbox-community/netbox/issues/8192) - Add "add prefix" button to aggregate child prefixes view
* [#8194](https://github.com/netbox-community/netbox/issues/8194) - Enable bulk user assignment to groups under admin UI
* [#8197](https://github.com/netbox-community/netbox/issues/8197) - Allow filtering sites by group when connecting a cable
* [#8210](https://github.com/netbox-community/netbox/issues/8210) - Establish `netbox/local/` as a path for local resources
### Bug Fixes
* [#8187](https://github.com/netbox-community/netbox/issues/8187) - Fix rendering of tags column in object tables
* [#8191](https://github.com/netbox-community/netbox/issues/8191) - Fix return URL when adding IP addresses to VM interfaces
* [#8196](https://github.com/netbox-community/netbox/issues/8196) - Fix IndexError exception when viewing large IPv6 prefixes in UI
* [#8201](https://github.com/netbox-community/netbox/issues/8201) - Custom integer fields should allow negative integers as minimum/maximum values
---
## v3.1.3 (2021-12-29)
### Enhancements

View File

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

View File

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

View File

@@ -578,7 +578,7 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
field_groups = [
['q', 'tag'],
['site_id', 'rack_id', 'device_id'],
['type', 'status', 'color'],
['type', 'status', 'color', 'length', 'length_unit'],
['tenant_group_id', 'tenant_id'],
]
region_id = DynamicModelMultipleChoiceField(
@@ -603,6 +603,16 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
'site_id': '$site_id'
}
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
'tenant_id': '$tenant_id',
'rack_id': '$rack_id',
},
label=_('Device')
)
type = forms.MultipleChoiceField(
choices=add_blank_choice(CableTypeChoices),
required=False,
@@ -616,15 +626,12 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
color = ColorField(
required=False
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
'tenant_id': '$tenant_id',
'rack_id': '$rack_id',
},
label=_('Device')
length = forms.IntegerField(
required=False
)
length_unit = forms.ChoiceField(
choices=add_blank_choice(CableLengthUnitChoices),
required=False
)
tag = TagFilterField(model)

View File

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

View File

@@ -2035,8 +2035,9 @@ class DeviceBayPopulateView(generic.ObjectEditView):
device_bay.installed_device = form.cleaned_data['installed_device']
device_bay.save()
messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay))
return_url = self.get_return_url(request)
return redirect('dcim:device', pk=device_bay.device.pk)
return redirect(return_url)
return render(request, 'dcim/devicebay_populate.html', {
'device_bay': device_bay,

View File

@@ -7,8 +7,8 @@ from extras.models import *
from extras.utils import FeatureQuery
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField,
ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField,
DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
)
from virtualization.models import Cluster, ClusterGroup
@@ -41,6 +41,10 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
('Values', ('default', 'choices')),
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
)
widgets = {
'type': StaticSelect(),
'filter_logic': StaticSelect(),
}
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
@@ -57,6 +61,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
('Templates', ('link_text', 'link_url')),
)
widgets = {
'button_class': StaticSelect(),
'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
}
@@ -96,8 +101,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
model = Webhook
fields = '__all__'
fieldsets = (
('Webhook', ('name', 'enabled')),
('Assigned Models', ('content_types',)),
('Webhook', ('name', 'content_types', 'enabled')),
('Events', ('type_create', 'type_update', 'type_delete')),
('HTTP Request', (
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
@@ -105,7 +109,13 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
('Conditions', ('conditions',)),
('SSL', ('ssl_verification', 'ca_file_path')),
)
labels = {
'type_create': 'Creations',
'type_update': 'Updates',
'type_delete': 'Deletions',
}
widgets = {
'http_method': StaticSelect(),
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
}

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0066_customfield_name_validation'),
]
operations = [
migrations.AlterField(
model_name='customfield',
name='validation_maximum',
field=models.IntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='customfield',
name='validation_minimum',
field=models.IntegerField(blank=True, null=True),
),
]

View File

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

View File

@@ -21,7 +21,7 @@ from extras.models import JobResult
from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
from utilities.exceptions import AbortTransaction
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms import add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from .context_managers import change_logging
from .forms import ScriptForm
@@ -164,16 +164,22 @@ class ChoiceVar(ScriptVariable):
def __init__(self, choices, *args, **kwargs):
super().__init__(*args, **kwargs)
# Set field choices
self.field_attrs['choices'] = choices
# Set field choices, adding a blank choice to avoid forced selections
self.field_attrs['choices'] = add_blank_choice(choices)
class MultiChoiceVar(ChoiceVar):
class MultiChoiceVar(ScriptVariable):
"""
Like ChoiceVar, but allows for the selection of multiple choices.
"""
form_field = forms.MultipleChoiceField
def __init__(self, choices, *args, **kwargs):
super().__init__(*args, **kwargs)
# Set field choices
self.field_attrs['choices'] = choices
class ObjectVar(ScriptVariable):
"""

View File

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

View File

@@ -65,6 +65,7 @@ FHRP_PROTOCOL_ROLE_MAPPINGS = {
FHRPGroupProtocolChoices.PROTOCOL_HSRP: IPAddressRoleChoices.ROLE_HSRP,
FHRPGroupProtocolChoices.PROTOCOL_GLBP: IPAddressRoleChoices.ROLE_GLBP,
FHRPGroupProtocolChoices.PROTOCOL_CARP: IPAddressRoleChoices.ROLE_CARP,
FHRPGroupProtocolChoices.PROTOCOL_OTHER: IPAddressRoleChoices.ROLE_VIP,
}

View File

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

View File

@@ -580,7 +580,7 @@ class FHRPGroupForm(CustomFieldModelForm):
vrf=self.cleaned_data['ip_vrf'],
address=self.cleaned_data['ip_address'],
status=self.cleaned_data['ip_status'],
role=FHRP_PROTOCOL_ROLE_MAPPINGS[self.cleaned_data['protocol']],
role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
assigned_object=instance
)
ipaddress.save()
@@ -628,8 +628,7 @@ class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm):
class VLANGroupForm(CustomFieldModelForm):
scope_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
required=False,
widget=StaticSelect
required=False
)
region = DynamicModelChoiceField(
queryset=Region.objects.all(),

View File

@@ -32,6 +32,28 @@ __all__ = (
)
class GetAvailablePrefixesMixin:
def get_available_prefixes(self):
"""
Return all available Prefixes within this aggregate as an IPSet.
"""
prefix = netaddr.IPSet(self.prefix)
child_prefixes = netaddr.IPSet([child.prefix for child in self.get_child_prefixes()])
available_prefixes = prefix - child_prefixes
return available_prefixes
def get_first_available_prefix(self):
"""
Return the first available child prefix within the prefix (or None).
"""
available_prefixes = self.get_available_prefixes()
if not available_prefixes:
return None
return available_prefixes.iter_cidrs()[0]
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
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).

View File

@@ -1,5 +1,7 @@
import datetime
from django.test import override_settings
from django.urls import reverse
from netaddr import IPNetwork
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
@@ -222,6 +224,21 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description',
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_aggregate_prefixes(self):
rir = RIR.objects.first()
aggregate = Aggregate.objects.create(prefix=IPNetwork('192.168.0.0/16'), rir=rir)
prefixes = (
Prefix(prefix=IPNetwork('192.168.1.0/24')),
Prefix(prefix=IPNetwork('192.168.2.0/24')),
Prefix(prefix=IPNetwork('192.168.3.0/24')),
)
Prefix.objects.bulk_create(prefixes)
self.assertEqual(aggregate.get_child_prefixes().count(), 3)
url = reverse('ipam:aggregate_prefixes', kwargs={'pk': aggregate.pk})
self.assertHttpStatus(self.client.get(url), 200)
class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Role
@@ -319,6 +336,48 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description',
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_prefixes(self):
prefixes = (
Prefix(prefix=IPNetwork('192.168.0.0/16')),
Prefix(prefix=IPNetwork('192.168.1.0/24')),
Prefix(prefix=IPNetwork('192.168.2.0/24')),
Prefix(prefix=IPNetwork('192.168.3.0/24')),
)
Prefix.objects.bulk_create(prefixes)
self.assertEqual(prefixes[0].get_child_prefixes().count(), 3)
url = reverse('ipam:prefix_prefixes', kwargs={'pk': prefixes[0].pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_ipranges(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16'))
ip_ranges = (
IPRange(start_address='192.168.0.1/24', end_address='192.168.0.100/24', size=99),
IPRange(start_address='192.168.1.1/24', end_address='192.168.1.100/24', size=99),
IPRange(start_address='192.168.2.1/24', end_address='192.168.2.100/24', size=99),
)
IPRange.objects.bulk_create(ip_ranges)
self.assertEqual(prefix.get_child_ranges().count(), 3)
url = reverse('ipam:prefix_ipranges', kwargs={'pk': prefix.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_ipaddresses(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16'))
ip_addresses = (
IPAddress(address=IPNetwork('192.168.0.1/16')),
IPAddress(address=IPNetwork('192.168.0.2/16')),
IPAddress(address=IPNetwork('192.168.0.3/16')),
)
IPAddress.objects.bulk_create(ip_addresses)
self.assertEqual(prefix.get_child_ips().count(), 3)
url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
self.assertHttpStatus(self.client.get(url), 200)
class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = IPRange
@@ -377,6 +436,24 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description',
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_iprange_ipaddresses(self):
iprange = IPRange.objects.create(
start_address=IPNetwork('192.168.0.1/24'),
end_address=IPNetwork('192.168.0.100/24'),
size=99
)
ip_addresses = (
IPAddress(address=IPNetwork('192.168.0.1/24')),
IPAddress(address=IPNetwork('192.168.0.2/24')),
IPAddress(address=IPNetwork('192.168.0.3/24')),
)
IPAddress.objects.bulk_create(ip_addresses)
self.assertEqual(iprange.get_child_ips().count(), 3)
url = reverse('ipam:iprange_ipaddresses', kwargs={'pk': iprange.pk})
self.assertHttpStatus(self.client.get(url), 200)
class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = IPAddress

View File

@@ -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,7 @@ 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', 'tenant')
def prep_table_data(self, request, queryset, parent):
show_available = bool(request.GET.get('show_available', 'true') == 'true')
@@ -524,7 +529,6 @@ class PrefixEditView(generic.ObjectEditView):
class PrefixDeleteView(generic.ObjectDeleteView):
queryset = Prefix.objects.all()
template_name = 'ipam/prefix_delete.html'
class PrefixBulkImportView(generic.BulkImportView):
@@ -569,7 +573,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 {

View File

@@ -176,7 +176,7 @@ CONNECTIONS_MENU = Menu(
label='Connections',
items=(
get_model_item('dcim', 'cable', 'Cables', actions=['import']),
get_model_item('wireless', 'wirelesslink', 'Wirelesss Links', actions=['import']),
get_model_item('wireless', 'wirelesslink', 'Wireless Links', actions=['import']),
MenuItem(
link='dcim:interface_connections_list',
link_text='Interface Connections',

View File

@@ -19,7 +19,7 @@ from netbox.config import PARAMS
# Environment setup
#
VERSION = '3.1.3'
VERSION = '3.1.5'
# Hostname
HOSTNAME = platform.node()

View File

@@ -10,6 +10,7 @@ from django.db.models import ManyToManyField, ProtectedError
from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.html import escape
from django.utils.http import is_safe_url
from django.utils.safestring import mark_safe
@@ -430,10 +431,21 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
obj = self.get_object(kwargs)
form = ConfirmationForm(initial=request.GET)
# If this is an HTMX request, return only the rendered deletion form as modal content
if is_htmx(request):
viewname = f'{self.queryset.model._meta.app_label}:{self.queryset.model._meta.model_name}_delete'
form_url = reverse(viewname, kwargs={'pk': obj.pk})
return render(request, 'htmx/delete_form.html', {
'object': obj,
'object_type': self.queryset.model._meta.verbose_name,
'form': form,
'form_url': form_url,
})
return render(request, self.template_name, {
'obj': obj,
'object': obj,
'object_type': self.queryset.model._meta.verbose_name,
'form': form,
'obj_type': self.queryset.model._meta.verbose_name,
'return_url': self.get_return_url(request, obj),
})
@@ -466,9 +478,9 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
logger.debug("Form validation failed")
return render(request, self.template_name, {
'obj': obj,
'object': obj,
'object_type': self.queryset.model._meta.verbose_name,
'form': form,
'obj_type': self.queryset.model._meta.verbose_name,
'return_url': self.get_return_url(request, obj),
})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -358,7 +358,7 @@ nav.search {
// Don't overtake dropdowns
z-index: 999;
justify-content: center;
background-color: var(--nbx-body-bg);
background-color: $navbar-light-color;
.search-container {
display: flex;
@@ -452,8 +452,8 @@ main.login-container {
}
.footer {
background-color: $tab-content-bg;
padding: 0;
.nav-link {
padding: 0.5rem;
}
@@ -517,6 +517,10 @@ h6.accordion-item-title {
}
}
.navbar {
border-bottom: 1px solid $border-color;
}
.navbar-brand {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
@@ -554,6 +558,7 @@ div.content-container {
}
div.content {
background-color: $tab-content-bg;
flex: 1;
}
@@ -898,6 +903,7 @@ div.card-overlay {
// Tabbed content
.nav-tabs {
background-color: $body-bg;
.nav-link {
&:hover {
// Don't show a bottom-border on a hovered nav link because it overlaps with the .nav-tab border.
@@ -919,14 +925,6 @@ div.card-overlay {
display: flex;
flex-direction: column;
padding: $spacer;
background-color: $tab-content-bg;
border-bottom: 1px solid $nav-tabs-border-color;
// Remove background and border when printing.
@media print {
background-color: var(--nbx-body-bg) !important;
border-bottom: none !important;
}
}
// Override masonry-layout styles when printing.

View File

@@ -33,95 +33,6 @@ $darkest: #171b1d;
@import '../node_modules/bootstrap/scss/variables';
// Make color palette colors available as theme colors.
// For example, you could use `.bg-red-100`, if needed.
$theme-color-addons: (
'darker': $darker,
'darkest': $darkest,
'gray': $gray-400,
'gray-100': $gray-100,
'gray-200': $gray-200,
'gray-300': $gray-300,
'gray-400': $gray-400,
'gray-500': $gray-500,
'gray-600': $gray-600,
'gray-700': $gray-700,
'gray-800': $gray-800,
'gray-900': $gray-900,
'red-100': $red-100,
'red-200': $red-200,
'red-300': $red-300,
'red-400': $red-400,
'red-500': $red-500,
'red-600': $red-600,
'red-700': $red-700,
'red-800': $red-800,
'red-900': $red-900,
'yellow-100': $yellow-100,
'yellow-200': $yellow-200,
'yellow-300': $yellow-300,
'yellow-400': $yellow-400,
'yellow-500': $yellow-500,
'yellow-600': $yellow-600,
'yellow-700': $yellow-700,
'yellow-800': $yellow-800,
'yellow-900': $yellow-900,
'green-100': $green-100,
'green-200': $green-200,
'green-300': $green-300,
'green-400': $green-400,
'green-500': $green-500,
'green-600': $green-600,
'green-700': $green-700,
'green-800': $green-800,
'green-900': $green-900,
'blue-100': $blue-100,
'blue-200': $blue-200,
'blue-300': $blue-300,
'blue-400': $blue-400,
'blue-500': $blue-500,
'blue-600': $blue-600,
'blue-700': $blue-700,
'blue-800': $blue-800,
'blue-900': $blue-900,
'cyan-100': $cyan-100,
'cyan-200': $cyan-200,
'cyan-300': $cyan-300,
'cyan-400': $cyan-400,
'cyan-500': $cyan-500,
'cyan-600': $cyan-600,
'cyan-700': $cyan-700,
'cyan-800': $cyan-800,
'cyan-900': $cyan-900,
'indigo-100': $indigo-100,
'indigo-200': $indigo-200,
'indigo-300': $indigo-300,
'indigo-400': $indigo-400,
'indigo-500': $indigo-500,
'indigo-600': $indigo-600,
'indigo-700': $indigo-700,
'indigo-800': $indigo-800,
'indigo-900': $indigo-900,
'purple-100': $purple-100,
'purple-200': $purple-200,
'purple-300': $purple-300,
'purple-400': $purple-400,
'purple-500': $purple-500,
'purple-600': $purple-600,
'purple-700': $purple-700,
'purple-800': $purple-800,
'purple-900': $purple-900,
'pink-100': $pink-100,
'pink-200': $pink-200,
'pink-300': $pink-300,
'pink-400': $pink-400,
'pink-500': $pink-500,
'pink-600': $pink-600,
'pink-700': $pink-700,
'pink-800': $pink-800,
'pink-900': $pink-900,
);
// This is the same value as the default from Bootstrap, but it needs to be in scope prior to
// importing _variables.scss from Bootstrap.
$btn-close-width: 1em;

View File

@@ -3,6 +3,7 @@
@use 'sass:map';
@import './theme-base';
// Theme colors (BS5 classes)
$primary: $blue-300;
$secondary: $gray-500;
$success: $green-300;
@@ -13,6 +14,7 @@ $light: $gray-300;
$dark: $gray-500;
$theme-colors: (
// BS5 theme colors
'primary': $primary,
'secondary': $secondary,
'success': $success,
@@ -21,18 +23,23 @@ $theme-colors: (
'danger': $danger,
'light': $light,
'dark': $dark,
'red': $red-300,
'yellow': $yellow-300,
'green': $green-300,
// General-purpose palette
'blue': $blue-300,
'cyan': $cyan-300,
'indigo': $indigo-300,
'purple': $purple-300,
'pink': $pink-300,
'red': $red-300,
'orange': $orange-300,
'yellow': $yellow-300,
'green': $green-300,
'teal': $teal-300,
'cyan': $cyan-300,
'gray': $gray-300,
'black': $black,
'white': $white,
);
$theme-colors: map-merge($theme-colors, $theme-color-addons);
// Gradient
$gradient: linear-gradient(180deg, rgba($white, 0.15), rgba($white, 0));
@@ -139,7 +146,7 @@ $nav-tabs-link-active-border-color: $gray-800 $gray-800 $nav-tabs-link-active-bg
$nav-pills-link-active-color: $component-active-color;
$nav-pills-link-active-bg: $component-active-bg;
$navbar-light-color: $gray-500;
$navbar-light-color: $darkest;
$navbar-light-toggler-icon-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'><path stroke='#{$navbar-light-color}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/></svg>");
$navbar-light-toggler-border-color: $gray-700;

View File

@@ -2,28 +2,47 @@
@import './theme-base.scss';
$input-border-color: $gray-200;
// Theme colors (BS5 classes)
$primary: #337ab7;
$secondary: $gray-600;
$success: $green-500;
$info: #54d6f0;
$warning: $yellow-500;
$danger: $red-500;
$light: $gray-200;
$dark: $gray-800;
$theme-colors: map-merge(
$theme-colors,
(
'primary': #337ab7,
'info': #54d6f0,
'red': $red-500,
'yellow': $yellow-500,
'green': $green-500,
'blue': $blue-500,
'cyan': $cyan-500,
'indigo': $indigo-500,
'purple': $purple-500,
'pink': $pink-500,
)
$theme-colors: (
// BS5 theme colors
'primary': $primary,
'secondary': $secondary,
'success': $success,
'info': $info,
'warning': $warning,
'danger': $danger,
'light': $light,
'dark': $dark,
// General-purpose palette
'blue': $blue-500,
'indigo': $indigo-500,
'purple': $purple-500,
'pink': $pink-500,
'red': $red-500,
'orange': $orange-500,
'yellow': $yellow-500,
'green': $green-500,
'teal': $teal-500,
'cyan': $cyan-500,
'gray': $gray-500,
'black': $black,
'white': $white,
);
$theme-colors: map-merge($theme-colors, $theme-color-addons);
$light: $gray-200;
$navbar-light-color: $gray-100;
$card-cap-color: $gray-800;
$accordion-bg: transparent;

View File

@@ -20,7 +20,7 @@
</div>
{# Top bar #}
<nav class="navbar navbar-light sticky-top flex-md-nowrap p-1 mb-3 search container-fluid border-bottom noprint">
<nav class="navbar navbar-light sticky-top flex-md-nowrap p-1 mb-3 search container-fluid noprint">
{# Mobile Navigation #}
<div class="nav-mobile">
@@ -103,6 +103,9 @@
</div>
{% endif %}
{# BS5 pop-up modals #}
{% block modals %}{% endblock %}
{# Page footer #}
<footer class="footer container-fluid">
<div class="row align-items-center justify-content-between mx-0">

View File

@@ -5,6 +5,14 @@
{% block title %}Connect {{ form.instance.termination_a.device }} {{ form.instance.termination_a }} to {{ termination_b_type|bettertitle }}{% endblock %}
{% block 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 %}

View File

@@ -42,5 +42,8 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -42,5 +42,8 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -39,5 +39,8 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -42,5 +42,8 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -77,5 +77,8 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -39,5 +39,8 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -42,5 +42,8 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -42,5 +42,8 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -42,5 +42,8 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -4,7 +4,7 @@
{% render_errors form %}
{% block content %}
<form action="." method="post">
<form action="" method="post">
{% csrf_token %}
<div class="row mb-3">
<div class="col col-md-6 offset-md-3">

View File

@@ -73,3 +73,5 @@
</div>
{% endblock content-wrapper %}
{% block modals %}{% endblock %}

View File

@@ -10,7 +10,7 @@
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}">Reports</a></li>
<li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}#module.{{ report.module }}">{{ report.module|bettertitle }}</a></li>
{% endblock %}
{% endblock breadcrumbs %}
{% block subtitle %}
{% if report.description %}
@@ -18,33 +18,42 @@
<div class="text-muted">{{ report.description|render_markdown }}</div>
</div>
{% endif %}
{% endblock %}
{% endblock subtitle %}
{% block controls %}{% endblock %}
{% block tabs %}{% endblock %}
{% block content-wrapper %}
{% if perms.extras.run_report %}
<div class="px-3 float-end noprint">
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
{% csrf_token %}
<button type="submit" name="_run" class="btn btn-primary">
{% if report.result %}
<i class="mdi mdi-replay"></i> Run Again
{% else %}
<i class="mdi mdi-play"></i> Run Report
{% endif %}
</button>
</form>
</div>
{% endif %}
<div class="row px-3">
<div class="col col-md-12">
{% if report.result %}
Last run: <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">
<strong>{{ report.result.created|annotated_date }}</strong>
</a>
{% endif %}
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<a href="#report" role="tab" data-bs-toggle="tab" class="nav-link active">Report</a>
</li>
</ul>
{% endblock tabs %}
{% block content %}
<div role="tabpanel" class="tab-pane active" id="report">
{% if perms.extras.run_report %}
<div class="float-end noprint">
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
{% csrf_token %}
<button type="submit" name="_run" class="btn btn-primary">
{% if report.result %}
<i class="mdi mdi-replay"></i> Run Again
{% else %}
<i class="mdi mdi-play"></i> Run Report
{% endif %}
</button>
</form>
</div>
{% endif %}
<div class="row">
<div class="col col-md-12">
{% if report.result %}
Last run: <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">
<strong>{{ report.result.created|annotated_date }}</strong>
</a>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% endblock content %}

View File

@@ -1,7 +1,7 @@
{% extends 'extras/report.html' %}
{% block content-wrapper %}
<div class="row px-3">
<div class="row p-3">
<div class="col col-md-12"{% if not result.completed %} hx-get="{% url 'extras:report_result' job_result_pk=result.pk %}" hx-trigger="every 3s"{% endif %}>
{% include 'extras/htmx/report_result.html' %}
</div>

View File

@@ -7,69 +7,67 @@
{% block object_identifier %}
{{ script.full_name }}
{% endblock %}
{% endblock object_identifier %}
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">Scripts</a></li>
<li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module.{{ module }}">{{ module|bettertitle }}</a></li>
{% endblock %}
{% endblock breadcrumbs %}
{% block subtitle %}
<div class="object-subtitle">
<div class="text-muted">{{ script.Meta.description|render_markdown }}</div>
</div>
{% endblock %}
{% endblock subtitle %}
{% block controls %}{% endblock %}
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<a href="#run" role="tab" data-bs-toggle="tab" class="nav-link active">Run</a>
</li>
<li class="nav-item" role="presentation">
<a href="#source" role="tab" data-bs-toggle="tab" class="nav-link">Source</a>
</li>
</ul>
{% endblock %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<a href="#run" role="tab" data-bs-toggle="tab" class="nav-link active">Run</a>
</li>
<li class="nav-item" role="presentation">
<a href="#source" role="tab" data-bs-toggle="tab" class="nav-link">Source</a>
</li>
</ul>
{% endblock tabs %}
{% block content-wrapper %}
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="run">
<div class="row">
<div class="col">
{% if not perms.extras.run_script %}
<div class="alert alert-warning">
<i class="mdi mdi-alert"></i>
You do not have permission to run scripts.
</div>
{% endif %}
<form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
{% csrf_token %}
<div class="field-group my-4">
{% if form.requires_input %}
<div class="row mb-2">
<h5 class="offset-sm-3">Script Data</h5>
</div>
{% else %}
<div class="alert alert-info">
<i class="mdi mdi-information"></i>
This script does not require any input to run.
</div>
{% endif %}
{% render_form form %}
</div>
<div class="float-end">
<a href="{% url 'extras:script_list' %}" class="btn btn-outline-danger">Cancel</a>
<button type="submit" name="_run" class="btn btn-primary"{% if not perms.extras.run_script %} disabled="disabled"{% endif %}><i class="mdi mdi-play"></i> Run Script</button>
</div>
</form>
</div>
{% block content %}
<div role="tabpanel" class="tab-pane active" id="run">
<div class="row">
<div class="col">
{% if not perms.extras.run_script %}
<div class="alert alert-warning">
<i class="mdi mdi-alert"></i>
You do not have permission to run scripts.
</div>
{% endif %}
<form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
{% csrf_token %}
<div class="field-group my-4">
{% if form.requires_input %}
<div class="row mb-2">
<h5 class="offset-sm-3">Script Data</h5>
</div>
{% else %}
<div class="alert alert-info">
<i class="mdi mdi-information"></i>
This script does not require any input to run.
</div>
{% endif %}
{% render_form form %}
</div>
<div class="float-end">
<a href="{% url 'extras:script_list' %}" class="btn btn-outline-danger">Cancel</a>
<button type="submit" name="_run" class="btn btn-primary"{% if not perms.extras.run_script %} disabled="disabled"{% endif %}><i class="mdi mdi-play"></i> Run Script</button>
</div>
</form>
</div>
</div>
<div role="tabpanel" class="tab-pane" id="source">
<code class="h6 my-3 d-block">{{ script.filename }}</code>
<pre class="block">{{ script.source }}</pre>
</div>
</div>
{% endblock content-wrapper %}
<div role="tabpanel" class="tab-pane" id="source">
<code class="h6 my-3 d-block">{{ script.filename }}</code>
<pre class="block">{{ script.source }}</pre>
</div>
{% endblock content %}

View File

@@ -100,4 +100,8 @@
<div class="tab-content">
{% block content %}{% endblock %}
</div>
{% endblock %}
{% endblock content-wrapper %}
{% block modals %}
{% include 'inc/htmx_modal.html' %}
{% endblock modals %}

View File

@@ -1,9 +1,16 @@
{% extends 'generic/confirmation_form.html' %}
{% extends 'base/layout.html' %}
{% load form_helpers %}
{% block title %}Delete {{ obj_type }}?{% endblock %}
{% block title %}Delete {{ object_type }}?{% endblock %}
{% block message %}
<p>Are you sure you want to <strong class="text-danger">delete</strong> {{ obj_type }} <strong>{{ obj }}</strong>?</p>
{% block message_extra %}{% endblock %}
{% endblock message %}
{% block header %}{% endblock %}
{% block content %}
<div class="modal" tabindex="-1" style="display: block; position: static">
<div class="modal-dialog">
<div class="modal-content" >
{% include 'htmx/delete_form.html' %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -133,6 +133,8 @@
{% endif %}
</div>
{# Table config form #}
{% table_config_form table table_name="ObjectTable" %}
{% endblock content-wrapper %}
{% block modals %}
{% table_config_form table table_name="ObjectTable" %}
{% endblock modals %}

View File

@@ -0,0 +1,20 @@
{% load form_helpers %}
<form action="{{ form_url }}" method="post">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title">Confirm Deletion</h5>
</div>
<div class="modal-body">
<p>Are you sure you want to <strong class="text-danger">delete</strong> {{ object_type }} <strong>{{ object }}</strong>?</p>
{% render_form form %}
</div>
<div class="modal-footer">
{% if return_url %}
<a href="{{ return_url }}" class="btn btn-outline-secondary">Cancel</a>
{% else %}
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
{% endif %}
<button type="submit" class="btn btn-danger">Delete</button>
</div>
</form>

View File

@@ -0,0 +1,7 @@
<div class="modal fade" id="htmx-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content" id="htmx-modal-content">
{# Dynamic content goes here #}
</div>
</div>
</div>

View File

@@ -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 %}
@@ -32,5 +37,8 @@
</div>
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -35,5 +35,8 @@
</div>
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

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

View File

@@ -35,5 +35,8 @@
</div>
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -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 %}
@@ -35,5 +35,8 @@
</div>
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -37,5 +37,8 @@
</div>
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -1,5 +0,0 @@
{% extends 'generic/object_delete.html' %}
{% block message_extra %}
<p>Note: This will <strong>not</strong> delete any child prefixes or IP addresses.</p>
{% endblock %}

View File

@@ -37,9 +37,3 @@
{% endif %}
</ul>
{% endblock %}
{% block content-wrapper %}
<div class="tab-content">
{% block content %}{% endblock %}
</div>
{% endblock %}

View File

@@ -5,13 +5,14 @@
<form method="post">
{% csrf_token %}
{% include 'inc/table_controls_htmx.html' with table_modal="VLANDevicesTable_config" %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
</form>
{% endblock content %}
{% block modals %}
{% table_config_form table %}
{% endblock %}
{% endblock modals %}

View File

@@ -5,13 +5,14 @@
<form method="post">
{% csrf_token %}
{% include 'inc/table_controls_htmx.html' with table_modal="VLANVirtualMachinesTable_config" %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
</form>
{% endblock content %}
{% block modals %}
{% table_config_form table %}
{% endblock %}
{% endblock modals %}

View File

@@ -6,13 +6,11 @@
<form action="{% url 'virtualization:cluster_remove_devices' pk=object.pk %}" method="post">
{% csrf_token %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceTable_config" %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if perms.virtualization.change_cluster %}
@@ -23,5 +21,8 @@
</div>
</div>
</form>
{% endblock content %}
{% block modals %}
{% table_config_form table %}
{% endblock %}
{% endblock modals %}

View File

@@ -6,13 +6,11 @@
<form method="post">
{% csrf_token %}
{% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineTable_config" %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if perms.virtualization.change_virtualmachine %}
@@ -28,5 +26,8 @@
</div>
</div>
</form>
{% endblock content %}
{% block modals %}
{% table_config_form table %}
{% endblock %}
{% endblock modals %}

View File

@@ -37,5 +37,8 @@
<div class="clearfix"></div>
</div>
</form>
{% endblock content %}
{% block modals %}
{% table_config_form table %}
{% endblock %}
{% endblock modals %}

View File

@@ -1,294 +0,0 @@
from django import forms
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as UserAdmin_
from django.contrib.auth.models import Group, User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError, ValidationError
from utilities.forms.fields import ContentTypeMultipleChoiceField
from .constants import *
from .models import ObjectPermission, Token, UserConfig
#
# Inline models
#
class ObjectPermissionInline(admin.TabularInline):
exclude = None
extra = 3
readonly_fields = ['object_types', 'actions', 'constraints']
verbose_name = 'Permission'
verbose_name_plural = 'Permissions'
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('objectpermission__object_types')
@staticmethod
def object_types(instance):
# Don't call .values_list() here because we want to reference the pre-fetched object_types
return ', '.join([ot.name for ot in instance.objectpermission.object_types.all()])
@staticmethod
def actions(instance):
return ', '.join(instance.objectpermission.actions)
@staticmethod
def constraints(instance):
return instance.objectpermission.constraints
class GroupObjectPermissionInline(ObjectPermissionInline):
model = Group.object_permissions.through
class UserObjectPermissionInline(ObjectPermissionInline):
model = User.object_permissions.through
class UserConfigInline(admin.TabularInline):
model = UserConfig
readonly_fields = ('data',)
can_delete = False
verbose_name = 'Preferences'
#
# Users & groups
#
# Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below
admin.site.unregister(Group)
admin.site.unregister(User)
@admin.register(Group)
class GroupAdmin(admin.ModelAdmin):
fields = ('name',)
list_display = ('name', 'user_count')
ordering = ('name',)
search_fields = ('name',)
inlines = [GroupObjectPermissionInline]
@staticmethod
def user_count(obj):
return obj.user_set.count()
@admin.register(User)
class UserAdmin(UserAdmin_):
list_display = [
'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active'
]
fieldsets = (
(None, {'fields': ('username', 'password', 'first_name', 'last_name', 'email')}),
('Groups', {'fields': ('groups',)}),
('Status', {
'fields': ('is_active', 'is_staff', 'is_superuser'),
}),
('Important dates', {'fields': ('last_login', 'date_joined')}),
)
filter_horizontal = ('groups',)
list_filter = ('is_active', 'is_staff', 'is_superuser', 'groups__name')
def get_inlines(self, request, obj):
if obj is not None:
return (UserObjectPermissionInline, UserConfigInline)
return ()
#
# REST API tokens
#
class TokenAdminForm(forms.ModelForm):
key = forms.CharField(
required=False,
help_text="If no key is provided, one will be generated automatically."
)
class Meta:
fields = [
'user', 'key', 'write_enabled', 'expires', 'description'
]
model = Token
@admin.register(Token)
class TokenAdmin(admin.ModelAdmin):
form = TokenAdminForm
list_display = [
'key', 'user', 'created', 'expires', 'write_enabled', 'description'
]
#
# Permissions
#
class ObjectPermissionForm(forms.ModelForm):
object_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES
)
can_view = forms.BooleanField(required=False)
can_add = forms.BooleanField(required=False)
can_change = forms.BooleanField(required=False)
can_delete = forms.BooleanField(required=False)
class Meta:
model = ObjectPermission
exclude = []
help_texts = {
'actions': 'Actions granted in addition to those listed above',
'constraints': 'JSON expression of a queryset filter that will return only permitted objects. Leave null '
'to match all objects of this type. A list of multiple objects will result in a logical OR '
'operation.'
}
labels = {
'actions': 'Additional actions'
}
widgets = {
'constraints': forms.Textarea(attrs={'class': 'vLargeTextField'})
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make the actions field optional since the admin form uses it only for non-CRUD actions
self.fields['actions'].required = False
# Order group and user fields
self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name')
self.fields['users'].queryset = self.fields['users'].queryset.order_by('username')
# Check the appropriate checkboxes when editing an existing ObjectPermission
if self.instance.pk:
for action in ['view', 'add', 'change', 'delete']:
if action in self.instance.actions:
self.fields[f'can_{action}'].initial = True
self.instance.actions.remove(action)
def clean(self):
super().clean()
object_types = self.cleaned_data.get('object_types')
constraints = self.cleaned_data.get('constraints')
# Append any of the selected CRUD checkboxes to the actions list
if not self.cleaned_data.get('actions'):
self.cleaned_data['actions'] = list()
for action in ['view', 'add', 'change', 'delete']:
if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']:
self.cleaned_data['actions'].append(action)
# At least one action must be specified
if not self.cleaned_data['actions']:
raise ValidationError("At least one action must be selected.")
# Validate the specified model constraints by attempting to execute a query. We don't care whether the query
# returns anything; we just want to make sure the specified constraints are valid.
if object_types and constraints:
# Normalize the constraints to a list of dicts
if type(constraints) is not list:
constraints = [constraints]
for ct in object_types:
model = ct.model_class()
try:
model.objects.filter(*[Q(**c) for c in constraints]).exists()
except FieldError as e:
raise ValidationError({
'constraints': f'Invalid filter for {model}: {e}'
})
class ActionListFilter(admin.SimpleListFilter):
title = 'action'
parameter_name = 'action'
def lookups(self, request, model_admin):
options = set()
for action_list in ObjectPermission.objects.values_list('actions', flat=True).distinct():
options.update(action_list)
return [
(action, action) for action in sorted(options)
]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(actions=[self.value()])
class ObjectTypeListFilter(admin.SimpleListFilter):
title = 'object type'
parameter_name = 'object_type'
def lookups(self, request, model_admin):
object_types = ObjectPermission.objects.values_list('object_types__pk', flat=True).distinct()
content_types = ContentType.objects.filter(pk__in=object_types).order_by('app_label', 'model')
return [
(ct.pk, ct) for ct in content_types
]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(object_types=self.value())
@admin.register(ObjectPermission)
class ObjectPermissionAdmin(admin.ModelAdmin):
actions = ('enable', 'disable')
fieldsets = (
(None, {
'fields': ('name', 'description', 'enabled')
}),
('Actions', {
'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions')
}),
('Objects', {
'fields': ('object_types',)
}),
('Assignment', {
'fields': ('groups', 'users')
}),
('Constraints', {
'fields': ('constraints',),
'classes': ('monospace',)
}),
)
filter_horizontal = ('object_types', 'groups', 'users')
form = ObjectPermissionForm
list_display = [
'name', 'enabled', 'list_models', 'list_users', 'list_groups', 'actions', 'constraints', 'description',
]
list_filter = [
'enabled', ActionListFilter, ObjectTypeListFilter, 'groups', 'users'
]
search_fields = ['actions', 'constraints', 'description', 'name']
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('object_types', 'users', 'groups')
def list_models(self, obj):
return ', '.join([f"{ct}" for ct in obj.object_types.all()])
list_models.short_description = 'Models'
def list_users(self, obj):
return ', '.join([u.username for u in obj.users.all()])
list_users.short_description = 'Users'
def list_groups(self, obj):
return ', '.join([g.name for g in obj.groups.all()])
list_groups.short_description = 'Groups'
#
# Admin actions
#
def enable(self, request, queryset):
updated = queryset.update(enabled=True)
self.message_user(request, f"Enabled {updated} permissions")
def disable(self, request, queryset):
updated = queryset.update(enabled=False)
self.message_user(request, f"Disabled {updated} permissions")

View File

@@ -0,0 +1,125 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as UserAdmin_
from django.contrib.auth.models import Group, User
from users.models import ObjectPermission, Token
from . import filters, forms, inlines
#
# Users & groups
#
# Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below
admin.site.unregister(Group)
admin.site.unregister(User)
@admin.register(Group)
class GroupAdmin(admin.ModelAdmin):
form = forms.GroupAdminForm
list_display = ('name', 'user_count')
ordering = ('name',)
search_fields = ('name',)
inlines = [inlines.GroupObjectPermissionInline]
@staticmethod
def user_count(obj):
return obj.user_set.count()
@admin.register(User)
class UserAdmin(UserAdmin_):
list_display = [
'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active'
]
fieldsets = (
(None, {'fields': ('username', 'password', 'first_name', 'last_name', 'email')}),
('Groups', {'fields': ('groups',)}),
('Status', {
'fields': ('is_active', 'is_staff', 'is_superuser'),
}),
('Important dates', {'fields': ('last_login', 'date_joined')}),
)
filter_horizontal = ('groups',)
list_filter = ('is_active', 'is_staff', 'is_superuser', 'groups__name')
def get_inlines(self, request, obj):
if obj is not None:
return (inlines.UserObjectPermissionInline, inlines.UserConfigInline)
return ()
#
# REST API tokens
#
@admin.register(Token)
class TokenAdmin(admin.ModelAdmin):
form = forms.TokenAdminForm
list_display = [
'key', 'user', 'created', 'expires', 'write_enabled', 'description'
]
#
# Permissions
#
@admin.register(ObjectPermission)
class ObjectPermissionAdmin(admin.ModelAdmin):
actions = ('enable', 'disable')
fieldsets = (
(None, {
'fields': ('name', 'description', 'enabled')
}),
('Actions', {
'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions')
}),
('Objects', {
'fields': ('object_types',)
}),
('Assignment', {
'fields': ('groups', 'users')
}),
('Constraints', {
'fields': ('constraints',),
'classes': ('monospace',)
}),
)
filter_horizontal = ('object_types', 'groups', 'users')
form = forms.ObjectPermissionForm
list_display = [
'name', 'enabled', 'list_models', 'list_users', 'list_groups', 'actions', 'constraints', 'description',
]
list_filter = [
'enabled', filters.ActionListFilter, filters.ObjectTypeListFilter, 'groups', 'users'
]
search_fields = ['actions', 'constraints', 'description', 'name']
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('object_types', 'users', 'groups')
def list_models(self, obj):
return ', '.join([f"{ct}" for ct in obj.object_types.all()])
list_models.short_description = 'Models'
def list_users(self, obj):
return ', '.join([u.username for u in obj.users.all()])
list_users.short_description = 'Users'
def list_groups(self, obj):
return ', '.join([g.name for g in obj.groups.all()])
list_groups.short_description = 'Groups'
#
# Admin actions
#
def enable(self, request, queryset):
updated = queryset.update(enabled=True)
self.message_user(request, f"Enabled {updated} permissions")
def disable(self, request, queryset):
updated = queryset.update(enabled=False)
self.message_user(request, f"Disabled {updated} permissions")

View File

@@ -0,0 +1,42 @@
from django.contrib import admin
from django.contrib.contenttypes.models import ContentType
from users.models import ObjectPermission
__all__ = (
'ActionListFilter',
'ObjectTypeListFilter',
)
class ActionListFilter(admin.SimpleListFilter):
title = 'action'
parameter_name = 'action'
def lookups(self, request, model_admin):
options = set()
for action_list in ObjectPermission.objects.values_list('actions', flat=True).distinct():
options.update(action_list)
return [
(action, action) for action in sorted(options)
]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(actions=[self.value()])
class ObjectTypeListFilter(admin.SimpleListFilter):
title = 'object type'
parameter_name = 'object_type'
def lookups(self, request, model_admin):
object_types = ObjectPermission.objects.values_list('object_types__pk', flat=True).distinct()
content_types = ContentType.objects.filter(pk__in=object_types).order_by('app_label', 'model')
return [
(ct.pk, ct) for ct in content_types
]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(object_types=self.value())

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

@@ -0,0 +1,132 @@
from django import forms
from django.contrib.auth.models import Group, User
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError, ValidationError
from django.db.models import Q
from users.constants import OBJECTPERMISSION_OBJECT_TYPES
from users.models import ObjectPermission, Token
from utilities.forms.fields import ContentTypeMultipleChoiceField
__all__ = (
'GroupAdminForm',
'ObjectPermissionForm',
'TokenAdminForm',
)
class GroupAdminForm(forms.ModelForm):
users = forms.ModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
widget=FilteredSelectMultiple('users', False)
)
class Meta:
model = Group
fields = ('name', 'users')
def __init__(self, *args, **kwargs):
super(GroupAdminForm, self).__init__(*args, **kwargs)
if self.instance.pk:
self.fields['users'].initial = self.instance.user_set.all()
def save_m2m(self):
self.instance.user_set.set(self.cleaned_data['users'])
def save(self, *args, **kwargs):
instance = super(GroupAdminForm, self).save()
self.save_m2m()
return instance
class TokenAdminForm(forms.ModelForm):
key = forms.CharField(
required=False,
help_text="If no key is provided, one will be generated automatically."
)
class Meta:
fields = [
'user', 'key', 'write_enabled', 'expires', 'description'
]
model = Token
class ObjectPermissionForm(forms.ModelForm):
object_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES
)
can_view = forms.BooleanField(required=False)
can_add = forms.BooleanField(required=False)
can_change = forms.BooleanField(required=False)
can_delete = forms.BooleanField(required=False)
class Meta:
model = ObjectPermission
exclude = []
help_texts = {
'actions': 'Actions granted in addition to those listed above',
'constraints': 'JSON expression of a queryset filter that will return only permitted objects. Leave null '
'to match all objects of this type. A list of multiple objects will result in a logical OR '
'operation.'
}
labels = {
'actions': 'Additional actions'
}
widgets = {
'constraints': forms.Textarea(attrs={'class': 'vLargeTextField'})
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make the actions field optional since the admin form uses it only for non-CRUD actions
self.fields['actions'].required = False
# Order group and user fields
self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name')
self.fields['users'].queryset = self.fields['users'].queryset.order_by('username')
# Check the appropriate checkboxes when editing an existing ObjectPermission
if self.instance.pk:
for action in ['view', 'add', 'change', 'delete']:
if action in self.instance.actions:
self.fields[f'can_{action}'].initial = True
self.instance.actions.remove(action)
def clean(self):
super().clean()
object_types = self.cleaned_data.get('object_types')
constraints = self.cleaned_data.get('constraints')
# Append any of the selected CRUD checkboxes to the actions list
if not self.cleaned_data.get('actions'):
self.cleaned_data['actions'] = list()
for action in ['view', 'add', 'change', 'delete']:
if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']:
self.cleaned_data['actions'].append(action)
# At least one action must be specified
if not self.cleaned_data['actions']:
raise ValidationError("At least one action must be selected.")
# Validate the specified model constraints by attempting to execute a query. We don't care whether the query
# returns anything; we just want to make sure the specified constraints are valid.
if object_types and constraints:
# Normalize the constraints to a list of dicts
if type(constraints) is not list:
constraints = [constraints]
for ct in object_types:
model = ct.model_class()
try:
model.objects.filter(*[Q(**c) for c in constraints]).exists()
except FieldError as e:
raise ValidationError({
'constraints': f'Invalid filter for {model}: {e}'
})

View File

@@ -0,0 +1,49 @@
from django.contrib import admin
from django.contrib.auth.models import Group, User
from users.models import UserConfig
__all__ = (
'GroupObjectPermissionInline',
'UserConfigInline',
'UserObjectPermissionInline',
)
class ObjectPermissionInline(admin.TabularInline):
exclude = None
extra = 3
readonly_fields = ['object_types', 'actions', 'constraints']
verbose_name = 'Permission'
verbose_name_plural = 'Permissions'
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('objectpermission__object_types')
@staticmethod
def object_types(instance):
# Don't call .values_list() here because we want to reference the pre-fetched object_types
return ', '.join([ot.name for ot in instance.objectpermission.object_types.all()])
@staticmethod
def actions(instance):
return ', '.join(instance.objectpermission.actions)
@staticmethod
def constraints(instance):
return instance.objectpermission.constraints
class GroupObjectPermissionInline(ObjectPermissionInline):
model = Group.object_permissions.through
class UserObjectPermissionInline(ObjectPermissionInline):
model = User.object_permissions.through
class UserConfigInline(admin.TabularInline):
model = UserConfig
readonly_fields = ('data',)
can_delete = False
verbose_name = 'Preferences'

View File

@@ -168,11 +168,11 @@ class ContentTypeChoiceMixin:
class ContentTypeChoiceField(ContentTypeChoiceMixin, forms.ModelChoiceField):
pass
widget = widgets.StaticSelect
class ContentTypeMultipleChoiceField(ContentTypeChoiceMixin, forms.ModelMultipleChoiceField):
pass
widget = widgets.StaticSelectMultiple
#

View File

@@ -14,7 +14,6 @@ __all__ = (
'BulkEditNullBooleanSelect',
'ClearableFileInput',
'ColorSelect',
'ContentTypeSelect',
'DatePicker',
'DateTimePicker',
'NumericArrayField',
@@ -110,15 +109,6 @@ class SelectWithPK(StaticSelect):
option_template_name = 'widgets/select_option_with_pk.html'
class ContentTypeSelect(StaticSelect):
"""
Appends an `api-value` attribute equal to the slugified model name for each ContentType. For example:
<option value="37" api-value="console-server-port">console server port</option>
This attribute can be used to reference the relevant API endpoint for a particular ContentType.
"""
option_template_name = 'widgets/select_contenttype.html'
class SelectSpeedWidget(forms.NumberInput):
"""
Speed field with dropdown selections for convenience.

View File

@@ -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">&mdash;</span>
{% endfor %}

View File

@@ -1,3 +1,9 @@
<a href="{{ url }}" class="btn btn-sm btn-danger" role="button">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span>&nbsp;Delete
<a href="#"
hx-get="{{ url }}"
hx-target="#htmx-modal-content"
class="btn btn-sm btn-danger"
data-bs-toggle="modal"
data-bs-target="#htmx-modal"
>
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span>&nbsp;Delete
</a>

View File

@@ -1 +0,0 @@
<option value="{{ widget.value }}"{% include "django/forms/widgets/attrs.html" %}{% if widget.value %} api-value="{{ widget.label|slugify }}"{% endif %}>{{ widget.label.label|default:widget.label|capfirst }}</option>

View File

@@ -0,0 +1,36 @@
from django.template import Context, Template
from django.test import TestCase
from dcim.models import Site
from utilities.tables import BaseTable, TagColumn
from utilities.testing import create_tags
class TagColumnTable(BaseTable):
tags = TagColumn(url_name='dcim:site_list')
class Meta(BaseTable.Meta):
model = Site
fields = ('pk', 'name', 'tags',)
default_columns = fields
class TagColumnTest(TestCase):
@classmethod
def setUpTestData(cls):
tags = create_tags('Alpha', 'Bravo', 'Charlie')
sites = [
Site(name=f'Site {i}', slug=f'site-{i}') for i in range(1, 6)
]
Site.objects.bulk_create(sites)
for site in sites:
site.tags.add(*tags)
def test_tagcolumn(self):
template = Template('{% load render_table from django_tables2 %}{% render_table table %}')
context = Context({
'table': TagColumnTable(Site.objects.all(), orderable=False)
})
template.render(context)

View File

@@ -18,7 +18,7 @@ __all__ = (
VMINTERFACE_BUTTONS = """
{% 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 %}
@@ -80,6 +80,12 @@ class ClusterTable(BaseTable):
name = tables.Column(
linkify=True
)
type = tables.Column(
linkify=True
)
group = tables.Column(
linkify=True
)
tenant = tables.Column(
linkify=True
)

View File

@@ -3,7 +3,7 @@ from django import forms
from dcim.choices import LinkStatusChoices
from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
from ipam.models import VLAN
from utilities.forms import DynamicModelChoiceField
from utilities.forms import add_blank_choice, DynamicModelChoiceField
from wireless.choices import *
from wireless.constants import SSID_MAX_LENGTH
from wireless.models import *
@@ -45,24 +45,27 @@ class WirelessLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
label='VLAN'
)
ssid = forms.CharField(
max_length=SSID_MAX_LENGTH,
required=False
required=False,
label='SSID'
)
description = forms.CharField(
required=False
)
auth_type = forms.ChoiceField(
choices=WirelessAuthTypeChoices,
choices=add_blank_choice(WirelessAuthTypeChoices),
required=False
)
auth_cipher = forms.ChoiceField(
choices=WirelessAuthCipherChoices,
choices=add_blank_choice(WirelessAuthCipherChoices),
required=False
)
auth_psk = forms.CharField(
required=False
required=False,
label='Pre-shared key'
)
class Meta:
@@ -76,25 +79,27 @@ class WirelessLinkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
)
ssid = forms.CharField(
max_length=SSID_MAX_LENGTH,
required=False
required=False,
label='SSID'
)
status = forms.ChoiceField(
choices=LinkStatusChoices,
choices=add_blank_choice(LinkStatusChoices),
required=False
)
description = forms.CharField(
required=False
)
auth_type = forms.ChoiceField(
choices=WirelessAuthTypeChoices,
choices=add_blank_choice(WirelessAuthTypeChoices),
required=False
)
auth_cipher = forms.ChoiceField(
choices=WirelessAuthCipherChoices,
choices=add_blank_choice(WirelessAuthCipherChoices),
required=False
)
auth_psk = forms.CharField(
required=False
required=False,
label='Pre-shared key'
)
class Meta:

View File

@@ -1,4 +1,4 @@
Django==3.2.10
Django==3.2.11
django-cors-headers==3.10.1
django-debug-toolbar==3.2.4
django-filter==21.1
@@ -18,7 +18,7 @@ gunicorn==20.1.0
Jinja2==3.0.3
Markdown==3.3.6
markdown-include==0.6.0
mkdocs-material==8.1.3
mkdocs-material==8.1.4
netaddr==0.8.0
Pillow==8.4.0
psycopg2-binary==2.9.3