Compare commits

...

57 Commits

Author SHA1 Message Date
Jeremy Stretch
d50148fab7 Merge pull request #8968 from netbox-community/develop
Release v3.1.10
2022-03-25 10:28:38 -04:00
jeremystretch
271c2ea3e3 Correct changelog 2022-03-25 10:16:40 -04:00
jeremystretch
20a6f6ac79 Release v3.1.10 2022-03-25 10:14:37 -04:00
jeremystretch
8924d5fa05 Correct change log 2022-03-25 10:04:48 -04:00
jeremystretch
26637d934b Change log for #8232, #8926 2022-03-25 10:02:21 -04:00
jeremystretch
dde4495e20 #8232: Cleanup & test fix 2022-03-25 09:59:58 -04:00
tranthang2404
1278429518 Closes #8232: Add color show full 100% utilization (#8816)
* Closes #8232: Add color show full 100% utilization

* change rounding

* change rounding

* fix hard code html

* format
2022-03-25 09:52:13 -04:00
Jeremy Stretch
421f5a03aa Merge pull request #8963 from minitriga/issue_8926
Closes #8926: Implement type and roll to device bay table
2022-03-25 09:12:45 -04:00
Alex Gittings
a433d5d59d Closes #8926: Implement type and roll to device bay table 2022-03-25 09:25:55 +00:00
jeremystretch
a5820e27a6 Fixes #8905: Disable ordering by assigned tags to prevent erroneous results 2022-03-24 11:56:18 -04:00
jeremystretch
d312fe7c2b Fixes #8696: Fix help link under FHRP group assigment creation view 2022-03-24 11:14:24 -04:00
Jeremy Stretch
124fc73386 Merge pull request #8953 from 991jo/fix-8952
Fixed #8952: rack rear faces link not clickable
2022-03-24 10:53:42 -04:00
jeremystretch
c78e7c14d3 Fixes #8947: Retain filter parameters when handling an export template exception 2022-03-24 10:47:39 -04:00
jeremystretch
30a6dc2f64 Fixes #8951: Allow changing device type & platform to different manufacturer simultaneously 2022-03-24 10:34:09 -04:00
Johannes Erwerle
6ceb78fd4c Fixed #8952: rack rear faces link not clickable 2022-03-24 09:34:23 +01:00
jeremystretch
e09ab79a1a Changelog for #8924 2022-03-23 17:01:57 -04:00
Jeremy Stretch
b6587c00ce Merge pull request #8925 from kkthxbye-code/fast_script_list
Fix #8924 - Speed up rendering of the script list
2022-03-23 16:43:15 -04:00
kkthxbye
ae46cd33b6 - Move do_not_call_in_templates to BaseScript
- Fix the name classproperty
2022-03-23 12:18:14 +01:00
jeremystretch
41efad4056 Fixes #8919: Fix filtering of VLAN groups by site under prefix edit form 2022-03-22 11:39:26 -04:00
jeremystretch
5f89226cd7 Update testing instructions 2022-03-22 10:59:43 -04:00
jeremystretch
197dfca5b2 Fixes #8935: Correct ordering of next/previous racks to use naturalized names 2022-03-22 09:50:38 -04:00
jeremystretch
e6980626d8 Fixes #8932: Fix error when setting null value for interface rf_role via REST API 2022-03-22 09:37:57 -04:00
kkthxbye
22980cea7b Speed up rendering of the script list 2022-03-21 10:46:51 +01:00
jeremystretch
f64987d0c4 Changelog for #8813 2022-03-18 13:25:47 -04:00
PieterL75
0da04232f3 Fixes #8813 Retain search value after submitting (#8907)
* Fixes #8813 Retain search value after submitting

* remove autofocus from searchbar

Co-authored-by: Pieter Lambrecht <pieter.lambrecht@sentia.com>
2022-03-18 13:23:39 -04:00
jeremystretch
9a0bb14e76 Install tblib to fix tracebacks during parallel test runs 2022-03-18 11:47:43 -04:00
jeremystretch
900825a2af Changelog for #8457, #8575, #8645 2022-03-18 11:46:49 -04:00
Jeremy Stretch
52de50aa64 Merge pull request #8873 from minitriga/issue_8457
Closes: #8457 - Nonracked  Devices Location/Site
2022-03-18 11:32:24 -04:00
Jeremy Stretch
1541060091 Merge pull request #8742 from minitriga/issue_8645
Allow filtering on Core models for Contacts
2022-03-18 11:24:09 -04:00
Alex Gittings
50bc0caccf Fix issues with ordering and add field_groups 2022-03-18 14:58:51 +00:00
minitriga
8f5b14ec84 Update netbox/dcim/forms/filtersets.py
Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2022-03-18 14:39:37 +00:00
minitriga
da37db1ea9 Update netbox/circuits/filtersets.py
Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2022-03-18 14:39:22 +00:00
Alex Gittings
5abde866f1 Closes: #8457 - implement nonracked devices on locations and sites 2022-03-18 14:34:42 +00:00
Jeremy Stretch
32eed72d2b Merge pull request #8874 from minitriga/issue_8575
Closes: #8575 - Show Racks on Cable Table and Cable Page
2022-03-18 10:14:40 -04:00
jeremystretch
585b5a221d Changelog for #8553 2022-03-17 17:06:21 -04:00
Jeremy Stretch
db52fe475a Merge pull request #8573 from 991jo/asn_search_fix
Fixes 8553: Fix contacts and ASNs missing in the search dropdown and …
2022-03-17 16:43:30 -04:00
Jeremy Stretch
c5db99f383 Merge pull request #8887 from sc68cal/sc68cal-patch-1
Update GitHub link for Netaddr
2022-03-16 20:18:47 -04:00
Sean M. Collins
fd6d3205d0 Update GitHub link for Netaddr
The project was renamed/moved to a new location in GitHub and we should update the link
in case the redirect stops functioning
2022-03-16 11:45:14 -04:00
Alex Gittings
9548cf32ff add new line 2022-03-15 00:05:10 +00:00
Alex Gittings
bdbfff911b add new line 2022-03-15 00:04:22 +00:00
Alex Gittings
a143eca57d Closes: #8575 Implement rack_a and rack_b for cable table 2022-03-15 00:02:16 +00:00
jeremystretch
1add5accf2 Fixes #8844: Correct VLAN ID max value 2022-03-14 09:57:51 -04:00
jeremystretch
faba6c9bdc Fixes #8850: Show airflow field on device REST API serializer when config context data is included 2022-03-14 09:54:11 -04:00
jeremystretch
4eb7cd06b4 Adjust font size for serial number under device status view 2022-03-14 09:46:40 -04:00
Alex Gittings
342f1d31be fix pycodestyle issues 2022-03-09 17:55:45 +00:00
Alex Gittings
b779bbfc9d add contacts to site table 2022-03-09 17:49:02 +00:00
Alex Gittings
ef6576bdd6 merge develop into issue 2022-03-09 17:47:58 +00:00
Alex Gittings
27dab262de add columns for each model table that has contacts 2022-03-09 17:35:25 +00:00
Alex Gittings
412c1df15a acidentally removed NestedContactAssignmentSerializer in previous commit 2022-03-09 16:48:29 +00:00
Alex Gittings
73af3ba095 remove contacts from api endpoints 2022-03-09 16:45:19 +00:00
Alex Gittings
21b7564976 Merge branch 'issue_8645' of https://github.com/minitriga/netbox into issue_8645 2022-03-09 16:36:16 +00:00
Alex Gittings
bf22b820bf Fixes #8645; Allow filtering on core models in the UI 2022-03-09 16:35:47 +00:00
thatmattlove
8cd24b1a67 Fixes #8820: correct navbar color in dark mode 2022-03-08 14:28:52 -07:00
jeremystretch
1fdc7a9163 Merge branch 'master' into develop 2022-03-07 10:49:06 -05:00
jeremystretch
c515218760 PRVB 2022-03-07 10:07:07 -05:00
Alex Gittings
36d6dd1ca9 Fixes #8645; Allow filtering on core models in the UI and API for contact assignments 2022-02-24 17:08:38 +00:00
Johannes Erwerle
538984c6d2 Fixes 8553: Fix Provider network, ASN, and contact options missings from global search selector 2022-02-21 12:26:12 +01:00
60 changed files with 674 additions and 440 deletions

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.1.9
placeholder: v3.1.10
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.9
placeholder: v3.1.10
validations:
required: true
- type: dropdown

