Compare commits

...

35 Commits

Author SHA1 Message Date
Jeremy Stretch
04137e887e Merge pull request #11376 from netbox-community/develop
Release v3.4.2
2023-01-03 16:28:05 -05:00
jeremystretch
e940f00c01 Release v3.4.2 2023-01-03 16:13:11 -05:00
jeremystretch
1c72a80d9a Changelog for #11156, #11259, #11342, #11345 2023-01-03 10:21:19 -05:00
kkthxbye
b9f8370097 Fixes #11156 - Allow InventoryItem component reassignment (#11256)
* Allow re-assigning InventoryItem components

* Refactor logic for finding initial component assignment on InventoryItems

* PEP8 fix

* Fix wrong HTML causing tab list to extend past the end of the parent row

* Tweak form field labels

Co-authored-by: jeremystretch <jstretch@ns1.com>
2023-01-03 10:13:34 -05:00
Christian Harendt
1c636ea127 add username for redis authentication 2023-01-03 09:42:18 -05:00
kkthxbye
e1169e7ea6 Fixes #11345 - Fix module validation (#11346)
* Make sure we bail out if field validation failed when importing modules

* Tweak form validation logic

Co-authored-by: jeremystretch <jstretch@ns1.com>
2023-01-03 09:14:25 -05:00
kkthxbye-code
5975dbcb07 Fix component traces all pointing to the interface trace URL 2023-01-03 08:25:28 -05:00
Arthur Hanson
08a419ec7a 11271 flag to disable localization (#11323)
* 11271 flag to disable localization

* 11271 change to remove middleware

* 11271 update docs for new var

* Update docs/configuration/system.md

Co-authored-by: kkthxbye <400797+kkthxbye-code@users.noreply.github.com>

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
Co-authored-by: kkthxbye <400797+kkthxbye-code@users.noreply.github.com>
2022-12-29 09:04:35 -05:00
jeremystretch
d417168805 Changelog for #11223, #11244, #11248 2022-12-28 16:58:04 -05:00
Mario
ccb2966c4c Fixes #11244: Elevations: Filter badge missing (#11321)
* Added filter badge in rack elevation

* Tweak template context

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2022-12-28 16:54:33 -05:00
Alef Burzmali
b7cdbd3d41 Fixes #11248 - Reindex only NetBox apps 2022-12-28 16:35:05 -05:00
Alef Burzmali
ae440c9edf Fixes #11223 - Accept app_label for reindex 2022-12-28 16:35:05 -05:00
jeremystretch
b6cd099117 Changelog for #11121, #11280 2022-12-27 16:07:02 -05:00
Arthur
98f57f2dba 11297 have custom field form display content-type instead of model 2022-12-27 15:50:08 -05:00
kkthxbye-code
735fa4aa31 Add summed resource card to cluster view 2022-12-27 10:24:46 -05:00
kkthxbye
c7108bb3f7 Fixes #11280 - Fix exporting interfaces and FHRP group rows with multiple IP's assigned (#11285)
undefined
2022-12-27 10:15:28 -05:00
jeremystretch
98b3fc03b8 Changelog for #9285, #10700, #11290 2022-12-22 10:14:38 -05:00
kkthxbye-code
92da2fe082 Add device name as part of module search for the q filter 2022-12-22 10:09:53 -05:00
kkthxbye-code
bfab3a26bc Add component import to InventoryItem bulk import 2022-12-22 09:59:50 -05:00
kkthxbye-code
b35b33e798 Use the start time to calculate duration of jobs instead of created time 2022-12-22 09:52:05 -05:00
jeremystretch
db5c2a379e Fixes #11232: Enable partial & regex matching for non-string types in global search 2022-12-22 09:14:57 -05:00
jeremystretch
3675ad2539 PRVB 2022-12-16 17:18:06 -05:00
Jeremy Stretch
27c71b8ec0 Merge pull request #11219 from netbox-community/develop
Release v3.4.1
2022-12-16 17:16:07 -05:00
jeremystretch
0058c7749c Release v3.4.1 2022-12-16 17:03:40 -05:00
jeremystretch
f882dcabf7 Fixes #11184: Correct visualization of cable path which splits across multiple circuit terminations 2022-12-16 16:45:51 -05:00
Arthur Hanson
c8f4a7c742 11206 dont remove user groups if no valid REMOTE_AUTH_DEFAULT_GROUPS (#11207)
* 11206 dont remove user groups if no valid REMOTE_AUTH_DEFAULT_GROUPS

* 11206 dont remove user groups if no valid REMOTE_AUTH_DEFAULT_GROUPS
2022-12-16 08:59:24 -05:00
jeremystretch
ed366c5ab2 Closes #11214: Introduce the DEFAULT_LANGUAGE configuration parameter 2022-12-16 08:56:14 -05:00
jeremystretch
2738da2d39 Fixes #11189: Fix localization of dates & numbers 2022-12-16 08:43:05 -05:00
jeremystretch
9f15ca2d90 Closes #9971: Enable ordering of nested group models by name 2022-12-15 16:21:30 -05:00
jeremystretch
e4f5407c70 Changelog for #11175, #11178 2022-12-15 16:05:43 -05:00
jeremystretch
951f82b428 Fixes #11205: Correct cloning behavior for recursively-nested models 2022-12-15 16:04:29 -05:00
Arthur Hanson
f8685ad7aa 11175 fix cloning special chars in fields (#11181)
* 11175 fix cloning special chars in fields

* 11175 fix cloning special chars in fields
2022-12-15 13:07:55 -05:00
Arthur
c59d527664 11178 fix quick search press enter button 2022-12-15 13:01:21 -05:00
jeremystretch
77423e7bb1 Fixes #11185: Fix TemplateSyntaxError when viewing custom script results 2022-12-15 12:55:09 -05:00
jeremystretch
ba12675267 PRVB 2022-12-14 14:24:46 -05:00
41 changed files with 459 additions and 74 deletions

View File

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

View File

@@ -63,6 +63,7 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes
* `HOST` - Name or IP address of the Redis server (use `localhost` if running locally)
* `PORT` - TCP port of the Redis service; leave blank for default port (6379)
* `USERNAME` - Redis username (if set)
* `PASSWORD` - Redis password (if set)
* `DATABASE` - Numeric database ID
* `SSL` - Use SSL connection to Redis
@@ -75,6 +76,7 @@ REDIS = {
'tasks': {
'HOST': 'redis.example.com',
'PORT': 1234,
'USERNAME': 'netbox'
'PASSWORD': 'foobar',
'DATABASE': 0,
'SSL': False,
@@ -82,6 +84,7 @@ REDIS = {
'caching': {
'HOST': 'localhost',
'PORT': 6379,
'USERNAME': ''
'PASSWORD': '',
'DATABASE': 1,
'SSL': False,

View File

@@ -12,6 +12,17 @@ BASE_PATH = 'netbox/'
---
## DEFAULT_LANGUAGE
Default: `en-us` (US English)
Defines the default preferred language/locale for requests that do not specify one. This is used to alter e.g. the display of dates and numbers to fit the user's locale. See [this list](http://www.i18nguy.com/unicode/language-identifiers.html) of standard language codes. (This parameter maps to Django's [`LANGUAGE_CODE`](https://docs.djangoproject.com/en/stable/ref/settings/#language-code) internal setting.)
!!! note
Altering this parameter will *not* change the language used in NetBox. We hope to provide translation support in a future NetBox release.
---
## DOCS_ROOT
Default: `$INSTALL_ROOT/docs/`
@@ -54,6 +65,14 @@ Email is sent from NetBox only for critical events or if configured for [logging
---
## ENABLE_LOCALIZATION
Default: False
Determines if localization features are enabled or not. This should only be enabled for development or testing purposes as netbox is not yet fully localized. Turning this on will localize numeric and date formats (overriding what is set for DATE_FORMAT) based on the browser locale as well as translate certain strings from third party modules.
---
## HTTP_PROXIES
Default: None

View File

@@ -1,5 +1,47 @@
# NetBox v3.4
## v3.4.2 (2023-01-03)
### Enhancements
* [#9285](https://github.com/netbox-community/netbox/issues/9285) - Enable specifying assigned component during bulk import of inventory items
* [#10700](https://github.com/netbox-community/netbox/issues/10700) - Match device name when using modules quick search
* [#11121](https://github.com/netbox-community/netbox/issues/11121) - Add VM resource totals to cluster view
* [#11156](https://github.com/netbox-community/netbox/issues/11156) - Enable selecting assigned component when editing inventory item in UI
* [#11223](https://github.com/netbox-community/netbox/issues/11223) - `reindex` management command should accept app label without model name
* [#11244](https://github.com/netbox-community/netbox/issues/11244) - Add controls for saved filters to rack elevations list
* [#11248](https://github.com/netbox-community/netbox/issues/11248) - Fix database migration when plugin with search indexer is enabled
* [#11259](https://github.com/netbox-community/netbox/issues/11259) - Add support for Redis username configuration
### Bug Fixes
* [#11280](https://github.com/netbox-community/netbox/issues/11280) - Fix errant newlines when exporting interfaces with multiple IP addresses assigned
* [#11290](https://github.com/netbox-community/netbox/issues/11290) - Correct reporting of scheduled job duration
* [#11232](https://github.com/netbox-community/netbox/issues/11232) - Enable partial & regular expression matching for non-string types in global search
* [#11342](https://github.com/netbox-community/netbox/issues/11342) - Correct cable trace URL under "connection" tab for device components
* [#11345](https://github.com/netbox-community/netbox/issues/11345) - Fix form validation for bulk import of modules
---
## v3.4.1 (2022-12-16)
### Enhancements
* [#9971](https://github.com/netbox-community/netbox/issues/9971) - Enable ordering of nested group models by name
* [#11214](https://github.com/netbox-community/netbox/issues/11214) - Introduce the `DEFAULT_LANGUAGE` configuration parameter
### Bug Fixes
* [#11175](https://github.com/netbox-community/netbox/issues/11175) - Fix cloning of fields containing special characters
* [#11178](https://github.com/netbox-community/netbox/issues/11178) - Pressing enter in quick search box should not trigger bulk operations
* [#11184](https://github.com/netbox-community/netbox/issues/11184) - Correct visualization of cable path which splits across multiple circuit terminations
* [#11185](https://github.com/netbox-community/netbox/issues/11185) - Fix TemplateSyntaxError when viewing custom script results
* [#11189](https://github.com/netbox-community/netbox/issues/11189) - Fix localization of dates & numbers
* [#11205](https://github.com/netbox-community/netbox/issues/11205) - Correct cloning behavior for recursively-nested models
* [#11206](https://github.com/netbox-community/netbox/issues/11206) - Avoid clearing assigned groups if `REMOTE_AUTH_DEFAULT_GROUPS` is invalid
---
## v3.4.0 (2022-12-14)
!!! warning "PostgreSQL 11 Required"

View File

@@ -1098,6 +1098,7 @@ class ModuleFilterSet(NetBoxModelFilterSet):
if not value.strip():
return queryset
return queryset.filter(
Q(device__name__icontains=value.strip()) |
Q(serial__icontains=value.strip()) |
Q(asset_tag__icontains=value.strip()) |
Q(comments__icontains=value)

View File

@@ -885,12 +885,22 @@ class InventoryItemImportForm(NetBoxModelImportForm):
required=False,
help_text=_('Parent inventory item')
)
component_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=MODULAR_COMPONENT_MODELS,
required=False,
help_text=_('Component Type')
)
component_name = forms.CharField(
required=False,
help_text=_('Component Name')
)
class Meta:
model = InventoryItem
fields = (
'device', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
'description', 'tags'
'description', 'tags', 'component_type', 'component_name',
)
def __init__(self, *args, **kwargs):
@@ -908,6 +918,24 @@ class InventoryItemImportForm(NetBoxModelImportForm):
else:
self.fields['parent'].queryset = InventoryItem.objects.none()
def clean_component_name(self):
content_type = self.cleaned_data.get('component_type')
component_name = self.cleaned_data.get('component_name')
device = self.cleaned_data.get("device")
if not device and hasattr(self, 'instance'):
device = self.instance.device
if not all([device, content_type, component_name]):
return None
model = content_type.model_class()
try:
component = model.objects.get(device=device, name=component_name)
self.instance.component = component
except ObjectDoesNotExist:
raise forms.ValidationError(f"Component not found: {device} - {component_name}")
#
# Device component roles

View File

@@ -56,8 +56,8 @@ class ModuleCommonForm(forms.Form):
def clean(self):
super().clean()
replicate_components = self.cleaned_data.get("replicate_components")
adopt_components = self.cleaned_data.get("adopt_components")
replicate_components = self.cleaned_data.get('replicate_components')
adopt_components = self.cleaned_data.get('adopt_components')
device = self.cleaned_data.get('device')
module_type = self.cleaned_data.get('module_type')
module_bay = self.cleaned_data.get('module_bay')
@@ -65,8 +65,9 @@ class ModuleCommonForm(forms.Form):
if adopt_components:
self.instance._adopt_components = True
# Bail out if we are not installing a new module or if we are not replicating components
if self.instance.pk or not replicate_components:
# Bail out if we are not installing a new module or if we are not replicating components (or if
# validation has already failed)
if self.errors or self.instance.pk or not replicate_components:
self.instance._disable_replication = True
return

View File

@@ -1549,15 +1549,63 @@ class InventoryItemForm(DeviceComponentForm):
queryset=Manufacturer.objects.all(),
required=False
)
component_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=MODULAR_COMPONENT_MODELS,
# Assigned component selectors
consoleport = DynamicModelChoiceField(
queryset=ConsolePort.objects.all(),
required=False,
widget=forms.HiddenInput
query_params={
'device_id': '$device'
},
label=_('Console port')
)
component_id = forms.IntegerField(
consoleserverport = DynamicModelChoiceField(
queryset=ConsoleServerPort.objects.all(),
required=False,
widget=forms.HiddenInput
query_params={
'device_id': '$device'
},
label=_('Console server port')
)
frontport = DynamicModelChoiceField(
queryset=FrontPort.objects.all(),
required=False,
query_params={
'device_id': '$device'
},
label=_('Front port')
)
interface = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
query_params={
'device_id': '$device'
},
label=_('Interface')
)
poweroutlet = DynamicModelChoiceField(
queryset=PowerOutlet.objects.all(),
required=False,
query_params={
'device_id': '$device'
},
label=_('Power outlet')
)
powerport = DynamicModelChoiceField(
queryset=PowerPort.objects.all(),
required=False,
query_params={
'device_id': '$device'
},
label=_('Power port')
)
rearport = DynamicModelChoiceField(
queryset=RearPort.objects.all(),
required=False,
query_params={
'device_id': '$device'
},
label=_('Rear port')
)
fieldsets = (
@@ -1565,22 +1613,61 @@ class InventoryItemForm(DeviceComponentForm):
('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')),
)
class Meta:
model = InventoryItem
fields = [
'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
'description', 'tags',
]
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {}).copy()
component_type = initial.get('component_type')
component_id = initial.get('component_id')
# Used for picking the default active tab for component selection
self.no_component = True
if instance:
# When editing set the initial value for component selectin
for component_model in ContentType.objects.filter(MODULAR_COMPONENT_MODELS):
if type(instance.component) is component_model.model_class():
initial[component_model.model] = instance.component
self.no_component = False
break
elif component_type and component_id:
# When adding the InventoryItem from a component page
if content_type := ContentType.objects.filter(MODULAR_COMPONENT_MODELS).filter(pk=component_type).first():
if component := content_type.model_class().objects.filter(pk=component_id).first():
initial[content_type.model] = component
self.no_component = False
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
# Specifically allow editing the device of IntentoryItems
if self.instance.pk:
self.fields['device'].disabled = False
class Meta:
model = InventoryItem
fields = [
'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
'description', 'component_type', 'component_id', 'tags',
def clean(self):
super().clean()
# Handle object assignment
selected_objects = [
field for field in (
'consoleport', 'consoleserverport', 'frontport', 'interface', 'poweroutlet', 'powerport', 'rearport'
) if self.cleaned_data[field]
]
if len(selected_objects) > 1:
raise forms.ValidationError("An InventoryItem can only be assigned to a single component.")
elif selected_objects:
self.instance.component = self.cleaned_data[selected_objects[0]]
else:
self.instance.component = None
#
# Device component roles
#

View File

@@ -567,11 +567,12 @@ class CablePath(models.Model):
elif isinstance(remote_terminations[0], CircuitTermination):
# Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
term_side = remote_terminations[0].term_side
assert all(ct.term_side == term_side for ct in remote_terminations[1:])
if len(remote_terminations) > 1:
is_split = True
break
circuit_termination = CircuitTermination.objects.filter(
circuit=remote_terminations[0].circuit,
term_side='Z' if term_side == 'A' else 'A'
term_side='Z' if remote_terminations[0].term_side == 'A' else 'A'
).first()
if circuit_termination is None:
break
@@ -685,6 +686,7 @@ class CablePath(models.Model):
"""
Return all available next segments in a split cable path.
"""
from circuits.models import CircuitTermination
nodes = self.path_objects[-1]
# RearPort splitting to multiple FrontPorts with no stack position
@@ -694,3 +696,8 @@ class CablePath(models.Model):
# RearPorts connected to different cables
elif type(nodes[0]) is FrontPort:
return RearPort.objects.filter(pk__in=[fp.rear_port_id for fp in nodes])
# Cable terminating to multiple CircuitTerminations
elif type(nodes[0]) is CircuitTermination:
return [
ct.get_peer_termination() for ct in nodes
]

View File

@@ -1146,3 +1146,8 @@ class InventoryItem(MPTTModel, ComponentModel):
# When moving an InventoryItem to another device, remove any associated component
if self.component and self.component.device != self.device:
self.component = None
else:
if self.component and self.component.device != self.device:
raise ValidationError({
"device": "Cannot assign inventory item to component on another device"
})

View File

@@ -961,7 +961,7 @@ class Module(PrimaryModel, ConfigContextModel):
def clean(self):
super().clean()
if self.module_bay.device != self.device:
if hasattr(self, "module_bay") and (self.module_bay.device != self.device):
raise ValidationError(
f"Module must be installed within a module bay belonging to the assigned device ({self.device})."
)

View File

@@ -506,6 +506,9 @@ class BaseInterfaceTable(NetBoxTable):
verbose_name='Tagged VLANs'
)
def value_ip_addresses(self, value):
return ",".join([str(obj.address) for obj in value.all()])
class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpointTable):
device = tables.Column(

View File

@@ -691,6 +691,7 @@ class RackElevationListView(generic.ObjectListView):
'sort_choices': ORDERING_CHOICES,
'rack_face': rack_face,
'filter_form': forms.RackElevationFilterForm(request.GET),
'model': self.queryset.model,
})
@@ -2913,23 +2914,14 @@ class InventoryItemView(generic.ObjectView):
class InventoryItemEditView(generic.ObjectEditView):
queryset = InventoryItem.objects.all()
form = forms.InventoryItemForm
template_name = 'dcim/inventoryitem_edit.html'
class InventoryItemCreateView(generic.ComponentCreateView):
queryset = InventoryItem.objects.all()
form = forms.InventoryItemCreateForm
model_form = forms.InventoryItemForm
def alter_object(self, instance, request):
# Set component (if any)
component_type = request.GET.get('component_type')
component_id = request.GET.get('component_id')
if component_type and component_id:
content_type = get_object_or_404(ContentType, pk=component_type)
instance.component = get_object_or_404(content_type.model_class(), pk=component_id)
return instance
template_name = 'dcim/inventoryitem_edit.html'
@register_model_view(InventoryItem, 'delete')

View File

@@ -32,7 +32,6 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
label=_('Model(s)')
)
object_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),

View File

@@ -27,17 +27,28 @@ class Command(BaseCommand):
# Return only indexers for the specified models
else:
for label in model_names:
try:
app_label, model_name = label.lower().split('.')
except ValueError:
labels = label.lower().split('.')
# Label specifies an exact model
if len(labels) == 2:
app_label, model_name = labels
try:
idx = registry['search'][f'{app_label}.{model_name}']
indexers[idx.model] = idx
except KeyError:
raise CommandError(f"No indexer registered for {label}")
# Label specifies all the models of an app
elif len(labels) == 1:
app_label = labels[0] + '.'
for indexer_label, idx in registry['search'].items():
if indexer_label.startswith(app_label):
indexers[idx.model] = idx
else:
raise CommandError(
f"Invalid model: {label}. Model names must be in the format <app_label>.<model_name>."
f"Invalid model: {label}. Model names must be in the format <app_label> or <app_label>.<model_name>."
)
try:
idx = registry['search'][f'{app_label}.{model_name}']
indexers[idx.model] = idx
except KeyError:
raise CommandError(f"No indexer registered for {label}")
return indexers

View File

@@ -10,7 +10,16 @@ from django.db import migrations, models
def reindex(apps, schema_editor):
# Build the search index (except during tests)
if 'test' not in sys.argv:
management.call_command('reindex')
management.call_command(
'reindex',
'circuits',
'dcim',
'extras',
'ipam',
'tenancy',
'virtualization',
'wireless',
)
class Migration(migrations.Migration):

View File

@@ -651,7 +651,12 @@ class JobResult(models.Model):
if not self.completed:
return None
duration = self.completed - self.created
start_time = self.started or self.created
if not start_time:
return None
duration = self.completed - start_time
minutes, seconds = divmod(duration.total_seconds(), 60)
return f"{int(minutes)} minutes, {seconds:.2f} seconds"

View File

@@ -33,6 +33,9 @@ class FHRPGroupTable(NetBoxTable):
url_name='ipam:fhrpgroup_list'
)
def value_ip_addresses(self, value):
return ",".join([str(obj.address) for obj in value.all()])
class Meta(NetBoxTable.Meta):
model = FHRPGroup
fields = (

View File

@@ -382,5 +382,4 @@ def user_default_groups_handler(backend, user, response, *args, **kwargs):
if group_list:
user.groups.add(*group_list)
else:
user.groups.clear()
logger.debug(f"Stripping user {user} from Groups")
logger.info(f"No valid group assignments for {user} - REMOTE_AUTH_DEFAULT_GROUPS may be incorrectly set?")

View File

@@ -31,6 +31,7 @@ REDIS = {
# Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel
# 'SENTINELS': [('mysentinel.redis.example.com', 6379)],
# 'SENTINEL_SERVICE': 'netbox',
'USERNAME': '',
'PASSWORD': '',
'DATABASE': 0,
'SSL': False,
@@ -44,6 +45,7 @@ REDIS = {
# Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel
# 'SENTINELS': [('mysentinel.redis.example.com', 6379)],
# 'SENTINEL_SERVICE': 'netbox',
'USERNAME': '',
'PASSWORD': '',
'DATABASE': 1,
'SSL': False,
@@ -106,6 +108,9 @@ CORS_ORIGIN_REGEX_WHITELIST = [
# on a production system.
DEBUG = False
# Set the default preferred language/locale
DEFAULT_LANGUAGE = 'en-us'
# Email settings
EMAIL = {
'SERVER': 'localhost',
@@ -219,6 +224,9 @@ SESSION_COOKIE_NAME = 'sessionid'
# database access.) Note that the user as which NetBox runs must have read and write permissions to this path.
SESSION_FILE_PATH = None
# Localization
ENABLE_LOCALIZATION = False
# Time zone (default: UTC)
TIME_ZONE = 'UTC'

View File

@@ -22,6 +22,7 @@ REDIS = {
'tasks': {
'HOST': 'localhost',
'PORT': 6379,
'USERNAME': '',
'PASSWORD': '',
'DATABASE': 0,
'SSL': False,
@@ -29,6 +30,7 @@ REDIS = {
'caching': {
'HOST': 'localhost',
'PORT': 6379,
'USERNAME': '',
'PASSWORD': '',
'DATABASE': 1,
'SSL': False,

View File

@@ -75,7 +75,7 @@ class PrimaryModel(NetBoxModel):
abstract = True
class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
class NestedGroupModel(CloningMixin, NetBoxFeatureSet, MPTTModel):
"""
Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest
recursively using MPTT. Within each parent, each child instance must have a unique name.

View File

@@ -99,8 +99,8 @@ class CachedValueSearchBackend(SearchBackend):
params = {
f'value__{lookup}': value
}
if lookup != LookupTypes.EXACT:
# Partial matches are valid only on string values
if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH):
# Partial string matches are valid only on string values
params['type'] = FieldTypes.STRING
if object_types:
params['object_type__in'] = object_types

View File

@@ -24,7 +24,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup
#
VERSION = '3.4.0'
VERSION = '3.4.2'
# Hostname
HOSTNAME = platform.node()
@@ -94,6 +94,7 @@ FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {})
LANGUAGE_CODE = getattr(configuration, 'DEFAULT_LANGUAGE', 'en-us')
LOGGING = getattr(configuration, 'LOGGING', {})
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
@@ -136,6 +137,7 @@ STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None)
STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {})
TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a')
TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
ENABLE_LOCALIZATION = getattr(configuration, 'ENABLE_LOCALIZATION', False)
# Check for hard-coded dynamic config parameters
for param in PARAMS:
@@ -228,6 +230,7 @@ TASKS_REDIS_USING_SENTINEL = all([
])
TASKS_REDIS_SENTINEL_SERVICE = TASKS_REDIS.get('SENTINEL_SERVICE', 'default')
TASKS_REDIS_SENTINEL_TIMEOUT = TASKS_REDIS.get('SENTINEL_TIMEOUT', 10)
TASKS_REDIS_USERNAME = TASKS_REDIS.get('USERNAME', '')
TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '')
TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0)
TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False)
@@ -241,6 +244,8 @@ if 'caching' not in REDIS:
CACHING_REDIS_HOST = REDIS['caching'].get('HOST', 'localhost')
CACHING_REDIS_PORT = REDIS['caching'].get('PORT', 6379)
CACHING_REDIS_DATABASE = REDIS['caching'].get('DATABASE', 0)
CACHING_REDIS_USERNAME = REDIS['caching'].get('USERNAME', '')
CACHING_REDIS_USERNAME_HOST = '@'.join(filter(None, [CACHING_REDIS_USERNAME, CACHING_REDIS_HOST]))
CACHING_REDIS_PASSWORD = REDIS['caching'].get('PASSWORD', '')
CACHING_REDIS_SENTINELS = REDIS['caching'].get('SENTINELS', [])
CACHING_REDIS_SENTINEL_SERVICE = REDIS['caching'].get('SENTINEL_SERVICE', 'default')
@@ -250,7 +255,7 @@ CACHING_REDIS_SKIP_TLS_VERIFY = REDIS['caching'].get('INSECURE_SKIP_TLS_VERIFY',
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': f'{CACHING_REDIS_PROTO}://{CACHING_REDIS_HOST}:{CACHING_REDIS_PORT}/{CACHING_REDIS_DATABASE}',
'LOCATION': f'{CACHING_REDIS_PROTO}://{CACHING_REDIS_USERNAME_HOST}:{CACHING_REDIS_PORT}/{CACHING_REDIS_DATABASE}',
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'PASSWORD': CACHING_REDIS_PASSWORD,
@@ -339,6 +344,7 @@ MIDDLEWARE = [
'django_prometheus.middleware.PrometheusBeforeMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
@@ -354,6 +360,9 @@ MIDDLEWARE = [
'django_prometheus.middleware.PrometheusAfterMiddleware',
]
if not ENABLE_LOCALIZATION:
MIDDLEWARE.remove("django.middleware.locale.LocaleMiddleware")
ROOT_URLCONF = 'netbox.urls'
TEMPLATES_DIR = BASE_DIR + '/templates'
@@ -385,9 +394,6 @@ AUTHENTICATION_BACKENDS = [
'netbox.authentication.ObjectPermissionBackend',
]
# Internationalization
LANGUAGE_CODE = 'en-us'
# Time zones
USE_TZ = True
@@ -637,6 +643,7 @@ else:
}
RQ_PARAMS.update({
'DB': TASKS_REDIS_DATABASE,
'USERNAME': TASKS_REDIS_USERNAME,
'PASSWORD': TASKS_REDIS_PASSWORD,
'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
})
@@ -652,6 +659,13 @@ RQ_QUEUES.update({
queue: RQ_PARAMS for queue in set(QUEUE_MAPPINGS.values()) if queue not in RQ_QUEUES
})
#
# Localization
#
if not ENABLE_LOCALIZATION:
USE_I18N = False
USE_L10N = False
#
# Plugins

View File

@@ -537,14 +537,15 @@ class MPTTColumn(tables.TemplateColumn):
"""
template_code = """
{% load helpers %}
{% for i in record.level|as_range %}<i class="mdi mdi-circle-small"></i>{% endfor %}
{% if not table.order_by %}
{% for i in record.level|as_range %}<i class="mdi mdi-circle-small"></i>{% endfor %}
{% endif %}
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
"""
def __init__(self, *args, **kwargs):
super().__init__(
template_code=self.template_code,
orderable=False,
attrs={'td': {'class': 'text-nowrap'}},
*args,
**kwargs

View File

@@ -60,7 +60,7 @@
{% if object.mark_connected %}
<span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as connected
{% elif object.cable %}
{% include 'dcim/inc/connection_endpoints.html' %}
{% include 'dcim/inc/connection_endpoints.html' with trace_url='dcim:consoleport_trace' %}
{% else %}
<div class="text-muted">
Not Connected

View File

@@ -60,7 +60,7 @@
{% if object.mark_connected %}
<span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as connected
{% elif object.cable %}
{% include 'dcim/inc/connection_endpoints.html' %}
{% include 'dcim/inc/connection_endpoints.html' with trace_url='dcim:consoleserverport_trace' %}
{% else %}
<div class="text-muted">
Not Connected

View File

@@ -3,7 +3,7 @@
<th scope="row">Cable</th>
<td>
{{ object.cable|linkify }}
<a href="{% url 'dcim:interface_trace' pk=object.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
<a href="{% url trace_url pk=object.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
</a>
</td>

View File

@@ -145,7 +145,7 @@
<span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as Connected
</div>
{% elif object.cable %}
{% include 'dcim/inc/connection_endpoints.html' %}
{% include 'dcim/inc/connection_endpoints.html' with trace_url='dcim:interface_trace' %}
{% elif object.wireless_link %}
<table class="table table-hover">
<tr>

View File

@@ -0,0 +1,106 @@
{% extends 'generic/object_edit.html' %}
{% load static %}
{% load form_helpers %}
{% load helpers %}
{% block form %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">InventoryItem</h5>
</div>
{% render_field form.device %}
{% render_field form.parent %}
{% render_field form.name %}
{% render_field form.label %}
{% render_field form.role %}
{% render_field form.description %}
{% render_field form.tags %}
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Hardware</h5>
</div>
{% render_field form.manufacturer %}
{% render_field form.part_id %}
{% render_field form.serial %}
{% render_field form.asset_tag %}
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Component Assignment</h5>
</div>
<div class="row mb-2 offset-sm-3">
<ul class="nav nav-pills" role="tablist">
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="consoleport_tab" data-bs-toggle="tab" aria-controls="consoleport" data-bs-target="#consoleport" class="nav-link {% if form.initial.consoleport or form.no_component %}active{% endif %}">
Console Port
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="consoleserverport_tab" data-bs-toggle="tab" aria-controls="consoleserverport" data-bs-target="#consoleserverport" class="nav-link {% if form.initial.consoleserverport %}active{% endif %}">
Console Server Port
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="frontport_tab" data-bs-toggle="tab" aria-controls="frontport" data-bs-target="#frontport" class="nav-link {% if form.initial.frontport %}active{% endif %}">
Front Port
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="interface_tab" data-bs-toggle="tab" aria-controls="interface" data-bs-target="#interface" class="nav-link {% if form.initial.interface %}active{% endif %}">
Interface
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="poweroutlet_tab" data-bs-toggle="tab" aria-controls="poweroutlet" data-bs-target="#poweroutlet" class="nav-link {% if form.initial.poweroutlet %}active{% endif %}">
Power Outlet
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="powerport_tab" data-bs-toggle="tab" aria-controls="powerport" data-bs-target="#powerport" class="nav-link {% if form.initial.powerport %}active{% endif %}">
Power Port
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="rearport_tab" data-bs-toggle="tab" aria-controls="rearport" data-bs-target="#rearport" class="nav-link {% if form.initial.rearport %}active{% endif %}">
Rear Port
</button>
</li>
</ul>
</div>
<div class="tab-content p-0 border-0">
<div class="tab-pane {% if form.initial.consoleport or form.no_component %}active{% endif %}" id="consoleport" role="tabpanel" aria-labeled-by="consoleport_tab">
{% render_field form.consoleport %}
</div>
<div class="tab-pane {% if form.initial.consoleserverport %}active{% endif %}" id="consoleserverport" role="tabpanel" aria-labeled-by="consoleserverport_tab">
{% render_field form.consoleserverport %}
</div>
<div class="tab-pane {% if form.initial.frontport %}active{% endif %}" id="frontport" role="tabpanel" aria-labeled-by="frontport_tab">
{% render_field form.frontport %}
</div>
<div class="tab-pane {% if form.initial.interface %}active{% endif %}" id="interface" role="tabpanel" aria-labeled-by="interface_tab">
{% render_field form.interface %}
</div>
<div class="tab-pane {% if form.initial.poweroutlet %}active{% endif %}" id="poweroutlet" role="tabpanel" aria-labeled-by="poweroutlet_tab">
{% render_field form.poweroutlet %}
</div>
<div class="tab-pane {% if form.initial.powerport %}active{% endif %}" id="powerport" role="tabpanel" aria-labeled-by="powerport_tab">
{% render_field form.powerport %}
</div>
<div class="tab-pane {% if form.initial.rearport %}active{% endif %}" id="rearport" role="tabpanel" aria-labeled-by="rearport_tab">
{% render_field form.rearport %}
</div>
</div>
</div>
{% if form.custom_fields %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>
{% render_custom_fields form %}
</div>
{% endif %}
{% endblock %}

View File

@@ -112,7 +112,7 @@
<span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as connected
</div>
{% elif object.cable %}
{% include 'dcim/inc/connection_endpoints.html' %}
{% include 'dcim/inc/connection_endpoints.html' with trace_url='dcim:powerfeed_trace' %}
{% else %}
<div class="text-muted">
Not connected

View File

@@ -66,7 +66,7 @@
<span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as Connected
</div>
{% elif object.cable %}
{% include 'dcim/inc/connection_endpoints.html' %}
{% include 'dcim/inc/connection_endpoints.html' with trace_url='dcim:poweroutlet_trace' %}
{% else %}
<div class="text-muted">
Not Connected

View File

@@ -66,7 +66,7 @@
<span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as Connected
</div>
{% elif object.cable %}
{% include 'dcim/inc/connection_endpoints.html' %}
{% include 'dcim/inc/connection_endpoints.html' with trace_url='dcim:powerport_trace' %}
{% else %}
<div class="text-muted">
Not Connected

View File

@@ -35,6 +35,10 @@
{% block content-wrapper %}
<div class="tab-content">
{% if filter_form %}
{% applied_filters model filter_form request.GET %}
{% endif %}
{# Rack elevations #}
<div class="tab-pane show active" id="object-list" role="tabpanel" aria-labelledby="object-list-tab">
{% if page %}

View File

@@ -1,3 +1,4 @@
{% load humanize %}
{% load helpers %}
{% load log_levels %}

View File

@@ -70,6 +70,9 @@ Context:
{% applied_filters model filter_form request.GET %}
{% endif %}
{# Object table controls #}
{% include 'inc/table_controls_htmx.html' with table_modal="ObjectTable_config" %}
<form method="post" class="form form-horizontal">
{% csrf_token %}
{# "Select all" form #}
@@ -96,9 +99,6 @@ Context:
</div>
{% endif %}
{# Object table controls #}
{% include 'inc/table_controls_htmx.html' with table_modal="ObjectTable_config" %}
<div class="form form-horizontal">
{% csrf_token %}
<input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />

View File

@@ -55,6 +55,37 @@
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Allocated Resources</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row"><i class="mdi mdi-gauge"></i> Virtual CPUs</th>
<td>{{ vcpus_sum|placeholder }}</td>
</tr>
<tr>
<th scope="row"><i class="mdi mdi-chip"></i> Memory</th>
<td>
{% if memory_sum %}
{{ memory_sum|humanize_megabytes }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row"><i class="mdi mdi-harddisk"></i> Disk Space</th>
<td>
{% if disk_sum %}
{{ disk_sum }} GB
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/contacts.html' %}

View File

@@ -19,6 +19,7 @@ from dcim.choices import CableLengthUnitChoices, WeightUnitChoices
from extras.plugins import PluginConfig
from extras.utils import is_taggable
from netbox.config import get_config
from urllib.parse import urlencode
from utilities.constants import HTTP_REQUEST_META_SAFE_COPY
@@ -353,7 +354,7 @@ def prepare_cloned_fields(instance):
params.append((key, ''))
# Return a QueryDict with the parameters
return QueryDict('&'.join([f'{k}={v}' for k, v in params]), mutable=True)
return QueryDict(urlencode(params), mutable=True)
def shallow_compare_dict(source_dict, destination_dict, exclude=None):

View File

@@ -1,6 +1,6 @@
from django.contrib import messages
from django.db import transaction
from django.db.models import Prefetch
from django.db.models import Prefetch, Sum
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext as _
@@ -169,6 +169,9 @@ class ClusterListView(generic.ObjectListView):
class ClusterView(generic.ObjectView):
queryset = Cluster.objects.all()
def get_extra_context(self, request, instance):
return instance.virtual_machines.aggregate(vcpus_sum=Sum('vcpus'), memory_sum=Sum('memory'), disk_sum=Sum('disk'))
@register_model_view(Cluster, 'virtualmachines', path='virtual-machines')
class ClusterVirtualMachinesView(generic.ObjectChildrenView):

View File

@@ -1,5 +1,5 @@
bleach==5.0.1
Django==4.1.4
Django==4.1.5
django-cors-headers==3.13.0
django-debug-toolbar==3.8.1
django-filter==22.1
@@ -10,7 +10,7 @@ django-prometheus==2.2.0
django-redis==5.2.0
django-rich==1.4.0
django-rq==2.6.0
django-tables2==2.4.1
django-tables2==2.5.0
django-taggit==3.1.0
django-timezone-field==5.0
djangorestframework==3.14.0
@@ -22,10 +22,10 @@ Markdown==3.3.7
mkdocs-material==8.5.11
mkdocstrings[python-legacy]==0.19.1
netaddr==0.8.0
Pillow==9.3.0
Pillow==9.4.0
psycopg2-binary==2.9.5
PyYAML==6.0
sentry-sdk==1.11.1
sentry-sdk==1.12.1
social-auth-app-django==5.0.0
social-auth-core[openidconnect]==4.3.0
svgwrite==1.4.3