mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-08 04:56:56 -06:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa8a9ef9de | ||
|
|
6beb079b97 | ||
|
|
c8aad24a1b | ||
|
|
42bd876604 | ||
|
|
f903442cb9 | ||
|
|
5a64cb712d | ||
|
|
4d90d559be | ||
|
|
19de058f94 | ||
|
|
dc00e19c3c | ||
|
|
6ed6da49d9 | ||
|
|
7154d4ae2e | ||
|
|
bc26529be8 | ||
|
|
da64c564ae | ||
|
|
6199b3e039 | ||
|
|
2a391253a5 | ||
|
|
914653d63e | ||
|
|
3813aad8b1 | ||
|
|
ea5371040e | ||
|
|
6c824cc48f | ||
|
|
f510e40428 | ||
|
|
860db9590b |
@@ -15,7 +15,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.4.9
|
||||
placeholder: v4.4.10
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@@ -27,7 +27,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.4.9
|
||||
placeholder: v4.4.10
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
3097
contrib/openapi.json
3097
contrib/openapi.json
File diff suppressed because it is too large
Load Diff
@@ -88,7 +88,7 @@ While permissions are typically assigned to specific groups and/or users, it is
|
||||
|
||||
### Viewing Objects
|
||||
|
||||
Object-based permissions work by filtering the database query generated by a user's request to restrict the set of objects returned. When a request is received, NetBox first determines whether the user is authenticated and has been granted to perform the requested action. For example, if the requested URL is `/dcim/devices/`, NetBox will check for the `dcim.view_device` permission. If the user has not been assigned this permission (either directly or via a group assignment), NetBox will return a 403 (forbidden) HTTP response.
|
||||
Object-based permissions work by filtering the database query generated by a user's request to restrict the set of objects returned. When a request is received, NetBox first determines whether the user is authenticated and has been granted permission to perform the requested action. For example, if the requested URL is `/dcim/devices/`, NetBox will check for the `dcim.view_device` permission. If the user has not been assigned this permission (either directly or via a group assignment), NetBox will return a 403 (forbidden) HTTP response.
|
||||
|
||||
If the permission _has_ been granted, NetBox will compile any specified constraints for the model and action. For example, suppose two permissions have been assigned to the user granting view access to the device model, with the following constraints:
|
||||
|
||||
@@ -102,9 +102,9 @@ If the permission _has_ been granted, NetBox will compile any specified constrai
|
||||
This grants the user access to view any device that is assigned to a site named NYC1 or NYC2, **or** which has a status of "offline" and has no tenant assigned. These constraints are equivalent to the following ORM query:
|
||||
|
||||
```no-highlight
|
||||
Site.objects.filter(
|
||||
Device.objects.filter(
|
||||
Q(site__name__in=['NYC1', 'NYC2']),
|
||||
Q(status='active', tenant__isnull=True)
|
||||
Q(status='offline', tenant__isnull=True)
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ The plugin source directory contains all the actual Python code and other resour
|
||||
|
||||
The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below:
|
||||
|
||||
```python
|
||||
```python title="__init__.py"
|
||||
from netbox.plugins import PluginConfig
|
||||
|
||||
class FooBarConfig(PluginConfig):
|
||||
@@ -151,7 +151,7 @@ Any additional apps must be installed within the same Python environment as NetB
|
||||
|
||||
An example `pyproject.toml` is below:
|
||||
|
||||
```
|
||||
```toml title="pyproject.toml"
|
||||
# See PEP 518 for the spec of this file
|
||||
# https://www.python.org/dev/peps/pep-0518/
|
||||
|
||||
@@ -179,11 +179,24 @@ classifiers=[
|
||||
]
|
||||
|
||||
requires-python = ">=3.10.0"
|
||||
|
||||
```
|
||||
|
||||
Many of these are self-explanatory, but for more information, see the [pyproject.toml documentation](https://packaging.python.org/en/latest/specifications/pyproject-toml/).
|
||||
|
||||
## Compatibility Matrix
|
||||
|
||||
Consider adding a file named `COMPATIBILITY.md` to your plugin project root (alongside `pyproject.toml`). This file should contain a table listing the minimum and maximum supported versions of NetBox (`min_version` and `max_version`) for each release. This serves as a handy reference for users who are upgrading from a previous version of your plugin. An example is shown below:
|
||||
|
||||
```markdown title="COMPATIBILITY.md"
|
||||
# Compatibility Matrix
|
||||
|
||||
| Release | Minimum NetBox Version | Maximum NetBox Version |
|
||||
|---------|------------------------|------------------------|
|
||||
| 0.2.0 | 4.4.0 | 4.5.x |
|
||||
| 0.1.1 | 4.3.0 | 4.4.x |
|
||||
| 0.1.0 | 4.3.0 | 4.4.x |
|
||||
```
|
||||
|
||||
## Create a Virtual Environment
|
||||
|
||||
It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) for the development of your plugin, as opposed to using system-wide packages. This will afford you complete control over the installed versions of all dependencies and avoid conflict with system packages. This environment can live wherever you'd like;however, it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.)
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# NetBox v4.4
|
||||
|
||||
## v4.4.10 (2026-01-06)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#20953](https://github.com/netbox-community/netbox/issues/20953) - Show reverse bridge relationships on interface detail pages
|
||||
* [#21071](https://github.com/netbox-community/netbox/issues/21071) - Include request method & URL when displaying server errors
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#19506](https://github.com/netbox-community/netbox/issues/19506) - Add filter forms for component templates to ensure object selector support
|
||||
* [#20044](https://github.com/netbox-community/netbox/issues/20044) - Fix dark mode support for rack elevations
|
||||
* [#20320](https://github.com/netbox-community/netbox/issues/20320) - Restore support for selecting related interfaces when bulk editing device interfaces
|
||||
* [#20817](https://github.com/netbox-community/netbox/issues/20817) - Re-enable sync button when disabling scheduled syncing for a data source
|
||||
* [#21045](https://github.com/netbox-community/netbox/issues/21045) - Fix `ValueError` exception when saving a site with an assigned prefix
|
||||
* [#21049](https://github.com/netbox-community/netbox/issues/21049) - Ignore stale custom field data when validating an object
|
||||
* [#21063](https://github.com/netbox-community/netbox/issues/21063) - Check for duplicate choice values when validating a custom field choice set
|
||||
* [#21064](https://github.com/netbox-community/netbox/issues/21064) - Ensures that extra choices in custom field choice sets preserve escaped colons
|
||||
|
||||
---
|
||||
|
||||
## v4.4.9 (2025-12-23)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -131,6 +131,19 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
'source_url': "URLs for local sources must start with file:// (or specify no scheme)"
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# If recurring sync is disabled for an existing DataSource, clear any pending sync jobs for it and reset its
|
||||
# "queued" status
|
||||
if not self._state.adding and not self.sync_interval:
|
||||
self.jobs.filter(status=JobStatusChoices.STATUS_PENDING).delete()
|
||||
if self.status == DataSourceStatusChoices.QUEUED and self.last_synced:
|
||||
self.status = DataSourceStatusChoices.COMPLETED
|
||||
elif self.status == DataSourceStatusChoices.QUEUED:
|
||||
self.status = DataSourceStatusChoices.NEW
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.utils.translation import gettext as _
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
@@ -183,6 +183,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
||||
type = ChoiceField(choices=InterfaceTypeChoices)
|
||||
parent = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
bridge = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
bridge_interfaces = NestedInterfaceSerializer(many=True, read_only=True)
|
||||
lag = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True)
|
||||
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True)
|
||||
@@ -222,13 +223,13 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled',
|
||||
'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'primary_mac_address', 'mac_addresses', 'speed', 'duplex',
|
||||
'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type',
|
||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
|
||||
'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers',
|
||||
'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
|
||||
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
|
||||
'parent', 'bridge', 'bridge_interfaces', 'lag', 'mtu', 'mac_address', 'primary_mac_address',
|
||||
'mac_addresses', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
|
||||
'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
|
||||
'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end',
|
||||
'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination',
|
||||
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
@@ -24,35 +24,45 @@ __all__ = (
|
||||
'CableFilterForm',
|
||||
'ConsoleConnectionFilterForm',
|
||||
'ConsolePortFilterForm',
|
||||
'ConsolePortTemplateFilterForm',
|
||||
'ConsoleServerPortFilterForm',
|
||||
'ConsoleServerPortTemplateFilterForm',
|
||||
'DeviceBayFilterForm',
|
||||
'DeviceBayTemplateFilterForm',
|
||||
'DeviceFilterForm',
|
||||
'DeviceRoleFilterForm',
|
||||
'DeviceTypeFilterForm',
|
||||
'FrontPortFilterForm',
|
||||
'FrontPortTemplateFilterForm',
|
||||
'InterfaceConnectionFilterForm',
|
||||
'InterfaceFilterForm',
|
||||
'InterfaceTemplateFilterForm',
|
||||
'InventoryItemFilterForm',
|
||||
'InventoryItemTemplateFilterForm',
|
||||
'InventoryItemRoleFilterForm',
|
||||
'LocationFilterForm',
|
||||
'MACAddressFilterForm',
|
||||
'ManufacturerFilterForm',
|
||||
'ModuleFilterForm',
|
||||
'ModuleBayFilterForm',
|
||||
'ModuleBayTemplateFilterForm',
|
||||
'ModuleTypeFilterForm',
|
||||
'ModuleTypeProfileFilterForm',
|
||||
'PlatformFilterForm',
|
||||
'PowerConnectionFilterForm',
|
||||
'PowerFeedFilterForm',
|
||||
'PowerOutletFilterForm',
|
||||
'PowerOutletTemplateFilterForm',
|
||||
'PowerPanelFilterForm',
|
||||
'PowerPortFilterForm',
|
||||
'PowerPortTemplateFilterForm',
|
||||
'RackFilterForm',
|
||||
'RackElevationFilterForm',
|
||||
'RackReservationFilterForm',
|
||||
'RackRoleFilterForm',
|
||||
'RackTypeFilterForm',
|
||||
'RearPortFilterForm',
|
||||
'RearPortTemplateFilterForm',
|
||||
'RegionFilterForm',
|
||||
'SiteFilterForm',
|
||||
'SiteGroupFilterForm',
|
||||
@@ -1290,6 +1300,23 @@ class PowerFeedFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
# Device components
|
||||
#
|
||||
|
||||
class DeviceComponentTemplateFilterForm(NetBoxModelFilterSetForm):
|
||||
device_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
required=False,
|
||||
label=_('Device type'),
|
||||
)
|
||||
|
||||
|
||||
class ModularDeviceComponentTemplateFilterForm(DeviceComponentTemplateFilterForm):
|
||||
module_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ModuleType.objects.all(),
|
||||
required=False,
|
||||
query_params={'manufacturer_id': '$manufacturer_id'},
|
||||
label=_('Module Type'),
|
||||
)
|
||||
|
||||
|
||||
class CabledFilterForm(forms.Form):
|
||||
cabled = forms.NullBooleanField(
|
||||
label=_('Cabled'),
|
||||
@@ -1342,6 +1369,20 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ConsolePortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = ConsolePortTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=ConsolePortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = ConsoleServerPort
|
||||
fieldsets = (
|
||||
@@ -1367,6 +1408,20 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = ConsoleServerPortTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=ConsolePortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = PowerPort
|
||||
fieldsets = (
|
||||
@@ -1387,6 +1442,20 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class PowerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = PowerPortTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=PowerPortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = PowerOutlet
|
||||
fieldsets = (
|
||||
@@ -1416,6 +1485,20 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
)
|
||||
|
||||
|
||||
class PowerOutletTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = PowerOutletTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=PowerOutletTypeChoices,
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = Interface
|
||||
fieldsets = (
|
||||
@@ -1543,6 +1626,51 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class InterfaceTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = InterfaceTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'enabled', 'mgmt_only', name=_('Attributes')),
|
||||
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
|
||||
FieldSet('rf_role', name=_('Wireless')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=InterfaceTypeChoices,
|
||||
required=False
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
label=_('Enabled'),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
mgmt_only = forms.NullBooleanField(
|
||||
label=_('Management only'),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
poe_mode = forms.MultipleChoiceField(
|
||||
choices=InterfacePoEModeChoices,
|
||||
required=False,
|
||||
label=_('PoE mode')
|
||||
)
|
||||
poe_type = forms.MultipleChoiceField(
|
||||
choices=InterfacePoETypeChoices,
|
||||
required=False,
|
||||
label=_('PoE type')
|
||||
)
|
||||
rf_role = forms.MultipleChoiceField(
|
||||
choices=WirelessRoleChoices,
|
||||
required=False,
|
||||
label=_('Wireless role')
|
||||
)
|
||||
|
||||
|
||||
class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
@@ -1567,6 +1695,24 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class FrontPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = FrontPortTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=PortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
color = ColorField(
|
||||
label=_('Color'),
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
model = RearPort
|
||||
fieldsets = (
|
||||
@@ -1591,6 +1737,24 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class RearPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = RearPortTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=PortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
color = ColorField(
|
||||
label=_('Color'),
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class ModuleBayFilterForm(DeviceComponentFilterForm):
|
||||
model = ModuleBay
|
||||
fieldsets = (
|
||||
@@ -1609,6 +1773,19 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
|
||||
)
|
||||
|
||||
|
||||
class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = ModuleBayTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'position', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
position = forms.CharField(
|
||||
label=_('Position'),
|
||||
required=False,
|
||||
)
|
||||
|
||||
|
||||
class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||
model = DeviceBay
|
||||
fieldsets = (
|
||||
@@ -1623,6 +1800,15 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class DeviceBayTemplateFilterForm(DeviceComponentTemplateFilterForm):
|
||||
model = DeviceBayTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', name=_('Attributes')),
|
||||
FieldSet('device_type_id', name=_('Device')),
|
||||
)
|
||||
|
||||
|
||||
class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
model = InventoryItem
|
||||
fieldsets = (
|
||||
@@ -1670,6 +1856,25 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class InventoryItemTemplateFilterForm(DeviceComponentTemplateFilterForm):
|
||||
model = InventoryItemTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'role_id', 'manufacturer_id', name=_('Attributes')),
|
||||
FieldSet('device_type_id', name=_('Device')),
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=InventoryItemRole.objects.all(),
|
||||
required=False,
|
||||
label=_('Role')
|
||||
)
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
label=_('Manufacturer')
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Device component roles
|
||||
#
|
||||
|
||||
@@ -210,7 +210,7 @@ def sync_cached_scope_fields(instance, created, **kwargs):
|
||||
for model in (Prefix, Cluster, WirelessLAN):
|
||||
qs = model.objects.filter(**filters)
|
||||
|
||||
for obj in qs.only('id'):
|
||||
for obj in qs:
|
||||
# Recompute cache using the same logic as save()
|
||||
obj.cache_related_objects()
|
||||
obj.save(update_fields=[
|
||||
|
||||
@@ -6,6 +6,7 @@ from core.models import ObjectType
|
||||
from dcim.choices import *
|
||||
from dcim.models import *
|
||||
from extras.models import CustomField
|
||||
from ipam.models import Prefix
|
||||
from netbox.choices import WeightUnitChoices
|
||||
from tenancy.models import Tenant
|
||||
from utilities.data import drange
|
||||
@@ -1192,3 +1193,14 @@ class VirtualChassisTestCase(TestCase):
|
||||
device2.vc_position = 1
|
||||
with self.assertRaises(ValidationError):
|
||||
device2.full_clean()
|
||||
|
||||
|
||||
class SiteSignalTestCase(TestCase):
|
||||
|
||||
@tag('regression')
|
||||
def test_edit_site_with_prefix_no_vrf(self):
|
||||
site = Site.objects.create(name='Test Site', slug='test-site')
|
||||
Prefix.objects.create(prefix='192.0.2.0/24', scope=site, vrf=None)
|
||||
|
||||
# Regression test for #21045: should not raise ValueError
|
||||
site.save()
|
||||
|
||||
@@ -13,7 +13,7 @@ from django.views.generic import View
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination
|
||||
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
|
||||
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
||||
from netbox.object_actions import *
|
||||
from netbox.views import generic
|
||||
@@ -2900,6 +2900,7 @@ class InterfaceView(generic.ObjectView):
|
||||
|
||||
return {
|
||||
'vdc_table': vdc_table,
|
||||
'bridge_interfaces': bridge_interfaces,
|
||||
'bridge_interfaces_table': bridge_interfaces_table,
|
||||
'child_interfaces_table': child_interfaces_table,
|
||||
'vlan_table': vlan_table,
|
||||
|
||||
@@ -189,22 +189,22 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, forms.ModelForm):
|
||||
# if standardize these, we can simplify this code
|
||||
|
||||
# Convert extra_choices Array Field from model to CharField for form
|
||||
if 'extra_choices' in self.initial and self.initial['extra_choices']:
|
||||
extra_choices = self.initial['extra_choices']
|
||||
if extra_choices := self.initial.get('extra_choices', None):
|
||||
if isinstance(extra_choices, str):
|
||||
extra_choices = [extra_choices]
|
||||
choices = ""
|
||||
choices = []
|
||||
for choice in extra_choices:
|
||||
# Setup choices in Add Another use case
|
||||
if isinstance(choice, str):
|
||||
choice_str = ":".join(choice.replace("'", "").replace(" ", "")[1:-1].split(","))
|
||||
choices += choice_str + "\n"
|
||||
choices.append(choice_str)
|
||||
# Setup choices in Edit use case
|
||||
elif isinstance(choice, list):
|
||||
choice_str = ":".join(choice)
|
||||
choices += choice_str + "\n"
|
||||
value = choice[0].replace(':', '\\:')
|
||||
label = choice[1].replace(':', '\\:')
|
||||
choices.append(f'{value}:{label}')
|
||||
|
||||
self.initial['extra_choices'] = choices
|
||||
self.initial['extra_choices'] = '\n'.join(choices)
|
||||
|
||||
def clean_extra_choices(self):
|
||||
data = []
|
||||
|
||||
@@ -878,6 +878,16 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
|
||||
if not self.base_choices and not self.extra_choices:
|
||||
raise ValidationError(_("Must define base or extra choices."))
|
||||
|
||||
# Check for duplicate values in extra_choices
|
||||
choice_values = [c[0] for c in self.extra_choices] if self.extra_choices else []
|
||||
if len(set(choice_values)) != len(choice_values):
|
||||
# At least one duplicate value is present. Find the first one and raise an error.
|
||||
_seen = []
|
||||
for value in choice_values:
|
||||
if value in _seen:
|
||||
raise ValidationError(_("Duplicate value '{value}' found in extra choices.").format(value=value))
|
||||
_seen.append(value)
|
||||
|
||||
# Check whether any choices have been removed. If so, check whether any of the removed
|
||||
# choices are still set in custom field data for any object.
|
||||
original_choices = set([
|
||||
|
||||
@@ -1506,19 +1506,18 @@ class CustomFieldModelTest(TestCase):
|
||||
|
||||
def test_invalid_data(self):
|
||||
"""
|
||||
Setting custom field data for a non-applicable (or non-existent) CustomField should raise a ValidationError.
|
||||
Any invalid or stale custom field data should be removed from the instance.
|
||||
"""
|
||||
site = Site(name='Test Site', slug='test-site')
|
||||
|
||||
# Set custom field data
|
||||
site.custom_field_data['foo'] = 'abc'
|
||||
site.custom_field_data['bar'] = 'def'
|
||||
with self.assertRaises(ValidationError):
|
||||
site.clean()
|
||||
|
||||
del site.custom_field_data['bar']
|
||||
site.clean()
|
||||
|
||||
self.assertIn('foo', site.custom_field_data)
|
||||
self.assertNotIn('bar', site.custom_field_data)
|
||||
|
||||
def test_missing_required_field(self):
|
||||
"""
|
||||
Check that a ValidationError is raised if any required custom fields are not present.
|
||||
|
||||
@@ -5,6 +5,7 @@ from dcim.forms import SiteForm
|
||||
from dcim.models import Site
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.forms import SavedFilterForm
|
||||
from extras.forms.model_forms import CustomFieldChoiceSetForm
|
||||
from extras.models import CustomField, CustomFieldChoiceSet
|
||||
|
||||
|
||||
@@ -90,6 +91,31 @@ class CustomFieldModelFormTest(TestCase):
|
||||
self.assertIsNone(instance.custom_field_data[field_type])
|
||||
|
||||
|
||||
class CustomFieldChoiceSetFormTest(TestCase):
|
||||
|
||||
def test_escaped_colons_preserved_on_edit(self):
|
||||
choice_set = CustomFieldChoiceSet.objects.create(
|
||||
name='Test Choice Set',
|
||||
extra_choices=[['foo:bar', 'label'], ['value', 'label:with:colons']]
|
||||
)
|
||||
|
||||
form = CustomFieldChoiceSetForm(instance=choice_set)
|
||||
initial_choices = form.initial['extra_choices']
|
||||
|
||||
# colons are re-escaped
|
||||
self.assertEqual(initial_choices, 'foo\\:bar:label\nvalue:label\\:with\\:colons')
|
||||
|
||||
form = CustomFieldChoiceSetForm(
|
||||
{'name': choice_set.name, 'extra_choices': initial_choices},
|
||||
instance=choice_set
|
||||
)
|
||||
self.assertTrue(form.is_valid())
|
||||
updated = form.save()
|
||||
|
||||
# cleaned extra choices are correct, which does actually mean a list of tuples
|
||||
self.assertEqual(updated.extra_choices, [('foo:bar', 'label'), ('value', 'label:with:colons')])
|
||||
|
||||
|
||||
class SavedFilterFormTest(TestCase):
|
||||
|
||||
def test_basic_submit(self):
|
||||
|
||||
@@ -288,12 +288,13 @@ class CustomFieldsMixin(models.Model):
|
||||
cf.name: cf for cf in CustomField.objects.get_for_model(self)
|
||||
}
|
||||
|
||||
# Remove any stale custom field data
|
||||
self.custom_field_data = {
|
||||
k: v for k, v in self.custom_field_data.items() if k in custom_fields.keys()
|
||||
}
|
||||
|
||||
# Validate all field values
|
||||
for field_name, value in self.custom_field_data.items():
|
||||
if field_name not in custom_fields:
|
||||
raise ValidationError(_("Unknown field name '{name}' in custom field data.").format(
|
||||
name=field_name
|
||||
))
|
||||
try:
|
||||
custom_fields[field_name].validate(value)
|
||||
except ValidationError as e:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.db.models import ForeignKey
|
||||
from django.template import loader
|
||||
from django.urls.exceptions import NoReverseMatch
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -175,6 +176,21 @@ class BulkEdit(ObjectAction):
|
||||
permissions_required = {'change'}
|
||||
template_name = 'buttons/bulk_edit.html'
|
||||
|
||||
@classmethod
|
||||
def get_context(cls, context, model):
|
||||
url_params = super().get_url_params(context)
|
||||
|
||||
# If this is a child object, pass the parent's PK as a URL parameter
|
||||
if parent := context.get('object'):
|
||||
for field in model._meta.get_fields():
|
||||
if isinstance(field, ForeignKey) and field.remote_field.model == parent.__class__:
|
||||
url_params[field.name] = parent.pk
|
||||
break
|
||||
|
||||
return {
|
||||
'url_params': url_params,
|
||||
}
|
||||
|
||||
|
||||
class BulkRename(ObjectAction):
|
||||
"""
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from unittest import skipIf
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from dcim.models import Device
|
||||
from netbox.object_actions import AddObject, BulkImport
|
||||
from netbox.tests.dummy_plugin.models import DummyNetBoxModel
|
||||
from dcim.models import Device, DeviceType, Manufacturer
|
||||
from netbox.object_actions import AddObject, BulkEdit, BulkImport
|
||||
|
||||
|
||||
class ObjectActionTest(TestCase):
|
||||
@@ -20,9 +19,11 @@ class ObjectActionTest(TestCase):
|
||||
url = BulkImport.get_url(obj)
|
||||
self.assertEqual(url, '/dcim/devices/import/')
|
||||
|
||||
@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
|
||||
@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, 'dummy_plugin not in settings.PLUGINS')
|
||||
def test_get_url_plugin_model(self):
|
||||
"""Test URL generation for plugin models includes plugins: namespace"""
|
||||
from netbox.tests.dummy_plugin.models import DummyNetBoxModel
|
||||
|
||||
obj = DummyNetBoxModel()
|
||||
|
||||
url = AddObject.get_url(obj)
|
||||
@@ -30,3 +31,29 @@ class ObjectActionTest(TestCase):
|
||||
|
||||
url = BulkImport.get_url(obj)
|
||||
self.assertEqual(url, '/plugins/dummy-plugin/netboxmodel/import/')
|
||||
|
||||
def test_bulk_edit_get_context_child_object(self):
|
||||
"""
|
||||
Test that the parent object's PK is included in the context for child objects.
|
||||
|
||||
Ensure that BulkEdit.get_context() correctly identifies and
|
||||
includes the parent object's PK when rendering a child object's
|
||||
action button.
|
||||
"""
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
|
||||
# Mock context containing the parent object (DeviceType)
|
||||
request = RequestFactory().get('/')
|
||||
context = {
|
||||
'request': request,
|
||||
'object': device_type,
|
||||
}
|
||||
|
||||
# Get context for the child model (Device)
|
||||
action_context = BulkEdit.get_context(context, Device)
|
||||
|
||||
# Verify that 'device_type' (the FK field name) is present in
|
||||
# url_params with the parent's PK
|
||||
self.assertIn('url_params', action_context)
|
||||
self.assertEqual(action_context['url_params'].get('device_type'), device_type.pk)
|
||||
|
||||
@@ -52,6 +52,7 @@ def handler_500(request, template_name=ERROR_500_TEMPLATE_NAME):
|
||||
type_, error = sys.exc_info()[:2]
|
||||
|
||||
return HttpResponseServerError(template.render({
|
||||
'request': request,
|
||||
'error': error,
|
||||
'exception': str(type_),
|
||||
'netbox_version': settings.RELEASE.full_version,
|
||||
|
||||
10
netbox/project-static/dist/netbox.js
vendored
10
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
6
netbox/project-static/dist/netbox.js.map
vendored
6
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -27,10 +27,10 @@
|
||||
"bootstrap": "5.3.8",
|
||||
"clipboard": "2.0.11",
|
||||
"flatpickr": "4.6.13",
|
||||
"gridstack": "12.4.1",
|
||||
"gridstack": "12.4.2",
|
||||
"htmx.org": "2.0.8",
|
||||
"query-string": "9.3.1",
|
||||
"sass": "1.97.1",
|
||||
"sass": "1.97.2",
|
||||
"tom-select": "2.4.3",
|
||||
"typeface-inter": "3.18.1",
|
||||
"typeface-roboto-mono": "1.1.13"
|
||||
|
||||
@@ -28,13 +28,27 @@ function updateElements(targetMode: ColorMode): void {
|
||||
}
|
||||
|
||||
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
|
||||
const svg = elevation.contentDocument?.querySelector('svg') ?? null;
|
||||
if (svg !== null) {
|
||||
const svg = elevation.firstElementChild ?? null;
|
||||
if (svg !== null && svg.nodeName == 'svg') {
|
||||
svg.setAttribute(`data-bs-theme`, targetMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the color mode to light of elevations after an htmx call.
|
||||
* Pulls current color mode from document
|
||||
*
|
||||
* @param event htmx listener event details. See: https://htmx.org/events/#htmx:afterSwap
|
||||
*/
|
||||
function updateElevations(evt: CustomEvent, ): void {
|
||||
const swappedElement = evt.detail.elt
|
||||
if (swappedElement.nodeName == 'svg') {
|
||||
const currentMode = localStorage.getItem(COLOR_MODE_KEY);
|
||||
swappedElement.setAttribute('data-bs-theme', currentMode)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call all functions necessary to update the color mode across the UI.
|
||||
*
|
||||
@@ -115,6 +129,7 @@ function initColorModeToggle(): void {
|
||||
*/
|
||||
export function initColorMode(): void {
|
||||
window.addEventListener('load', defaultColorMode);
|
||||
window.addEventListener('htmx:afterSwap', updateElevations as EventListener); // Uses a custom event from HTMX
|
||||
for (const func of [initColorModeToggle]) {
|
||||
func();
|
||||
}
|
||||
|
||||
@@ -2178,10 +2178,10 @@ graphql@16.10.0:
|
||||
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.10.0.tgz#24c01ae0af6b11ea87bf55694429198aaa8e220c"
|
||||
integrity sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ==
|
||||
|
||||
gridstack@12.4.1:
|
||||
version "12.4.1"
|
||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-12.4.1.tgz#4a44511e5da33016e731f00bee279bed550d4ab9"
|
||||
integrity sha512-dYBNVEDw2zwnz0bCDouHk8rMclrMoMn4r6rtNyyWSeYsV3RF8QV2KFRTj4c86T2FsZPr3iQv+/LD/ae29FcpHQ==
|
||||
gridstack@12.4.2:
|
||||
version "12.4.2"
|
||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-12.4.2.tgz#188de180b6cda77e48b1414aac1d778a38f48f04"
|
||||
integrity sha512-aXbJrQpi3LwpYXYOr4UriPM5uc/dPcjK01SdOE5PDpx2vi8tnLhU7yBg/1i4T59UhNkG/RBfabdFUObuN+gMnw==
|
||||
|
||||
has-bigints@^1.0.1, has-bigints@^1.0.2:
|
||||
version "1.0.2"
|
||||
@@ -3190,10 +3190,10 @@ safe-regex-test@^1.1.0:
|
||||
es-errors "^1.3.0"
|
||||
is-regex "^1.2.1"
|
||||
|
||||
sass@1.97.1:
|
||||
version "1.97.1"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.1.tgz#f36e492baf8ccdd08d591b58d3d8b53ea35ab905"
|
||||
integrity sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==
|
||||
sass@1.97.2:
|
||||
version "1.97.2"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.2.tgz#e515a319092fd2c3b015228e3094b40198bff0da"
|
||||
integrity sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==
|
||||
dependencies:
|
||||
chokidar "^4.0.0"
|
||||
immutable "^5.0.2"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version: "4.4.9"
|
||||
version: "4.4.10"
|
||||
edition: "Community"
|
||||
published: "2025-12-23"
|
||||
published: "2026-01-06"
|
||||
|
||||
@@ -35,6 +35,12 @@
|
||||
{% trans "Plugins" %}: {% for plugin, version in plugins.items %}
|
||||
{{ plugin }}: {{ version }}{% empty %}{% trans "None installed" %}{% endfor %}
|
||||
</pre>
|
||||
<p>
|
||||
{% trans "The request which yielded the above error is shown below:" %}
|
||||
</p>
|
||||
<p>
|
||||
<code>{{ request.method }} {{ request.build_absolute_uri }}</code>
|
||||
</p>
|
||||
<p>
|
||||
{% trans "If further assistance is required, please post to the" %} <a href="https://github.com/netbox-community/netbox/discussions">{% trans "NetBox discussion forum" %}</a> {% trans "on GitHub" %}.
|
||||
</p>
|
||||
|
||||
@@ -112,6 +112,19 @@
|
||||
<th scope="row">{% trans "Bridge" %}</th>
|
||||
<td>{{ object.bridge|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Bridged Interfaces" %}</th>
|
||||
<td>
|
||||
{% if bridge_interfaces %}
|
||||
{% for interface in bridge_interfaces %}
|
||||
{{ interface|linkify }}
|
||||
{% if not forloop.last %}<br />{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "LAG" %}</th>
|
||||
<td>{{ object.lag|linkify|placeholder }}</td>
|
||||
@@ -435,13 +448,11 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if object.is_bridge %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
{% include 'inc/panel_table.html' with table=bridge_interfaces_table heading="Bridge Interfaces" %}
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
{% include 'inc/panel_table.html' with table=bridge_interfaces_table heading="Bridged Interfaces" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
{% include 'inc/panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
|
||||
[project]
|
||||
name = "netbox"
|
||||
version = "4.4.9"
|
||||
version = "4.4.10"
|
||||
requires-python = ">=3.10"
|
||||
description = "The premier source of truth powering network automation."
|
||||
readme = "README.md"
|
||||
|
||||
@@ -10,14 +10,14 @@ django-pglocks==1.0.4
|
||||
django-prometheus==2.4.1
|
||||
django-redis==6.0.0
|
||||
django-rich==2.2.0
|
||||
django-rq==3.2.1
|
||||
django-rq==3.2.2
|
||||
django-storages==1.14.6
|
||||
django-tables2==2.8.0
|
||||
django-taggit==6.1.0
|
||||
django-timezone-field==7.2.1
|
||||
djangorestframework==3.16.1
|
||||
drf-spectacular==0.29.0
|
||||
drf-spectacular-sidecar==2025.12.1
|
||||
drf-spectacular-sidecar==2026.1.1
|
||||
feedparser==6.0.12
|
||||
gunicorn==23.0.0
|
||||
Jinja2==3.1.6
|
||||
@@ -28,7 +28,7 @@ mkdocstrings==1.0.0
|
||||
mkdocstrings-python==2.0.1
|
||||
netaddr==1.3.0
|
||||
nh3==0.3.2
|
||||
Pillow==12.0.0
|
||||
Pillow==12.1.0
|
||||
psycopg[c,pool]==3.3.2
|
||||
PyYAML==6.0.3
|
||||
requests==2.32.5
|
||||
@@ -36,8 +36,8 @@ rq==2.6.1
|
||||
social-auth-app-django==5.7.0
|
||||
social-auth-core==4.8.3
|
||||
sorl-thumbnail==12.11.0
|
||||
strawberry-graphql==0.287.3
|
||||
strawberry-graphql-django==0.70.1
|
||||
strawberry-graphql==0.288.2
|
||||
strawberry-graphql-django==0.73.0
|
||||
svgwrite==1.4.3
|
||||
tablib==3.9.0
|
||||
tzdata==2025.3
|
||||
|
||||
Reference in New Issue
Block a user