View File

@@ -56,7 +56,7 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pycodestyle coverage
pip install pycodestyle coverage tblib
ln -s configuration.testing.py netbox/netbox/configuration.py
- name: Build documentation

View File

@@ -83,7 +83,7 @@ markdown-include
mkdocs-material
# Library for manipulating IP prefixes and addresses
# https://github.com/drkjam/netaddr
# https://github.com/netaddr/netaddr
netaddr
# Fork of PIL (Python Imaging Library) for image processing

View File

@@ -21,6 +21,7 @@
---
{!models/ipam/fhrpgroup.md!}
{!models/ipam/fhrpgroupassignment.md!}
---

View File

@@ -124,8 +124,10 @@ The demo data is provided in JSON format and loaded into an empty database using
Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command. Remember to ensure the Python virtual environment is active before running this command. Also keep in mind that these commands are executed in the `/netbox/` directory, not the root directory of the repository.
When running tests, it's advised to use the special testing configuration file that ships with NetBox. This ensures that tests are run with the same configuration parameters consistently. To override your local configuration when running tests, set the `NETBOX_CONFIGURATION` environment variable to `netbox.configuration_testing`.
```no-highlight
$ python manage.py test
$ NETBOX_CONFIGURATION=netbox.configuration_testing python manage.py test
```
In cases where you haven't made any changes to the database (which is most of the time), you can append the `--keepdb` argument to this command to reuse the test database between runs. This cuts down on the time it takes to run the test suite since the database doesn't have to be rebuilt each time. (Note that this argument will cause errors if you've modified any model fields since the previous test run.)

View File

@@ -8,9 +8,3 @@ A first-hop redundancy protocol (FHRP) enables multiple physical interfaces to p
* Gateway Load Balancing Protocol (GLBP)
NetBox models these redundancy groups by protocol and group ID. Each group may optionally be assigned an authentication type and key. (Note that the authentication key is stored as a plaintext value in NetBox.) Each group may be assigned or more virtual IPv4 and/or IPv6 addresses.
## FHRP Group Assignments
Member device and VM interfaces can be assigned to FHRP groups, along with a numeric priority value. For instance, three interfaces, each belonging to a different router, may each be assigned to the same FHRP group to serve a common virtual IP address. Each of these assignments would typically receive a different priority.
Interfaces are assigned to FHRP groups under the interface detail view.

View File

@@ -0,0 +1,5 @@
# FHRP Group Assignments
Member device and VM interfaces can be assigned to FHRP groups, along with a numeric priority value. For instance, three interfaces, each belonging to a different router, may each be assigned to the same FHRP group to serve a common virtual IP address. Each of these assignments would typically receive a different priority.
Interfaces are assigned to FHRP groups under the interface detail view.

View File

@@ -1,5 +1,33 @@
# NetBox v3.1
## v3.1.10 (2022-03-25)
### Enhancements
* [#8232](https://github.com/netbox-community/netbox/issues/8232) - Use a different color for 100% utilization bars
* [#8457](https://github.com/netbox-community/netbox/issues/8457) - Enable adding non-racked devices from site & location views
* [#8553](https://github.com/netbox-community/netbox/issues/8553) - Add missing object types to global search form
* [#8575](https://github.com/netbox-community/netbox/issues/8575) - Add rack columns to cables list
* [#8645](https://github.com/netbox-community/netbox/issues/8645) - Enable filtering objects by assigned contacts & contact roles
* [#8926](https://github.com/netbox-community/netbox/issues/8926) - Add device type, role columns to device bay table
### Bug Fixes
* [#8696](https://github.com/netbox-community/netbox/issues/8696) - Fix help link under FHRP group assigment creation view
* [#8813](https://github.com/netbox-community/netbox/issues/8813) - Retain global search bar query after submitting
* [#8820](https://github.com/netbox-community/netbox/issues/8820) - Fix navbar background color in dark mode
* [#8850](https://github.com/netbox-community/netbox/issues/8850) - Show airflow field on device REST API serializer when config context data is included
* [#8905](https://github.com/netbox-community/netbox/issues/8905) - Disable ordering by assigned tags to prevent erroneous results
* [#8919](https://github.com/netbox-community/netbox/issues/8919) - Fix filtering of VLAN groups by site under prefix edit form
* [#8924](https://github.com/netbox-community/netbox/issues/8924) - Improve load time of custom script list
* [#8932](https://github.com/netbox-community/netbox/issues/8932) - Fix error when setting null value for interface `rf_role` via REST API
* [#8935](https://github.com/netbox-community/netbox/issues/8935) - Correct ordering of next/previous racks to use naturalized names
* [#8947](https://github.com/netbox-community/netbox/issues/8947) - Retain filter parameters when handling an export template exception
* [#8951](https://github.com/netbox-community/netbox/issues/8951) - Allow changing device type & platform to different manufacturer simultaneously
* [#8952](https://github.com/netbox-community/netbox/issues/8952) - Device images in rear rack elevations should be hyperlinked
---
## v3.1.9 (2022-03-07)
### Enhancements

View File

@@ -5,7 +5,7 @@ from dcim.filtersets import CableTerminationFilterSet
from dcim.models import Region, Site, SiteGroup
from extras.filters import TagFilter
from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
from tenancy.filtersets import TenancyFilterSet
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from utilities.filters import TreeNodeMultipleChoiceFilter
from .choices import *
from .models import *
@@ -19,7 +19,7 @@ __all__ = (
)
class ProviderFilterSet(PrimaryModelFilterSet):
class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -118,7 +118,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
fields = ['id', 'name', 'slug', 'description']
class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',

View File

@@ -5,7 +5,7 @@ from circuits.choices import CircuitStatusChoices
from circuits.models import *
from dcim.models import Region, Site, SiteGroup
from extras.forms import CustomFieldModelFilterForm
from tenancy.forms import TenancyFilterForm
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
from utilities.forms import DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField
__all__ = (
@@ -16,12 +16,13 @@ __all__ = (
)
class ProviderFilterForm(CustomFieldModelFilterForm):
class ProviderFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
model = Provider
field_groups = [
['q', 'tag'],
['region_id', 'site_group_id', 'site_id'],
['asn'],
['contact', 'contact_role']
]
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -68,7 +69,7 @@ class CircuitTypeFilterForm(CustomFieldModelFilterForm):
tag = TagFilterField(model)
class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
model = Circuit
field_groups = [
['q', 'tag'],
@@ -76,6 +77,7 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
['type_id', 'status', 'commit_rate'],
['region_id', 'site_group_id', 'site_id'],
['tenant_group_id', 'tenant_id'],
['contact', 'contact_role']
]
type_id = DynamicModelMultipleChoiceField(
queryset=CircuitType.objects.all(),

View File

@@ -58,6 +58,9 @@ class ProviderTable(BaseTable):
verbose_name='Circuits'
)
comments = MarkdownColumn()
contacts = tables.ManyToManyColumn(
linkify_item=True
)
tags = TagColumn(
url_name='circuits:provider_list'
)
@@ -66,7 +69,7 @@ class ProviderTable(BaseTable):
model = Provider
fields = (
'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count',
'comments', 'tags', 'created', 'last_updated',
'comments', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
@@ -142,6 +145,9 @@ class CircuitTable(BaseTable):
)
commit_rate = CommitRateColumn()
comments = MarkdownColumn()
contacts = tables.ManyToManyColumn(
linkify_item=True
)
tags = TagColumn(
url_name='circuits:circuit_list'
)
@@ -150,7 +156,7 @@ class CircuitTable(BaseTable):
model = Circuit
fields = (
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
'commit_rate', 'description', 'comments', 'tags', 'created', 'last_updated',
'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',

View File

@@ -497,9 +497,9 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
class Meta(DeviceSerializer.Meta):
fields = [
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data',
'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments',
'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@@ -619,8 +619,8 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con
parent = NestedInterfaceSerializer(required=False, allow_null=True)
bridge = NestedInterfaceSerializer(required=False, allow_null=True)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True)
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True)
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField(

View File

@@ -7,8 +7,8 @@ from ipam.models import ASN
from netbox.filtersets import (
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet,
)
from tenancy.filtersets import TenancyFilterSet
from tenancy.models import Tenant
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from tenancy.models import *
from utilities.choices import ColorChoices
from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
@@ -62,7 +62,7 @@ __all__ = (
)
class RegionFilterSet(OrganizationalModelFilterSet):
class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=Region.objects.all(),
label='Parent region (ID)',
@@ -80,7 +80,7 @@ class RegionFilterSet(OrganizationalModelFilterSet):
fields = ['id', 'name', 'slug', 'description']
class SiteGroupFilterSet(OrganizationalModelFilterSet):
class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
label='Parent site group (ID)',
@@ -98,7 +98,7 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet):
fields = ['id', 'name', 'slug', 'description']
class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -167,7 +167,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
return queryset.filter(qs_filter)
class LocationFilterSet(TenancyFilterSet, OrganizationalModelFilterSet):
class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region',
@@ -240,7 +240,7 @@ class RackRoleFilterSet(OrganizationalModelFilterSet):
fields = ['id', 'name', 'slug', 'color', 'description']
class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -398,7 +398,7 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
)
class ManufacturerFilterSet(OrganizationalModelFilterSet):
class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
tag = TagFilter()
class Meta:
@@ -608,7 +608,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
fields = ['id', 'name', 'slug', 'napalm_driver', 'description']
class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet):
class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -1289,7 +1289,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
return queryset
class PowerPanelFilterSet(PrimaryModelFilterSet):
class PowerPanelFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',

View File

@@ -5,9 +5,10 @@ from django.utils.translation import gettext as _
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from tenancy.models import *
from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
from ipam.models import ASN
from tenancy.forms import TenancyFilterForm
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from utilities.forms import (
APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect,
StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
@@ -98,8 +99,13 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm):
)
class RegionFilterForm(CustomFieldModelFilterForm):
class RegionFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
model = Region
field_groups = [
['q', 'tag'],
['parent_id'],
['contact', 'contact_role'],
]
parent_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -108,8 +114,13 @@ class RegionFilterForm(CustomFieldModelFilterForm):
tag = TagFilterField(model)
class SiteGroupFilterForm(CustomFieldModelFilterForm):
class SiteGroupFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
model = SiteGroup
field_groups = [
['q', 'tag'],
['parent_id'],
['contact', 'contact_role'],
]
parent_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
@@ -118,13 +129,14 @@ class SiteGroupFilterForm(CustomFieldModelFilterForm):
tag = TagFilterField(model)
class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
model = Site
field_groups = [
['q', 'tag'],
['status', 'region_id', 'group_id'],
['tenant_group_id', 'tenant_id'],
['asn_id']
['asn_id'],
['contact', 'contact_role'],
]
status = forms.MultipleChoiceField(
choices=SiteStatusChoices,
@@ -149,12 +161,13 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
tag = TagFilterField(model)
class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
model = Location
field_groups = [
['q', 'tag'],
['region_id', 'site_group_id', 'site_id', 'parent_id'],
['tenant_group_id', 'tenant_id'],
['contact', 'contact_role'],
]
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -192,7 +205,7 @@ class RackRoleFilterForm(CustomFieldModelFilterForm):
tag = TagFilterField(model)
class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
model = Rack
field_groups = [
['q', 'tag'],
@@ -200,6 +213,7 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
['status', 'role_id'],
['type', 'width', 'serial', 'asset_tag'],
['tenant_group_id', 'tenant_id'],
['contact', 'contact_role']
]
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -303,8 +317,12 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
tag = TagFilterField(model)
class ManufacturerFilterForm(CustomFieldModelFilterForm):
class ManufacturerFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
model = Manufacturer
field_groups = [
['q', 'tag'],
['contact', 'contact_role'],
]
tag = TagFilterField(model)
@@ -390,7 +408,7 @@ class PlatformFilterForm(CustomFieldModelFilterForm):
tag = TagFilterField(model)
class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
model = Device
field_groups = [
['q', 'tag'],
@@ -402,6 +420,7 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
'has_primary_ip', 'virtual_chassis_member', 'console_ports', 'console_server_ports', 'power_ports',
'power_outlets', 'interfaces', 'pass_through_ports', 'local_context_data',
],
['contact', 'contact_role'],
]
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -636,11 +655,12 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
tag = TagFilterField(model)
class PowerPanelFilterForm(CustomFieldModelFilterForm):
class PowerPanelFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
model = PowerPanel
field_groups = (
('q', 'tag'),
('region_id', 'site_group_id', 'site_id', 'location_id')
('region_id', 'site_group_id', 'site_id', 'location_id'),
('contact', 'contact_role')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),

View File

@@ -605,11 +605,6 @@ class DeviceForm(TenancyForm, CustomFieldModelForm):
# can be flipped from one face to another.
self.fields['position'].widget.add_query_param('exclude', self.instance.pk)
# Limit platform by manufacturer
self.fields['platform'].queryset = Platform.objects.filter(
Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer)
)
# Disable rack assignment if this is a child device installed in a parent device
if self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
self.fields['site'].disabled = True

View File

@@ -739,8 +739,8 @@ class Device(PrimaryModel, ConfigContextModel):
if hasattr(self, 'device_type') and self.platform:
if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
raise ValidationError({
'platform': "The assigned platform is limited to {} device types, but this device's type belongs "
"to {}.".format(self.platform.manufacturer, self.device_type.manufacturer)
'platform': f"The assigned platform is limited to {self.platform.manufacturer} device types, but "
f"this device's type belongs to {self.device_type.manufacturer}."
})
# A Device can only be assigned to a Cluster in the same Site (or no Site)

View File

@@ -412,7 +412,7 @@ class Rack(PrimaryModel):
available_units.remove(u)
occupied_unit_count = self.u_height - len(available_units)
percentage = int(float(occupied_unit_count) / self.u_height * 100)
percentage = float(occupied_unit_count) / self.u_height * 100
return percentage

View File

@@ -146,10 +146,10 @@ class RackElevationSVG:
class_='device-image'
)
image.fit(scale='slice')
drawing.add(image)
drawing.add(drawing.text(get_device_name(device), insert=text, stroke='black',
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
drawing.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
link.add(image)
link.add(drawing.text(get_device_name(device), insert=text, stroke='black',
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
@staticmethod
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):

View File

@@ -23,6 +23,12 @@ class CableTable(BaseTable):
orderable=False,
verbose_name='Side A'
)
rack_a = tables.Column(
accessor=Accessor('termination_a__device__rack'),
orderable=False,
linkify=True,
verbose_name='Rack A'
)
termination_a = tables.Column(
accessor=Accessor('termination_a'),
orderable=False,
@@ -35,6 +41,12 @@ class CableTable(BaseTable):
orderable=False,
verbose_name='Side B'
)
rack_b = tables.Column(
accessor=Accessor('termination_b__device__rack'),
orderable=False,
linkify=True,
verbose_name='Rack B'
)
termination_b = tables.Column(
accessor=Accessor('termination_b'),
orderable=False,
@@ -55,7 +67,7 @@ class CableTable(BaseTable):
class Meta(BaseTable.Meta):
model = Cable
fields = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
'pk', 'id', 'label', 'termination_a_parent', 'rack_a', 'termination_a', 'termination_b_parent', 'rack_b', 'termination_b',
'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated',
)
default_columns = (

View File

@@ -194,6 +194,9 @@ class DeviceTable(BaseTable):
vc_priority = tables.Column(
verbose_name='VC Priority'
)
contacts = tables.ManyToManyColumn(
linkify_item=True
)
comments = MarkdownColumn()
tags = TagColumn(
url_name='dcim:device_list'
@@ -204,8 +207,8 @@ class DeviceTable(BaseTable):
fields = (
'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4',
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'created',
'last_updated',
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'contacts', 'tags',
'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
@@ -677,6 +680,15 @@ class DeviceBayTable(DeviceComponentTable):
'args': [Accessor('device_id')],
}
)
device_role = ColoredLabelColumn(
accessor=Accessor('installed_device__device_role'),
verbose_name='Role'
)
device_type = tables.Column(
accessor=Accessor('installed_device__device_type'),
linkify=True,
verbose_name='Type'
)
status = tables.TemplateColumn(
template_code=DEVICEBAY_STATUS,
order_by=Accessor('installed_device__status')
@@ -691,7 +703,7 @@ class DeviceBayTable(DeviceComponentTable):
class Meta(DeviceComponentTable.Meta):
model = DeviceBay
fields = (
'pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags',
'pk', 'id', 'name', 'device', 'label', 'status', 'device_role', 'device_type', 'installed_device', 'description', 'tags',
'created', 'last_updated',
)

View File

@@ -41,6 +41,9 @@ class ManufacturerTable(BaseTable):
verbose_name='Platforms'
)
slug = tables.Column()
contacts = tables.ManyToManyColumn(
linkify_item=True
)
tags = TagColumn(
url_name='dcim:manufacturer_list'
)
@@ -50,7 +53,7 @@ class ManufacturerTable(BaseTable):
model = Manufacturer
fields = (
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
'actions', 'created', 'last_updated',
'contacts', 'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',

View File

@@ -27,13 +27,16 @@ class PowerPanelTable(BaseTable):
url_params={'power_panel_id': 'pk'},
verbose_name='Feeds'
)
contacts = tables.ManyToManyColumn(
linkify_item=True
)
tags = TagColumn(
url_name='dcim:powerpanel_list'
)
class Meta(BaseTable.Meta):
model = PowerPanel
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags', 'created', 'last_updated',)
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')

View File

@@ -75,6 +75,9 @@ class RackTable(BaseTable):
orderable=False,
verbose_name='Power'
)
contacts = tables.ManyToManyColumn(
linkify_item=True
)
tags = TagColumn(
url_name='dcim:rack_list'
)
@@ -92,7 +95,7 @@ class RackTable(BaseTable):
fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag',
'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization',
'get_power_utilization', 'tags', 'created', 'last_updated',
'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',

View File

@@ -29,6 +29,9 @@ class RegionTable(BaseTable):
url_params={'region_id': 'pk'},
verbose_name='Sites'
)
contacts = tables.ManyToManyColumn(
linkify_item=True
)
tags = TagColumn(
url_name='dcim:region_list'
)
@@ -36,7 +39,7 @@ class RegionTable(BaseTable):
class Meta(BaseTable.Meta):
model = Region
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions', 'created', 'last_updated')
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'actions', 'created', 'last_updated')
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
@@ -54,6 +57,9 @@ class SiteGroupTable(BaseTable):
url_params={'group_id': 'pk'},
verbose_name='Sites'
)
contacts = tables.ManyToManyColumn(
linkify_item=True
)
tags = TagColumn(
url_name='dcim:sitegroup_list'
)
@@ -61,7 +67,7 @@ class SiteGroupTable(BaseTable):
class Meta(BaseTable.Meta):
model = SiteGroup
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions', 'created', 'last_updated')
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'actions', 'created', 'last_updated')
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
@@ -92,6 +98,9 @@ class SiteTable(BaseTable):
verbose_name='ASNs'
)
tenant = TenantColumn()
contacts = tables.ManyToManyColumn(
linkify_item=True
)
comments = MarkdownColumn()
tags = TagColumn(
url_name='dcim:site_list'
@@ -102,7 +111,7 @@ class SiteTable(BaseTable):
fields = (
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asns', 'asn_count',
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
'contact_phone', 'contact_email', 'comments', 'tags', 'created', 'last_updated',
'contact_phone', 'contact_email', 'contacts', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')
@@ -130,6 +139,9 @@ class LocationTable(BaseTable):
url_params={'location_id': 'pk'},
verbose_name='Devices'
)
contacts = tables.ManyToManyColumn(
linkify_item=True
)
tags = TagColumn(
url_name='dcim:location_list'
)
@@ -141,7 +153,7 @@ class LocationTable(BaseTable):
class Meta(BaseTable.Meta):
model = Location
fields = (
'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags',
'actions', 'created', 'last_updated',
'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'contacts',
'tags', 'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions')

View File

@@ -328,6 +328,11 @@ class SiteView(generic.ObjectView):
'device_count',
cumulative=True
).restrict(request.user, 'view').filter(site=instance)
nonracked_devices = Device.objects.filter(
site=instance,
position__isnull=True,
parent_bay__isnull=True
).prefetch_related('device_type__manufacturer')
asns = ASN.objects.restrict(request.user, 'view').filter(sites=instance)
asn_count = asns.count()
@@ -338,6 +343,7 @@ class SiteView(generic.ObjectView):
'stats': stats,
'locations': locations,
'asns': asns,
'nonracked_devices': nonracked_devices,
}
@@ -415,11 +421,17 @@ class LocationView(generic.ObjectView):
).filter(pk__in=location_ids).exclude(pk=instance.pk)
child_locations_table = tables.LocationTable(child_locations)
paginate_table(child_locations_table, request)
nonracked_devices = Device.objects.filter(
location=instance,
position__isnull=True,
parent_bay__isnull=True
).prefetch_related('device_type__manufacturer')
return {
'rack_count': rack_count,
'device_count': device_count,
'child_locations_table': child_locations_table,
'nonracked_devices': nonracked_devices,
}
@@ -597,8 +609,8 @@ class RackView(generic.ObjectView):
peer_racks = peer_racks.filter(location=instance.location)
else:
peer_racks = peer_racks.filter(location__isnull=True)
next_rack = peer_racks.filter(name__gt=instance.name).order_by('name').first()
prev_rack = peer_racks.filter(name__lt=instance.name).order_by('-name').first()
next_rack = peer_racks.filter(_name__gt=instance._name).first()
prev_rack = peer_racks.filter(_name__lt=instance._name).reverse().first()
reservations = RackReservation.objects.restrict(request.user, 'view').filter(rack=instance)
power_feeds = PowerFeed.objects.restrict(request.user, 'view').filter(rack=instance).prefetch_related(

View File

@@ -259,6 +259,10 @@ class BaseScript:
Base model for custom scripts. User classes should inherit from this model if they want to extend Script
functionality for use in other subclasses.
"""
# Prevent django from instantiating the class on all accesses
do_not_call_in_templates = True
class Meta:
pass
@@ -280,7 +284,7 @@ class BaseScript:
@classproperty
def name(self):
return getattr(self.Meta, 'name', self.__class__.__name__)
return getattr(self.Meta, 'name', self.__name__)
@classproperty
def full_name(self):

View File

@@ -334,7 +334,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
)
vlan_vid = django_filters.NumberFilter(
field_name='vlan__vid',
label='VLAN number (1-4095)',
label='VLAN number (1-4094)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
queryset=Role.objects.all(),

View File

@@ -375,7 +375,7 @@ class VLANCSVForm(CustomFieldModelCSVForm):
model = VLAN
fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description')
help_texts = {
'vid': 'Numeric VLAN ID (1-4095)',
'vid': 'Numeric VLAN ID (1-4094)',
'name': 'VLAN name',
}

View File

@@ -222,7 +222,7 @@ class PrefixForm(TenancyForm, CustomFieldModelForm):
label='VLAN group',
null_option='None',
query_params={
'site_id': '$site'
'site': '$site'
},
initial_params={
'vlans': '$vlan'

View File

@@ -248,7 +248,7 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
"""
queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix))
child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
utilization = int(float(child_prefixes.size) / self.prefix.size * 100)
utilization = float(child_prefixes.size) / self.prefix.size * 100
return min(utilization, 100)
@@ -548,7 +548,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
vrf=self.vrf
)
child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
utilization = int(float(child_prefixes.size) / self.prefix.size * 100)
utilization = float(child_prefixes.size) / self.prefix.size * 100
else:
# Compile an IPSet to avoid counting duplicate IPs
child_ips = netaddr.IPSet(
@@ -558,7 +558,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
prefix_size = self.prefix.size
if self.prefix.version == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
prefix_size -= 2
utilization = int(float(child_ips.size) / prefix_size * 100)
utilization = float(child_ips.size) / prefix_size * 100
return min(utilization, 100)

View File

@@ -204,11 +204,11 @@ class TestPrefix(TestCase):
IPAddress.objects.bulk_create([
IPAddress(address=IPNetwork(f'10.0.0.{i}/24')) for i in range(1, 33)
])
self.assertEqual(prefix.get_utilization(), 12) # 12.5% utilization
self.assertEqual(prefix.get_utilization(), 32 / 254 * 100) # ~12.5% utilization
# Create a child range with 32 additional IPs
IPRange.objects.create(start_address=IPNetwork('10.0.0.33/24'), end_address=IPNetwork('10.0.0.64/24'))
self.assertEqual(prefix.get_utilization(), 25) # 25% utilization
self.assertEqual(prefix.get_utilization(), 64 / 254 * 100) # ~25% utilization
#
# Uniqueness enforcement tests

View File

@@ -1,4 +1,5 @@
from collections import OrderedDict
from typing import Dict
from circuits.filtersets import CircuitFilterSet, ProviderFilterSet, ProviderNetworkFilterSet
from circuits.models import Circuit, ProviderNetwork, Provider
@@ -26,169 +27,212 @@ from virtualization.models import Cluster, VirtualMachine
from virtualization.tables import ClusterTable, VirtualMachineTable
SEARCH_MAX_RESULTS = 15
SEARCH_TYPES = OrderedDict((
# Circuits
('provider', {
'queryset': Provider.objects.annotate(
count_circuits=count_related(Circuit, 'provider')
),
'filterset': ProviderFilterSet,
'table': ProviderTable,
'url': 'circuits:provider_list',
}),
('circuit', {
'queryset': Circuit.objects.prefetch_related(
'type', 'provider', 'tenant', 'terminations__site'
),
'filterset': CircuitFilterSet,
'table': CircuitTable,
'url': 'circuits:circuit_list',
}),
('providernetwork', {
'queryset': ProviderNetwork.objects.prefetch_related('provider'),
'filterset': ProviderNetworkFilterSet,
'table': ProviderNetworkTable,
'url': 'circuits:providernetwork_list',
}),
# DCIM
('site', {
'queryset': Site.objects.prefetch_related('region', 'tenant'),
'filterset': SiteFilterSet,
'table': SiteTable,
'url': 'dcim:site_list',
}),
('rack', {
'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role'),
'filterset': RackFilterSet,
'table': RackTable,
'url': 'dcim:rack_list',
}),
('rackreservation', {
'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
'filterset': RackReservationFilterSet,
'table': RackReservationTable,
'url': 'dcim:rackreservation_list',
}),
('location', {
'queryset': Location.objects.add_related_count(
Location.objects.add_related_count(
Location.objects.all(),
Device,
'location',
'device_count',
cumulative=True
CIRCUIT_TYPES = OrderedDict(
(
('provider', {
'queryset': Provider.objects.annotate(
count_circuits=count_related(Circuit, 'provider')
),
Rack,
'location',
'rack_count',
cumulative=True
).prefetch_related('site'),
'filterset': LocationFilterSet,
'table': LocationTable,
'url': 'dcim:location_list',
}),
('devicetype', {
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=count_related(Device, 'device_type')
),
'filterset': DeviceTypeFilterSet,
'table': DeviceTypeTable,
'url': 'dcim:devicetype_list',
}),
('device', {
'queryset': Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6',
),
'filterset': DeviceFilterSet,
'table': DeviceTable,
'url': 'dcim:device_list',
}),
('virtualchassis', {
'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
member_count=count_related(Device, 'virtual_chassis')
),
'filterset': VirtualChassisFilterSet,
'table': VirtualChassisTable,
'url': 'dcim:virtualchassis_list',
}),
('cable', {
'queryset': Cable.objects.all(),
'filterset': CableFilterSet,
'table': CableTable,
'url': 'dcim:cable_list',
}),
('powerfeed', {
'queryset': PowerFeed.objects.all(),
'filterset': PowerFeedFilterSet,
'table': PowerFeedTable,
'url': 'dcim:powerfeed_list',
}),
# Virtualization
('cluster', {
'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
device_count=count_related(Device, 'cluster'),
vm_count=count_related(VirtualMachine, 'cluster')
),
'filterset': ClusterFilterSet,
'table': ClusterTable,
'url': 'virtualization:cluster_list',
}),
('virtualmachine', {
'queryset': VirtualMachine.objects.prefetch_related(
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
),
'filterset': VirtualMachineFilterSet,
'table': VirtualMachineTable,
'url': 'virtualization:virtualmachine_list',
}),
# IPAM
('vrf', {
'queryset': VRF.objects.prefetch_related('tenant'),
'filterset': VRFFilterSet,
'table': VRFTable,
'url': 'ipam:vrf_list',
}),
('aggregate', {
'queryset': Aggregate.objects.prefetch_related('rir'),
'filterset': AggregateFilterSet,
'table': AggregateTable,
'url': 'ipam:aggregate_list',
}),
('prefix', {
'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
'filterset': PrefixFilterSet,
'table': PrefixTable,
'url': 'ipam:prefix_list',
}),
('ipaddress', {
'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'),
'filterset': IPAddressFilterSet,
'table': IPAddressTable,
'url': 'ipam:ipaddress_list',
}),
('vlan', {
'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'),
'filterset': VLANFilterSet,
'table': VLANTable,
'url': 'ipam:vlan_list',
}),
('asn', {
'queryset': ASN.objects.prefetch_related('rir', 'tenant'),
'filterset': ASNFilterSet,
'table': ASNTable,
'url': 'ipam:asn_list',
}),
# Tenancy
('tenant', {
'queryset': Tenant.objects.prefetch_related('group'),
'filterset': TenantFilterSet,
'table': TenantTable,
'url': 'tenancy:tenant_list',
}),
('contact', {
'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(assignment_count=count_related(ContactAssignment, 'contact')),
'filterset': ContactFilterSet,
'table': ContactTable,
'url': 'tenancy:contact_list',
}),
))
'filterset': ProviderFilterSet,
'table': ProviderTable,
'url': 'circuits:provider_list',
}),
('circuit', {
'queryset': Circuit.objects.prefetch_related(
'type', 'provider', 'tenant', 'terminations__site'
),
'filterset': CircuitFilterSet,
'table': CircuitTable,
'url': 'circuits:circuit_list',
}),
('providernetwork', {
'queryset': ProviderNetwork.objects.prefetch_related('provider'),
'filterset': ProviderNetworkFilterSet,
'table': ProviderNetworkTable,
'url': 'circuits:providernetwork_list',
}),
)
)
DCIM_TYPES = OrderedDict(
(
('site', {
'queryset': Site.objects.prefetch_related('region', 'tenant'),
'filterset': SiteFilterSet,
'table': SiteTable,
'url': 'dcim:site_list',
}),
('rack', {
'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role'),
'filterset': RackFilterSet,
'table': RackTable,
'url': 'dcim:rack_list',
}),
('rackreservation', {
'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
'filterset': RackReservationFilterSet,
'table': RackReservationTable,
'url': 'dcim:rackreservation_list',
}),
('location', {
'queryset': Location.objects.add_related_count(
Location.objects.add_related_count(
Location.objects.all(),
Device,
'location',
'device_count',
cumulative=True
),
Rack,
'location',
'rack_count',
cumulative=True
).prefetch_related('site'),
'filterset': LocationFilterSet,
'table': LocationTable,
'url': 'dcim:location_list',
}),
('devicetype', {
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=count_related(Device, 'device_type')
),
'filterset': DeviceTypeFilterSet,
'table': DeviceTypeTable,
'url': 'dcim:devicetype_list',
}),
('device', {
'queryset': Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6',
),
'filterset': DeviceFilterSet,
'table': DeviceTable,
'url': 'dcim:device_list',
}),
('virtualchassis', {
'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
member_count=count_related(Device, 'virtual_chassis')
),
'filterset': VirtualChassisFilterSet,
'table': VirtualChassisTable,
'url': 'dcim:virtualchassis_list',
}),
('cable', {
'queryset': Cable.objects.all(),
'filterset': CableFilterSet,
'table': CableTable,
'url': 'dcim:cable_list',
}),
('powerfeed', {
'queryset': PowerFeed.objects.all(),
'filterset': PowerFeedFilterSet,
'table': PowerFeedTable,
'url': 'dcim:powerfeed_list',
}),
)
)
IPAM_TYPES = OrderedDict(
(
('vrf', {
'queryset': VRF.objects.prefetch_related('tenant'),
'filterset': VRFFilterSet,
'table': VRFTable,
'url': 'ipam:vrf_list',
}),
('aggregate', {
'queryset': Aggregate.objects.prefetch_related('rir'),
'filterset': AggregateFilterSet,
'table': AggregateTable,
'url': 'ipam:aggregate_list',
}),
('prefix', {
'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
'filterset': PrefixFilterSet,
'table': PrefixTable,
'url': 'ipam:prefix_list',
}),
('ipaddress', {
'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'),
'filterset': IPAddressFilterSet,
'table': IPAddressTable,
'url': 'ipam:ipaddress_list',
}),
('vlan', {
'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'),
'filterset': VLANFilterSet,
'table': VLANTable,
'url': 'ipam:vlan_list',
}),
('asn', {
'queryset': ASN.objects.prefetch_related('rir', 'tenant'),
'filterset': ASNFilterSet,
'table': ASNTable,
'url': 'ipam:asn_list',
}),
)
)
TENANCY_TYPES = OrderedDict(
(
('tenant', {
'queryset': Tenant.objects.prefetch_related('group'),
'filterset': TenantFilterSet,
'table': TenantTable,
'url': 'tenancy:tenant_list',
}),
('contact', {
'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(
assignment_count=count_related(ContactAssignment, 'contact')),
'filterset': ContactFilterSet,
'table': ContactTable,
'url': 'tenancy:contact_list',
}),
)
)
VIRTUALIZATION_TYPES = OrderedDict(
(
('cluster', {
'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
device_count=count_related(Device, 'cluster'),
vm_count=count_related(VirtualMachine, 'cluster')
),
'filterset': ClusterFilterSet,
'table': ClusterTable,
'url': 'virtualization:cluster_list',
}),
('virtualmachine', {
'queryset': VirtualMachine.objects.prefetch_related(
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
),
'filterset': VirtualMachineFilterSet,
'table': VirtualMachineTable,
'url': 'virtualization:virtualmachine_list',
}),
)
)
SEARCH_TYPE_HIERARCHY = OrderedDict(
(
("Circuits", CIRCUIT_TYPES),
("DCIM", DCIM_TYPES),
("IPAM", IPAM_TYPES),
("Tenancy", TENANCY_TYPES),
("Virtualization", VIRTUALIZATION_TYPES),
)
)
def build_search_types() -> Dict[str, Dict]:
result = dict()
for app_types in SEARCH_TYPE_HIERARCHY.values():
for name, items in app_types.items():
result[name] = items
return result
SEARCH_TYPES = build_search_types()

View File

@@ -1,39 +1,24 @@
from django import forms
from utilities.forms import BootstrapMixin
from netbox.constants import SEARCH_TYPE_HIERARCHY
OBJ_TYPE_CHOICES = (
('', 'All Objects'),
('Circuits', (
('provider', 'Providers'),
('circuit', 'Circuits'),
)),
('DCIM', (
('site', 'Sites'),
('rack', 'Racks'),
('rackreservation', 'Rack reservations'),
('location', 'Locations'),
('devicetype', 'Device Types'),
('device', 'Devices'),
('virtualchassis', 'Virtual chassis'),
('cable', 'Cables'),
('powerfeed', 'Power feeds'),
)),
('IPAM', (
('vrf', 'VRFs'),
('aggregate', 'Aggregates'),
('prefix', 'Prefixes'),
('ipaddress', 'IP Addresses'),
('vlan', 'VLANs'),
)),
('Tenancy', (
('tenant', 'Tenants'),
)),
('Virtualization', (
('cluster', 'Clusters'),
('virtualmachine', 'Virtual Machines'),
)),
)
def build_search_choices():
result = list()
result.append(('', 'All Objects'))
for category, items in SEARCH_TYPE_HIERARCHY.items():
subcategories = list()
for slug, obj in items.items():
name = obj['queryset'].model._meta.verbose_name_plural
name = name[0].upper() + name[1:]
subcategories.append((slug, name))
result.append((category, tuple(subcategories)))
return tuple(result)
OBJ_TYPE_CHOICES = build_search_choices()
def build_options():

View File

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

View File

@@ -212,7 +212,10 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
return template.render_to_response(self.queryset)
except Exception as e:
messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}")
return redirect(request.path)
# Strip the `export` param and redirect user to the filtered objects list
query_params = request.GET.copy()
query_params.pop('export')
return redirect(f'{request.path}?{query_params.urlencode()}')
def get(self, request):
model = self.queryset.model

File diff suppressed because one or more lines are too long

View File

@@ -145,9 +145,9 @@ $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: $navbar-dark-color;
$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-color: $darker;
$navbar-light-toggler-border-color: $gray-700;
$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-toggler-border-color}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/></svg>");
// Dropdowns
$dropdown-color: $body-color;

View File

@@ -33,7 +33,7 @@
</button>
</div>
<div class="d-flex my-1 flex-grow-1 justify-content-center w-100">
{% search_options %}
{% search_options request %}
</div>
</div>
@@ -45,7 +45,7 @@
{# Search bar #}
<div class="col-6 d-flex flex-grow-1 justify-content-center">
{% search_options %}
{% search_options request %}
</div>
{# Proflie/login button #}

View File

@@ -37,9 +37,7 @@
</tr>
<tr>
<th scope="row">Serial Number</th>
<td>
<code id="serial_number"></code>
</td>
<td id="serial_number" class="text-monospace"></td>
</tr>
<tr>
<th scope="row">OS Version</th>

View File

@@ -8,6 +8,22 @@
<a href="{{ termination.device.get_absolute_url }}">{{ termination.device }}</a>
</td>
</tr>
{% if termination.device.site %}
<tr>
<td>Site</td>
<td>
<a href="{{ termination.device.site.get_absolute_url }}">{{ termination.device.site }}</a>
</td>
</tr>
{% endif %}
{% if termination.device.rack %}
<tr>
<td>Rack</td>
<td>
<a href="{{ termination.device.rack.get_absolute_url }}">{{ termination.device.rack }}</a>
</td>
</tr>
{% endif %}
<tr>
<td>Type</td>
<td>

View File

@@ -0,0 +1,62 @@
{% load helpers %}
<div class="card">
<h5 class="card-header">
Non-Racked Devices
</h5>
<div class="card-body">
{% if nonracked_devices %}
<table class="table table-hover">
<tr>
<th>Name</th>
<th>Role</th>
<th>Type</th>
<th colspan="2">Parent Device</th>
</tr>
{% for device in nonracked_devices %}
<tr{% if device.device_type.u_height %} class="warning"{% endif %}>
<td>
<a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a>
</td>
<td>{{ device.device_role }}</td>
<td>{{ device.device_type }}</td>
{% if device.parent_bay %}
<td><a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay.device }}</a></td>
<td>{{ device.parent_bay }}</td>
{% else %}
<td colspan="2" class="text-muted">&mdash;</td>
{% endif %}
</tr>
{% endfor %}
</table>
{% else %}
<div class="text-muted">
None
</div>
{% endif %}
</div>
{% if perms.dcim.add_device %}
{% if object|meta:'verbose_name' == 'rack' %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:device_add' %}?site={{ object.site.pk }}&rack={{ object.pk }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
Add a Non-Racked Device
</a>
</div>
{% elif object|meta:'verbose_name' == 'site' %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:device_add' %}?site={{ object.pk }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
Add a Non-Racked Device
</a>
</div>
{% elif object|meta:'verbose_name' == 'location' %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:device_add' %}?site={{ object.site.pk }}&location={{ object.pk }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
Add a Non-Racked Device
</a>
</div>
{% endif %}
{% endif %}
</div>

View File

@@ -90,6 +90,7 @@
<div class="col col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %}
{% include 'dcim/inc/nonracked_devices.html' %}
{% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %}
</div>

View File

@@ -288,50 +288,7 @@
</div>
</div>
</div>
<div class="card">
<h5 class="card-header">
Non-Racked Devices
</h5>
<div class="card-body">
{% if nonracked_devices %}
<table class="table table-hover">
<tr>
<th>Name</th>
<th>Role</th>
<th>Type</th>
<th colspan="2">Parent Device</th>
</tr>
{% for device in nonracked_devices %}
<tr{% if device.device_type.u_height %} class="warning"{% endif %}>
<td>
<a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a>
</td>
<td>{{ device.device_role }}</td>
<td>{{ device.device_type }}</td>
{% if device.parent_bay %}
<td><a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay.device }}</a></td>
<td>{{ device.parent_bay }}</td>
{% else %}
<td colspan="2" class="text-muted">&mdash;</td>
{% endif %}
</tr>
{% endfor %}
</table>
{% else %}
<div class="text-muted">
None
</div>
{% endif %}
</div>
{% if perms.dcim.add_device %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:device_add' %}?site={{ object.site.pk }}&rack={{ object.pk }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
Add a Non-Racked Device
</a>
</div>
{% endif %}
</div>
{% include 'dcim/inc/nonracked_devices.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %}
</div>

View File

@@ -277,6 +277,7 @@
</table>
</div>
</div>
{% include 'dcim/inc/nonracked_devices.html' %}
{% include 'inc/panels/contacts.html' %}
<div class="card">
<h5 class="card-header">Locations</h5>

View File

@@ -34,7 +34,7 @@
{% for class_name, script in module_scripts.items %}
<tr>
<td>
<a href="{% url 'extras:script' module=script.module name=class_name %}" name="script.{{ class_name }}">{{ script }}</a>
<a href="{% url 'extras:script' module=script.module name=class_name %}" name="script.{{ class_name }}">{{ script.name }}</a>
</td>
<td>
{% include 'extras/inc/job_label.html' with result=script.result %}

View File

@@ -11,6 +11,7 @@ __all__ = (
'ContactAssignmentFilterSet',
'ContactFilterSet',
'ContactGroupFilterSet',
'ContactModelFilterSet',
'ContactRoleFilterSet',
'TenancyFilterSet',
'TenantFilterSet',
@@ -18,92 +19,6 @@ __all__ = (
)
#
# Tenancy
#
class TenantGroupFilterSet(OrganizationalModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=TenantGroup.objects.all(),
label='Tenant group (ID)',
)
parent = django_filters.ModelMultipleChoiceFilter(
field_name='parent__slug',
queryset=TenantGroup.objects.all(),
to_field_name='slug',
label='Tenant group (slug)',
)
tag = TagFilter()
class Meta:
model = TenantGroup
fields = ['id', 'name', 'slug', 'description']
class TenantFilterSet(PrimaryModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
group_id = TreeNodeMultipleChoiceFilter(
queryset=TenantGroup.objects.all(),
field_name='group',
lookup_expr='in',
label='Tenant group (ID)',
)
group = TreeNodeMultipleChoiceFilter(
queryset=TenantGroup.objects.all(),
field_name='group',
lookup_expr='in',
to_field_name='slug',
label='Tenant group (slug)',
)
tag = TagFilter()
class Meta:
model = Tenant
fields = ['id', 'name', 'slug', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(slug__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
class TenancyFilterSet(django_filters.FilterSet):
"""
An inheritable FilterSet for models which support Tenant assignment.
"""
tenant_group_id = TreeNodeMultipleChoiceFilter(
queryset=TenantGroup.objects.all(),
field_name='tenant__group',
lookup_expr='in',
label='Tenant Group (ID)',
)
tenant_group = TreeNodeMultipleChoiceFilter(
queryset=TenantGroup.objects.all(),
field_name='tenant__group',
to_field_name='slug',
lookup_expr='in',
label='Tenant Group (slug)',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
field_name='tenant__slug',
to_field_name='slug',
label='Tenant (slug)',
)
#
# Contacts
#
@@ -191,3 +106,102 @@ class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet):
class Meta:
model = ContactAssignment
fields = ['id', 'content_type_id', 'object_id', 'priority']
class ContactModelFilterSet(django_filters.FilterSet):
contact = django_filters.ModelMultipleChoiceFilter(
field_name='contacts__contact',
queryset=Contact.objects.all(),
label='Contact',
)
contact_role = django_filters.ModelMultipleChoiceFilter(
field_name='contacts__role',
queryset=ContactRole.objects.all(),
label='Contact Role'
)
#
# Tenancy
#
class TenantGroupFilterSet(OrganizationalModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=TenantGroup.objects.all(),
label='Tenant group (ID)',
)
parent = django_filters.ModelMultipleChoiceFilter(
field_name='parent__slug',
queryset=TenantGroup.objects.all(),
to_field_name='slug',
label='Tenant group (slug)',
)
tag = TagFilter()
class Meta:
model = TenantGroup
fields = ['id', 'name', 'slug', 'description']
class TenantFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
group_id = TreeNodeMultipleChoiceFilter(
queryset=TenantGroup.objects.all(),
field_name='group',
lookup_expr='in',
label='Tenant group (ID)',
)
group = TreeNodeMultipleChoiceFilter(
queryset=TenantGroup.objects.all(),
field_name='group',
lookup_expr='in',
to_field_name='slug',
label='Tenant group (slug)',
)
tag = TagFilter()
class Meta:
model = Tenant
fields = ['id', 'name', 'slug', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(slug__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
class TenancyFilterSet(django_filters.FilterSet):
"""
An inheritable FilterSet for models which support Tenant assignment.
"""
tenant_group_id = TreeNodeMultipleChoiceFilter(
queryset=TenantGroup.objects.all(),
field_name='tenant__group',
lookup_expr='in',
label='Tenant Group (ID)',
)
tenant_group = TreeNodeMultipleChoiceFilter(
queryset=TenantGroup.objects.all(),
field_name='tenant__group',
to_field_name='slug',
lookup_expr='in',
label='Tenant Group (slug)',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
field_name='tenant__slug',
to_field_name='slug',
label='Tenant (slug)',
)

View File

@@ -2,6 +2,7 @@ from django.utils.translation import gettext as _
from extras.forms import CustomFieldModelFilterForm
from tenancy.models import *
from tenancy.forms import ContactModelFilterForm
from utilities.forms import DynamicModelMultipleChoiceField, TagFilterField
__all__ = (
@@ -27,11 +28,12 @@ class TenantGroupFilterForm(CustomFieldModelFilterForm):
tag = TagFilterField(model)
class TenantFilterForm(CustomFieldModelFilterForm):
class TenantFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
model = Tenant
field_groups = (
('q', 'tag'),
('group_id',),
('contact', 'contact_role')
)
group_id = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),

View File

@@ -1,10 +1,11 @@
from django import forms
from django.utils.translation import gettext as _
from tenancy.models import Tenant, TenantGroup
from tenancy.models import *
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
__all__ = (
'ContactModelFilterForm',
'TenancyForm',
'TenancyFilterForm',
)
@@ -44,3 +45,16 @@ class TenancyFilterForm(forms.Form):
},
label=_('Tenant')
)
class ContactModelFilterForm(forms.Form):
contact = DynamicModelMultipleChoiceField(
queryset=Contact.objects.all(),
required=False,
label=_('Contact')
)
contact_role = DynamicModelMultipleChoiceField(
queryset=ContactRole.objects.all(),
required=False,
label=_('Contact Role')
)

View File

@@ -166,3 +166,6 @@ class ContactAssignment(ChangeLoggedModel):
if self.priority:
return f"{self.contact} ({self.get_priority_display()})"
return str(self.contact)
def get_absolute_url(self):
return reverse('tenancy:contact', args=[self.contact.pk])

View File

@@ -77,6 +77,9 @@ class TenantTable(BaseTable):
group = tables.Column(
linkify=True
)
contacts = tables.ManyToManyColumn(
linkify_item=True
)
comments = MarkdownColumn()
tags = TagColumn(
url_name='tenancy:tenant_list'
@@ -84,7 +87,7 @@ class TenantTable(BaseTable):
class Meta(BaseTable.Meta):
model = Tenant
fields = ('pk', 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'created', 'last_updated',)
fields = ('pk', 'id', 'name', 'slug', 'group', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'group', 'description')

View File

@@ -430,6 +430,7 @@ class TagColumn(tables.TemplateColumn):
def __init__(self, url_name=None):
super().__init__(
orderable=False,
template_code=self.template_code,
extra_context={'url_name': url_name}
)

View File

@@ -12,10 +12,10 @@
class="progress-bar {{ bar_class }}"
style="width: {{ utilization }}%;"
>
{% if utilization >= 25 %}{{ utilization }}%{% endif %}
{% if utilization >= 25 %}{{ utilization|floatformat:0 }}%{% endif %}
</div>
{% if utilization < 25 %}
<span class="ps-1">{{ utilization }}%</span>
<span class="ps-1">{{ utilization|floatformat:0 }}%</span>
{% endif %}
</div>
{% endif %}

View File

@@ -5,7 +5,7 @@
aria-label="Search"
placeholder="Search"
class="form-control"
value="{{ request.GET.q }}"
value="{{ request.GET.q|escape }}"
/>
<input name="obj_type" hidden type="text" class="search-obj-type" />

View File

@@ -389,7 +389,9 @@ def utilization_graph(utilization, warning_threshold=75, danger_threshold=90):
"""
Display a horizontal bar graph indicating a percentage of utilization.
"""
if danger_threshold and utilization >= danger_threshold:
if utilization == 100:
bar_class = 'bg-secondary'
elif danger_threshold and utilization >= danger_threshold:
bar_class = 'bg-danger'
elif warning_threshold and utilization >= warning_threshold:
bar_class = 'bg-warning'

View File

@@ -8,6 +8,9 @@ search_form = SearchForm()
@register.inclusion_tag("search/searchbar.html")
def search_options() -> Dict:
def search_options(request) -> Dict:
"""Provide search options to template."""
return {"options": search_form.options}
return {
'options': search_form.options,
'request': request,
}

View File

@@ -5,7 +5,7 @@ from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
from extras.filters import TagFilter
from extras.filtersets import LocalConfigContextFilterSet
from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet
from tenancy.filtersets import TenancyFilterSet
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from utilities.filters import MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
from .choices import *
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -27,7 +27,7 @@ class ClusterTypeFilterSet(OrganizationalModelFilterSet):
fields = ['id', 'name', 'slug', 'description']
class ClusterGroupFilterSet(OrganizationalModelFilterSet):
class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
tag = TagFilter()
class Meta:
@@ -35,7 +35,7 @@ class ClusterGroupFilterSet(OrganizationalModelFilterSet):
fields = ['id', 'name', 'slug', 'description']
class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -111,7 +111,7 @@ class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
)
class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet):
class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',

View File

@@ -3,7 +3,7 @@ from django.utils.translation import gettext as _
from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
from tenancy.forms import TenancyFilterForm
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
from utilities.forms import (
DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
)
@@ -24,18 +24,19 @@ class ClusterTypeFilterForm(CustomFieldModelFilterForm):
tag = TagFilterField(model)
class ClusterGroupFilterForm(CustomFieldModelFilterForm):
class ClusterGroupFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
model = ClusterGroup
tag = TagFilterField(model)
class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
model = Cluster
field_groups = [
['q', 'tag'],
['group_id', 'type_id'],
['region_id', 'site_group_id', 'site_id'],
['tenant_group_id', 'tenant_id'],
['contact', 'contact_role'],
]
type_id = DynamicModelMultipleChoiceField(
queryset=ClusterType.objects.all(),
@@ -71,7 +72,7 @@ class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
tag = TagFilterField(model)
class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
model = VirtualMachine
field_groups = [
['q', 'tag'],
@@ -79,6 +80,7 @@ class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm,
['region_id', 'site_group_id', 'site_id'],
['status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data'],
['tenant_group_id', 'tenant_id'],
['contact', 'contact_role'],
]
cluster_group_id = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),

View File

@@ -62,6 +62,9 @@ class ClusterGroupTable(BaseTable):
cluster_count = tables.Column(
verbose_name='Clusters'
)
contacts = tables.ManyToManyColumn(
linkify_item=True
)
tags = TagColumn(
url_name='virtualization:clustergroup_list'
)
@@ -70,7 +73,7 @@ class ClusterGroupTable(BaseTable):
class Meta(BaseTable.Meta):
model = ClusterGroup
fields = (
'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions', 'created', 'last_updated',
'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'contacts', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions')
@@ -106,6 +109,9 @@ class ClusterTable(BaseTable):
url_params={'cluster_id': 'pk'},
verbose_name='VMs'
)
contacts = tables.ManyToManyColumn(
linkify_item=True
)
comments = MarkdownColumn()
tags = TagColumn(
url_name='virtualization:cluster_list'
@@ -114,7 +120,7 @@ class ClusterTable(BaseTable):
class Meta(BaseTable.Meta):
model = Cluster
fields = (
'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'tags',
'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'contacts', 'tags',
'created', 'last_updated',
)
default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count')
@@ -150,6 +156,9 @@ class VirtualMachineTable(BaseTable):
order_by=('primary_ip4', 'primary_ip6'),
verbose_name='IP Address'
)
contacts = tables.ManyToManyColumn(
linkify_item=True
)
tags = TagColumn(
url_name='virtualization:virtualmachine_list'
)
@@ -158,7 +167,7 @@ class VirtualMachineTable(BaseTable):
model = VirtualMachine
fields = (
'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated',
'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',

View File

@@ -18,14 +18,14 @@ gunicorn==20.1.0
Jinja2==3.0.3
Markdown==3.3.6
markdown-include==0.6.0
mkdocs-material==8.2.5
mkdocs-material==8.2.7
netaddr==0.8.0
Pillow==9.0.1
psycopg2-binary==2.9.3
PyYAML==6.0
social-auth-app-django==5.0.0
social-auth-core==4.2.0
svgwrite==1.4.1
svgwrite==1.4.2
tablib==3.2.0
tzdata==2021.5