mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-19 01:58:43 -06:00
Compare commits
31 Commits
67c4da607d
...
fix-19669-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef7880a013 | ||
|
|
8fd8493d11 | ||
|
|
db805053d9 | ||
|
|
cf4db67e0b | ||
|
|
f48e1cb534 | ||
|
|
ffa9a52667 | ||
|
|
47320f9958 | ||
|
|
d08a1bd07d | ||
|
|
14c4aeca54 | ||
|
|
26bec1275f | ||
|
|
fa2d7f6516 | ||
|
|
d571cb4867 | ||
|
|
2129355c30 | ||
|
|
c40bfb1445 | ||
|
|
b88b5b0b1b | ||
|
|
6eeb382512 | ||
|
|
e5d6c71171 | ||
|
|
f777bfee2e | ||
|
|
8b63eb64c1 | ||
|
|
cff29f9551 | ||
|
|
a5c0cae112 | ||
|
|
2a27e475e4 | ||
|
|
44efa037cc | ||
|
|
6c17629159 | ||
|
|
f13d028c98 | ||
|
|
f5d32b1bf1 | ||
|
|
f05897d61a | ||
|
|
b5421f1cd6 | ||
|
|
23cc4f1c41 | ||
|
|
9c2cd66162 | ||
|
|
f61a2964c8 |
@@ -15,7 +15,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v4.3.3
|
placeholder: v4.3.4
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- 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:
|
attributes:
|
||||||
label: NetBox Version
|
label: NetBox Version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v4.3.3
|
placeholder: v4.3.4
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
|
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
|
||||||
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
|
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
|
||||||
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
|
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
|
||||||
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=main" alt="CI status" /></a>
|
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/actions/workflows/ci.yml/badge.svg" alt="CI status" /></a>
|
||||||
<p>
|
<p>
|
||||||
<strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> |
|
<strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> |
|
||||||
<strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> |
|
<strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> |
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ django-debug-toolbar
|
|||||||
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
|
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
|
||||||
django-filter
|
django-filter
|
||||||
|
|
||||||
|
# Django Debug Toolbar extension for GraphiQL
|
||||||
|
# https://github.com/flavors/django-graphiql-debug-toolbar/blob/main/CHANGES.rst
|
||||||
|
django-graphiql-debug-toolbar
|
||||||
|
|
||||||
# HTMX utilities for Django
|
# HTMX utilities for Django
|
||||||
# https://django-htmx.readthedocs.io/en/latest/changelog.html
|
# https://django-htmx.readthedocs.io/en/latest/changelog.html
|
||||||
django-htmx
|
django-htmx
|
||||||
@@ -108,6 +112,7 @@ nh3
|
|||||||
|
|
||||||
# Fork of PIL (Python Imaging Library) for image processing
|
# Fork of PIL (Python Imaging Library) for image processing
|
||||||
# https://github.com/python-pillow/Pillow/releases
|
# https://github.com/python-pillow/Pillow/releases
|
||||||
|
# https://pillow.readthedocs.io/en/stable/releasenotes/
|
||||||
Pillow
|
Pillow
|
||||||
|
|
||||||
# PostgreSQL database adapter for Python
|
# PostgreSQL database adapter for Python
|
||||||
@@ -126,14 +131,14 @@ requests
|
|||||||
# https://github.com/rq/rq/blob/master/CHANGES.md
|
# https://github.com/rq/rq/blob/master/CHANGES.md
|
||||||
rq
|
rq
|
||||||
|
|
||||||
# Social authentication framework
|
|
||||||
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
|
|
||||||
social-auth-core
|
|
||||||
|
|
||||||
# Django app for social-auth-core
|
# Django app for social-auth-core
|
||||||
# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
|
# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
|
||||||
social-auth-app-django
|
social-auth-app-django
|
||||||
|
|
||||||
|
# Social authentication framework
|
||||||
|
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
|
||||||
|
social-auth-core
|
||||||
|
|
||||||
# Strawberry GraphQL
|
# Strawberry GraphQL
|
||||||
# https://github.com/strawberry-graphql/strawberry/blob/main/CHANGELOG.md
|
# https://github.com/strawberry-graphql/strawberry/blob/main/CHANGELOG.md
|
||||||
strawberry-graphql
|
strawberry-graphql
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ LOGGING = {
|
|||||||
* `netbox.<app>.<model>` - Generic form for model-specific log messages
|
* `netbox.<app>.<model>` - Generic form for model-specific log messages
|
||||||
* `netbox.auth.*` - Authentication events
|
* `netbox.auth.*` - Authentication events
|
||||||
* `netbox.api.views.*` - Views which handle business logic for the REST API
|
* `netbox.api.views.*` - Views which handle business logic for the REST API
|
||||||
|
* `netbox.event_rules` - Event rules
|
||||||
* `netbox.reports.*` - Report execution (`module.name`)
|
* `netbox.reports.*` - Report execution (`module.name`)
|
||||||
* `netbox.scripts.*` - Custom script execution (`module.name`)
|
* `netbox.scripts.*` - Custom script execution (`module.name`)
|
||||||
* `netbox.views.*` - Views which handle business logic for the web UI
|
* `netbox.views.*` - Views which handle business logic for the web UI
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ For UI development you will need to review the [Web UI Development Guide](web-ui
|
|||||||
|
|
||||||
## Populating Demo Data
|
## Populating Demo Data
|
||||||
|
|
||||||
Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. (This sample data is used to populate the public demo instance at <https://demo.netbox.dev>.)
|
Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. This sample data is used to populate the [public demo instance](https://demo.netbox.dev).
|
||||||
|
|
||||||
The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data.
|
The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data.
|
||||||
|
|
||||||
|
|||||||
@@ -302,13 +302,6 @@ Quit the server with CONTROL-C.
|
|||||||
|
|
||||||
Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, <http://127.0.0.1:8000/>. You should be greeted with the NetBox home page. Try logging in using the username and password specified when creating a superuser.
|
Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, <http://127.0.0.1:8000/>. You should be greeted with the NetBox home page. Try logging in using the username and password specified when creating a superuser.
|
||||||
|
|
||||||
!!! note
|
|
||||||
By default RHEL based distros will likely block your testing attempts with firewalld. The development server port can be opened with `firewall-cmd` (add `--permanent` if you want the rule to survive server restarts):
|
|
||||||
|
|
||||||
```no-highlight
|
|
||||||
firewall-cmd --zone=public --add-port=8000/tcp
|
|
||||||
```
|
|
||||||
|
|
||||||
!!! danger "Not for production use"
|
!!! danger "Not for production use"
|
||||||
The development server is for development and testing purposes only. It is neither performant nor secure enough for production use. **Do not use it in production.**
|
The development server is for development and testing purposes only. It is neither performant nor secure enough for production use. **Do not use it in production.**
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 24 KiB |
@@ -80,18 +80,20 @@ GET /api/ipam/vlans/?vid__gt=900
|
|||||||
|
|
||||||
String based (char) fields (Name, Address, etc) support these lookup expressions:
|
String based (char) fields (Name, Address, etc) support these lookup expressions:
|
||||||
|
|
||||||
| Filter | Description |
|
| Filter | Description |
|
||||||
|---------|----------------------------------------|
|
|----------|----------------------------------------|
|
||||||
| `n` | Not equal to |
|
| `n` | Not equal to |
|
||||||
| `ic` | Contains (case-insensitive) |
|
| `ic` | Contains (case-insensitive) |
|
||||||
| `nic` | Does not contain (case-insensitive) |
|
| `nic` | Does not contain (case-insensitive) |
|
||||||
| `isw` | Starts with (case-insensitive) |
|
| `isw` | Starts with (case-insensitive) |
|
||||||
| `nisw` | Does not start with (case-insensitive) |
|
| `nisw` | Does not start with (case-insensitive) |
|
||||||
| `iew` | Ends with (case-insensitive) |
|
| `iew` | Ends with (case-insensitive) |
|
||||||
| `niew` | Does not end with (case-insensitive) |
|
| `niew` | Does not end with (case-insensitive) |
|
||||||
| `ie` | Exact match (case-insensitive) |
|
| `ie` | Exact match (case-insensitive) |
|
||||||
| `nie` | Inverse exact match (case-insensitive) |
|
| `nie` | Inverse exact match (case-insensitive) |
|
||||||
| `empty` | Is empty/null (boolean) |
|
| `empty` | Is empty/null (boolean) |
|
||||||
|
| `regex` | Regexp matching |
|
||||||
|
| `iregex` | Regexp matching (case-insensitive) |
|
||||||
|
|
||||||
Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name:
|
Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name:
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
# NetBox v4.3
|
# NetBox v4.3
|
||||||
|
|
||||||
|
## v4.3.4 (2025-07-15)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#18811](https://github.com/netbox-community/netbox/issues/18811) - Match expanded form IPv6 addresses in global search
|
||||||
|
* [#19550](https://github.com/netbox-community/netbox/issues/19550) - Enable lazy loading for rack elevations
|
||||||
|
* [#19571](https://github.com/netbox-community/netbox/issues/19571) - Add a default module type profile for expansion cards
|
||||||
|
* [#19793](https://github.com/netbox-community/netbox/issues/19793) - Support custom dynamic navigation menu links
|
||||||
|
* [#19828](https://github.com/netbox-community/netbox/issues/19828) - Expose L2VPN termination in interface GraphQL response
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#19413](https://github.com/netbox-community/netbox/issues/19413) - Custom fields should be grouped in filter forms
|
||||||
|
* [#19633](https://github.com/netbox-community/netbox/issues/19633) - Introduce InvalidCondition exception and log all evaluations of invalid event rule conditions
|
||||||
|
* [#19800](https://github.com/netbox-community/netbox/issues/19800) - Module type bulk import should support profile assignment
|
||||||
|
* [#19806](https://github.com/netbox-community/netbox/issues/19806) - Introduce JobFailed exception to allow marking background jobs as failed
|
||||||
|
* [#19827](https://github.com/netbox-community/netbox/issues/19827) - Enforce uniqueness for device role names & slugs
|
||||||
|
* [#19839](https://github.com/netbox-community/netbox/issues/19839) - Enable export of parent assignment for recursively nested objects
|
||||||
|
* [#19876](https://github.com/netbox-community/netbox/issues/19876) - Remove Markdown rendering from CustomFieldChoiceSet description field
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v4.3.3 (2025-06-26)
|
## v4.3.3 (2025-06-26)
|
||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
|
|||||||
@@ -470,8 +470,8 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = ModuleType
|
model = ModuleType
|
||||||
fields = [
|
fields = [
|
||||||
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'comments',
|
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile',
|
||||||
'tags',
|
'comments', 'tags'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -1335,6 +1335,13 @@ class MACAddressImportForm(NetBoxModelImportForm):
|
|||||||
|
|
||||||
class CableImportForm(NetBoxModelImportForm):
|
class CableImportForm(NetBoxModelImportForm):
|
||||||
# Termination A
|
# Termination A
|
||||||
|
side_a_site = CSVModelChoiceField(
|
||||||
|
label=_('Side A site'),
|
||||||
|
queryset=Site.objects.all(),
|
||||||
|
required=False,
|
||||||
|
to_field_name='name',
|
||||||
|
help_text=_('Site of parent device A (if any)'),
|
||||||
|
)
|
||||||
side_a_device = CSVModelChoiceField(
|
side_a_device = CSVModelChoiceField(
|
||||||
label=_('Side A device'),
|
label=_('Side A device'),
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
@@ -1353,6 +1360,13 @@ class CableImportForm(NetBoxModelImportForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Termination B
|
# Termination B
|
||||||
|
side_b_site = CSVModelChoiceField(
|
||||||
|
label=_('Side B site'),
|
||||||
|
queryset=Site.objects.all(),
|
||||||
|
required=False,
|
||||||
|
to_field_name='name',
|
||||||
|
help_text=_('Site of parent device B (if any)'),
|
||||||
|
)
|
||||||
side_b_device = CSVModelChoiceField(
|
side_b_device = CSVModelChoiceField(
|
||||||
label=_('Side B device'),
|
label=_('Side B device'),
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
@@ -1396,14 +1410,39 @@ class CableImportForm(NetBoxModelImportForm):
|
|||||||
required=False,
|
required=False,
|
||||||
help_text=_('Length unit')
|
help_text=_('Length unit')
|
||||||
)
|
)
|
||||||
|
color = forms.CharField(
|
||||||
|
label=_('Color'),
|
||||||
|
required=False,
|
||||||
|
max_length=16,
|
||||||
|
help_text=_('Color name (e.g. "Red") or hex code (e.g. "f44336")')
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Cable
|
model = Cable
|
||||||
fields = [
|
fields = [
|
||||||
'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type',
|
'side_a_site', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_site', 'side_b_device', 'side_b_type',
|
||||||
'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
|
'side_b_name', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description',
|
||||||
|
'comments', 'tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
|
super().__init__(data, *args, **kwargs)
|
||||||
|
|
||||||
|
if data:
|
||||||
|
# Limit choices for side_a_device to the assigned side_a_site
|
||||||
|
if side_a_site := data.get('side_a_site'):
|
||||||
|
side_a_device_params = {f'site__{self.fields["side_a_site"].to_field_name}': side_a_site}
|
||||||
|
self.fields['side_a_device'].queryset = self.fields['side_a_device'].queryset.filter(
|
||||||
|
**side_a_device_params
|
||||||
|
)
|
||||||
|
|
||||||
|
# Limit choices for side_b_device to the assigned side_b_site
|
||||||
|
if side_b_site := data.get('side_b_site'):
|
||||||
|
side_b_device_params = {f'site__{self.fields["side_b_site"].to_field_name}': side_b_site}
|
||||||
|
self.fields['side_b_device'].queryset = self.fields['side_b_device'].queryset.filter(
|
||||||
|
**side_b_device_params
|
||||||
|
)
|
||||||
|
|
||||||
def _clean_side(self, side):
|
def _clean_side(self, side):
|
||||||
"""
|
"""
|
||||||
Derive a Cable's A/B termination objects.
|
Derive a Cable's A/B termination objects.
|
||||||
@@ -1440,6 +1479,24 @@ class CableImportForm(NetBoxModelImportForm):
|
|||||||
setattr(self.instance, f'{side}_terminations', [termination_object])
|
setattr(self.instance, f'{side}_terminations', [termination_object])
|
||||||
return termination_object
|
return termination_object
|
||||||
|
|
||||||
|
def _clean_color(self, color):
|
||||||
|
"""
|
||||||
|
Derive a colors hex code
|
||||||
|
|
||||||
|
:param color: color as hex or color name
|
||||||
|
"""
|
||||||
|
color_parsed = color.strip().lower()
|
||||||
|
|
||||||
|
for hex_code, label in ColorChoices.CHOICES:
|
||||||
|
if color.lower() == label.lower():
|
||||||
|
color_parsed = hex_code
|
||||||
|
|
||||||
|
if len(color_parsed) > 6:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
_(f"{color} did not match any used color name and was longer than six characters: invalid hex.")
|
||||||
|
)
|
||||||
|
return color_parsed
|
||||||
|
|
||||||
def clean_side_a_name(self):
|
def clean_side_a_name(self):
|
||||||
return self._clean_side('a')
|
return self._clean_side('a')
|
||||||
|
|
||||||
@@ -1451,11 +1508,14 @@ class CableImportForm(NetBoxModelImportForm):
|
|||||||
length_unit = self.cleaned_data.get('length_unit', None)
|
length_unit = self.cleaned_data.get('length_unit', None)
|
||||||
return length_unit if length_unit is not None else ''
|
return length_unit if length_unit is not None else ''
|
||||||
|
|
||||||
|
def clean_color(self):
|
||||||
|
color = self.cleaned_data.get('color', None)
|
||||||
|
return self._clean_color(color) if color is not None else ''
|
||||||
#
|
#
|
||||||
# Virtual chassis
|
# Virtual chassis
|
||||||
#
|
#
|
||||||
|
|
||||||
|
|
||||||
class VirtualChassisImportForm(NetBoxModelImportForm):
|
class VirtualChassisImportForm(NetBoxModelImportForm):
|
||||||
master = CSVModelChoiceField(
|
master = CSVModelChoiceField(
|
||||||
label=_('Master'),
|
label=_('Master'),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ if TYPE_CHECKING:
|
|||||||
from tenancy.graphql.types import TenantType
|
from tenancy.graphql.types import TenantType
|
||||||
from users.graphql.types import UserType
|
from users.graphql.types import UserType
|
||||||
from virtualization.graphql.types import ClusterType, VMInterfaceType, VirtualMachineType
|
from virtualization.graphql.types import ClusterType, VMInterfaceType, VirtualMachineType
|
||||||
|
from vpn.graphql.types import L2VPNTerminationType
|
||||||
from wireless.graphql.types import WirelessLANType, WirelessLinkType
|
from wireless.graphql.types import WirelessLANType, WirelessLinkType
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@@ -440,6 +441,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
|
|||||||
primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None
|
primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None
|
||||||
qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
|
qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
|
||||||
vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
|
vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
|
||||||
|
l2vpn_termination: Annotated["L2VPNTerminationType", strawberry.lazy('vpn.graphql.types')] | None
|
||||||
|
|
||||||
vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]]
|
vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]]
|
||||||
tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
|
tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
|
||||||
|
|||||||
44
netbox/dcim/migrations/0208_devicerole_uniqueness.py
Normal file
44
netbox/dcim/migrations/0208_devicerole_uniqueness.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0207_remove_redundant_indexes'),
|
||||||
|
('extras', '0129_fix_script_paths'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='devicerole',
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=('parent', 'name'),
|
||||||
|
name='dcim_devicerole_parent_name'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='devicerole',
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(('parent__isnull', True)),
|
||||||
|
fields=('name',),
|
||||||
|
name='dcim_devicerole_name',
|
||||||
|
violation_error_message='A top-level device role with this name already exists.'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='devicerole',
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=('parent', 'slug'),
|
||||||
|
name='dcim_devicerole_parent_slug'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='devicerole',
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(('parent__isnull', True)),
|
||||||
|
fields=('slug',),
|
||||||
|
name='dcim_devicerole_slug',
|
||||||
|
violation_error_message='A top-level device role with this slug already exists.'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -398,6 +398,28 @@ class DeviceRole(NestedGroupModel):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('name',)
|
ordering = ('name',)
|
||||||
|
constraints = (
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=('parent', 'name'),
|
||||||
|
name='%(app_label)s_%(class)s_parent_name'
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=('name',),
|
||||||
|
name='%(app_label)s_%(class)s_name',
|
||||||
|
condition=Q(parent__isnull=True),
|
||||||
|
violation_error_message=_("A top-level device role with this name already exists.")
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=('parent', 'slug'),
|
||||||
|
name='%(app_label)s_%(class)s_parent_slug'
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=('slug',),
|
||||||
|
name='%(app_label)s_%(class)s_slug',
|
||||||
|
condition=Q(parent__isnull=True),
|
||||||
|
violation_error_message=_("A top-level device role with this slug already exists.")
|
||||||
|
),
|
||||||
|
)
|
||||||
verbose_name = _('device role')
|
verbose_name = _('device role')
|
||||||
verbose_name_plural = _('device roles')
|
verbose_name_plural = _('device roles')
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import svgwrite
|
|||||||
from svgwrite.container import Hyperlink
|
from svgwrite.container import Hyperlink
|
||||||
from svgwrite.image import Image
|
from svgwrite.image import Image
|
||||||
from svgwrite.gradients import LinearGradient
|
from svgwrite.gradients import LinearGradient
|
||||||
|
from svgwrite.masking import ClipPath
|
||||||
from svgwrite.shapes import Rect
|
from svgwrite.shapes import Rect
|
||||||
from svgwrite.text import Text
|
from svgwrite.text import Text
|
||||||
|
|
||||||
@@ -67,6 +68,20 @@ def get_device_description(device):
|
|||||||
return description
|
return description
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_text(text, width, font_size=15):
|
||||||
|
"""
|
||||||
|
Truncate text to fit within the width of a rectangle.
|
||||||
|
|
||||||
|
:param text: The text to truncate
|
||||||
|
:param width: Width of rectangle
|
||||||
|
:param font_size: Font size (default is 15, ~0.875rem)
|
||||||
|
"""
|
||||||
|
char_width = font_size * 0.6 # 0.6 is an approximation of the average character width in pixels
|
||||||
|
max_char = int(width / char_width)
|
||||||
|
|
||||||
|
return text if len(text) <= max_char else text[:max_char] + '...'
|
||||||
|
|
||||||
|
|
||||||
class RackElevationSVG:
|
class RackElevationSVG:
|
||||||
"""
|
"""
|
||||||
Use this class to render a rack elevation as an SVG image.
|
Use this class to render a rack elevation as an SVG image.
|
||||||
@@ -177,12 +192,26 @@ class RackElevationSVG:
|
|||||||
link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target="_parent")
|
link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target="_parent")
|
||||||
link.set_desc(description)
|
link.set_desc(description)
|
||||||
|
|
||||||
|
# Create clipPath element
|
||||||
|
# This is necessary as fallback because the truncate_text method is an approximation
|
||||||
|
clip_id = f"clip-{device.id}"
|
||||||
|
clip_path = ClipPath(id=clip_id)
|
||||||
|
clip_path.add(Rect(coords, size))
|
||||||
|
|
||||||
|
self.drawing.defs.add(clip_path)
|
||||||
|
|
||||||
|
# Name to display
|
||||||
|
display_name = truncate_text(name, size[0])
|
||||||
|
|
||||||
# Add rect element to hyperlink
|
# Add rect element to hyperlink
|
||||||
if color:
|
if color:
|
||||||
link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}'))
|
link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}'))
|
||||||
else:
|
else:
|
||||||
link.add(Rect(coords, size, class_=f'slot blocked{css_extra}'))
|
link.add(Rect(coords, size, class_=f'slot blocked{css_extra}'))
|
||||||
link.add(Text(name, insert=text_coords, fill=text_color, class_=f'label{css_extra}'))
|
link.add(
|
||||||
|
Text(display_name, insert=text_coords, fill=text_color, clip_path=f"url(#{clip_id})",
|
||||||
|
class_=f'label{css_extra}')
|
||||||
|
)
|
||||||
|
|
||||||
# Embed device type image if provided
|
# Embed device type image if provided
|
||||||
if self.include_images and image:
|
if self.include_images and image:
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ class DeviceRoleTable(NetBoxTable):
|
|||||||
verbose_name=_('Name'),
|
verbose_name=_('Name'),
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
parent = tables.Column(
|
||||||
|
verbose_name=_('Parent'),
|
||||||
|
linkify=True,
|
||||||
|
)
|
||||||
device_count = columns.LinkedCountColumn(
|
device_count = columns.LinkedCountColumn(
|
||||||
viewname='dcim:device_list',
|
viewname='dcim:device_list',
|
||||||
url_params={'role_id': 'pk'},
|
url_params={'role_id': 'pk'},
|
||||||
@@ -88,8 +92,8 @@ class DeviceRoleTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = models.DeviceRole
|
model = models.DeviceRole
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'config_template', 'description',
|
'pk', 'id', 'name', 'parent', 'device_count', 'vm_count', 'color', 'vm_role', 'config_template',
|
||||||
'slug', 'tags', 'actions', 'created', 'last_updated',
|
'description', 'slug', 'tags', 'actions', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description')
|
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description')
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ class RegionTable(ContactsColumnMixin, NetBoxTable):
|
|||||||
verbose_name=_('Name'),
|
verbose_name=_('Name'),
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
parent = tables.Column(
|
||||||
|
verbose_name=_('Parent'),
|
||||||
|
linkify=True,
|
||||||
|
)
|
||||||
site_count = columns.LinkedCountColumn(
|
site_count = columns.LinkedCountColumn(
|
||||||
viewname='dcim:site_list',
|
viewname='dcim:site_list',
|
||||||
url_params={'region_id': 'pk'},
|
url_params={'region_id': 'pk'},
|
||||||
@@ -39,7 +43,7 @@ class RegionTable(ContactsColumnMixin, NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = Region
|
model = Region
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
|
'pk', 'id', 'name', 'parent', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
|
||||||
'created', 'last_updated', 'actions',
|
'created', 'last_updated', 'actions',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'site_count', 'description')
|
default_columns = ('pk', 'name', 'site_count', 'description')
|
||||||
@@ -54,6 +58,10 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
|
|||||||
verbose_name=_('Name'),
|
verbose_name=_('Name'),
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
parent = tables.Column(
|
||||||
|
verbose_name=_('Parent'),
|
||||||
|
linkify=True,
|
||||||
|
)
|
||||||
site_count = columns.LinkedCountColumn(
|
site_count = columns.LinkedCountColumn(
|
||||||
viewname='dcim:site_list',
|
viewname='dcim:site_list',
|
||||||
url_params={'group_id': 'pk'},
|
url_params={'group_id': 'pk'},
|
||||||
@@ -69,7 +77,7 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = SiteGroup
|
model = SiteGroup
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
|
'pk', 'id', 'name', 'parent', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
|
||||||
'created', 'last_updated', 'actions',
|
'created', 'last_updated', 'actions',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'site_count', 'description')
|
default_columns = ('pk', 'name', 'site_count', 'description')
|
||||||
@@ -135,6 +143,10 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
|||||||
verbose_name=_('Name'),
|
verbose_name=_('Name'),
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
parent = tables.Column(
|
||||||
|
verbose_name=_('Parent'),
|
||||||
|
linkify=True,
|
||||||
|
)
|
||||||
site = tables.Column(
|
site = tables.Column(
|
||||||
verbose_name=_('Site'),
|
verbose_name=_('Site'),
|
||||||
linkify=True
|
linkify=True
|
||||||
@@ -170,8 +182,8 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = Location
|
model = Location
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count',
|
'pk', 'id', 'name', 'parent', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count',
|
||||||
'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated',
|
'device_count', 'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated',
|
||||||
'vlangroup_count',
|
'vlangroup_count',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from decimal import Decimal
|
|||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from django.test import override_settings
|
from django.test import override_settings, tag
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from netaddr import EUI
|
from netaddr import EUI
|
||||||
|
|
||||||
@@ -1000,18 +1000,7 @@ inventory-items:
|
|||||||
self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
|
self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
|
||||||
|
|
||||||
|
|
||||||
# TODO: Change base class to PrimaryObjectViewTestCase
|
class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||||
# Blocked by absence of bulk import view for ModuleTypes
|
|
||||||
class ModuleTypeTestCase(
|
|
||||||
ViewTestCases.GetObjectViewTestCase,
|
|
||||||
ViewTestCases.GetObjectChangelogViewTestCase,
|
|
||||||
ViewTestCases.CreateObjectViewTestCase,
|
|
||||||
ViewTestCases.EditObjectViewTestCase,
|
|
||||||
ViewTestCases.DeleteObjectViewTestCase,
|
|
||||||
ViewTestCases.ListObjectsViewTestCase,
|
|
||||||
ViewTestCases.BulkEditObjectsViewTestCase,
|
|
||||||
ViewTestCases.BulkDeleteObjectsViewTestCase
|
|
||||||
):
|
|
||||||
model = ModuleType
|
model = ModuleType
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1023,7 +1012,7 @@ class ModuleTypeTestCase(
|
|||||||
)
|
)
|
||||||
Manufacturer.objects.bulk_create(manufacturers)
|
Manufacturer.objects.bulk_create(manufacturers)
|
||||||
|
|
||||||
ModuleType.objects.bulk_create([
|
module_types = ModuleType.objects.bulk_create([
|
||||||
ModuleType(model='Module Type 1', manufacturer=manufacturers[0]),
|
ModuleType(model='Module Type 1', manufacturer=manufacturers[0]),
|
||||||
ModuleType(model='Module Type 2', manufacturer=manufacturers[0]),
|
ModuleType(model='Module Type 2', manufacturer=manufacturers[0]),
|
||||||
ModuleType(model='Module Type 3', manufacturer=manufacturers[0]),
|
ModuleType(model='Module Type 3', manufacturer=manufacturers[0]),
|
||||||
@@ -1031,6 +1020,8 @@ class ModuleTypeTestCase(
|
|||||||
|
|
||||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||||
|
|
||||||
|
fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan')
|
||||||
|
|
||||||
cls.form_data = {
|
cls.form_data = {
|
||||||
'manufacturer': manufacturers[1].pk,
|
'manufacturer': manufacturers[1].pk,
|
||||||
'model': 'Device Type X',
|
'model': 'Device Type X',
|
||||||
@@ -1044,6 +1035,70 @@ class ModuleTypeTestCase(
|
|||||||
'part_number': '456DEF',
|
'part_number': '456DEF',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cls.csv_data = (
|
||||||
|
"manufacturer,model,part_number,comments,profile",
|
||||||
|
f"Manufacturer 1,fan0,generic-fan,,{fan_module_type_profile.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
cls.csv_update_data = (
|
||||||
|
"id,model",
|
||||||
|
f"{module_types[0].id},test model",
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
|
def test_bulk_update_objects_with_permission(self):
|
||||||
|
self.add_permissions(
|
||||||
|
'dcim.add_consoleporttemplate',
|
||||||
|
'dcim.add_consoleserverporttemplate',
|
||||||
|
'dcim.add_powerporttemplate',
|
||||||
|
'dcim.add_poweroutlettemplate',
|
||||||
|
'dcim.add_interfacetemplate',
|
||||||
|
'dcim.add_frontporttemplate',
|
||||||
|
'dcim.add_rearporttemplate',
|
||||||
|
'dcim.add_modulebaytemplate',
|
||||||
|
)
|
||||||
|
|
||||||
|
# run base test
|
||||||
|
super().test_bulk_update_objects_with_permission()
|
||||||
|
|
||||||
|
@tag('regression')
|
||||||
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
|
||||||
|
def test_bulk_import_objects_with_permission(self):
|
||||||
|
self.add_permissions(
|
||||||
|
'dcim.add_consoleporttemplate',
|
||||||
|
'dcim.add_consoleserverporttemplate',
|
||||||
|
'dcim.add_powerporttemplate',
|
||||||
|
'dcim.add_poweroutlettemplate',
|
||||||
|
'dcim.add_interfacetemplate',
|
||||||
|
'dcim.add_frontporttemplate',
|
||||||
|
'dcim.add_rearporttemplate',
|
||||||
|
'dcim.add_modulebaytemplate',
|
||||||
|
)
|
||||||
|
|
||||||
|
# run base test
|
||||||
|
super().test_bulk_import_objects_with_permission()
|
||||||
|
|
||||||
|
# TODO: remove extra regression asserts once parent test supports testing all import fields
|
||||||
|
fan_module_type = ModuleType.objects.get(part_number='generic-fan')
|
||||||
|
fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan')
|
||||||
|
|
||||||
|
assert fan_module_type.profile == fan_module_type_profile
|
||||||
|
|
||||||
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
|
||||||
|
def test_bulk_import_objects_with_constrained_permission(self):
|
||||||
|
self.add_permissions(
|
||||||
|
'dcim.add_consoleporttemplate',
|
||||||
|
'dcim.add_consoleserverporttemplate',
|
||||||
|
'dcim.add_powerporttemplate',
|
||||||
|
'dcim.add_poweroutlettemplate',
|
||||||
|
'dcim.add_interfacetemplate',
|
||||||
|
'dcim.add_frontporttemplate',
|
||||||
|
'dcim.add_rearporttemplate',
|
||||||
|
'dcim.add_modulebaytemplate',
|
||||||
|
)
|
||||||
|
|
||||||
|
super().test_bulk_import_objects_with_constrained_permission()
|
||||||
|
|
||||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
def test_moduletype_consoleports(self):
|
def test_moduletype_consoleports(self):
|
||||||
moduletype = ModuleType.objects.first()
|
moduletype = ModuleType.objects.first()
|
||||||
@@ -1804,9 +1859,9 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
|||||||
|
|
||||||
cls.csv_data = (
|
cls.csv_data = (
|
||||||
"name,slug,color",
|
"name,slug,color",
|
||||||
"Device Role 4,device-role-4,ff0000",
|
"Device Role 6,device-role-6,ff0000",
|
||||||
"Device Role 5,device-role-5,00ff00",
|
"Device Role 7,device-role-7,00ff00",
|
||||||
"Device Role 6,device-role-6,0000ff",
|
"Device Role 8,device-role-8,0000ff",
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.csv_update_data = (
|
cls.csv_update_data = (
|
||||||
@@ -3211,17 +3266,27 @@ class CableTestCase(
|
|||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
|
||||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
sites = (
|
||||||
|
Site(name='Site 1', slug='site-1'),
|
||||||
|
Site(name='Site 2', slug='site-2'),
|
||||||
|
)
|
||||||
|
Site.objects.bulk_create(sites)
|
||||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||||
devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
|
devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
|
||||||
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||||
vc = VirtualChassis.objects.create(name='Virtual Chassis')
|
vc = VirtualChassis.objects.create(name='Virtual Chassis')
|
||||||
|
|
||||||
|
# NOTE: By design, NetBox now allows for the creation of devices with the same name if they belong to
|
||||||
|
# different sites.
|
||||||
|
# The CSV test below demonstrates that devices with identical names on different sites can be created
|
||||||
|
# and referenced successfully.
|
||||||
devices = (
|
devices = (
|
||||||
Device(name='Device 1', site=site, device_type=devicetype, role=role),
|
# Create 'Device 1' assigned to 'Site 1'
|
||||||
Device(name='Device 2', site=site, device_type=devicetype, role=role),
|
Device(name='Device 1', site=sites[0], device_type=devicetype, role=role),
|
||||||
Device(name='Device 3', site=site, device_type=devicetype, role=role),
|
Device(name='Device 2', site=sites[0], device_type=devicetype, role=role),
|
||||||
Device(name='Device 4', site=site, device_type=devicetype, role=role),
|
Device(name='Device 3', site=sites[0], device_type=devicetype, role=role),
|
||||||
|
# Create 'Device 1' assigned to 'Site 2' (allowed since the site is different)
|
||||||
|
Device(name='Device 1', site=sites[1], device_type=devicetype, role=role),
|
||||||
)
|
)
|
||||||
Device.objects.bulk_create(devices)
|
Device.objects.bulk_create(devices)
|
||||||
|
|
||||||
@@ -3272,13 +3337,15 @@ class CableTestCase(
|
|||||||
'tags': [t.pk for t in tags],
|
'tags': [t.pk for t in tags],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Ensure that CSV bulk import supports assigning terminations from parent devices that share
|
||||||
|
# the same device name, provided those devices belong to different sites.
|
||||||
cls.csv_data = (
|
cls.csv_data = (
|
||||||
"side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name",
|
"side_a_site,side_a_device,side_a_type,side_a_name,side_b_site,side_b_device,side_b_type,side_b_name",
|
||||||
"Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1",
|
"Site 1,Device 3,dcim.interface,Interface 1,Site 2,Device 1,dcim.interface,Interface 1",
|
||||||
"Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
|
"Site 1,Device 3,dcim.interface,Interface 2,Site 2,Device 1,dcim.interface,Interface 2",
|
||||||
"Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
|
"Site 1,Device 3,dcim.interface,Interface 3,Site 2,Device 1,dcim.interface,Interface 3",
|
||||||
"Device 1,dcim.interface,Device 2 Interface,Device 4,dcim.interface,Interface 4",
|
"Site 1,Device 1,dcim.interface,Device 2 Interface,Site 2,Device 1,dcim.interface,Interface 4",
|
||||||
"Device 1,dcim.interface,Device 3 Interface,Device 4,dcim.interface,Interface 5",
|
"Site 1,Device 1,dcim.interface,Device 3 Interface,Site 2,Device 1,dcim.interface,Interface 5",
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.csv_update_data = (
|
cls.csv_update_data = (
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
from django.conf import settings
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.views.static import serve
|
||||||
from django_rq.queues import get_connection
|
from django_rq.queues import get_connection
|
||||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@@ -200,6 +202,17 @@ class ImageAttachmentViewSet(NetBoxModelViewSet):
|
|||||||
serializer_class = serializers.ImageAttachmentSerializer
|
serializer_class = serializers.ImageAttachmentSerializer
|
||||||
filterset_class = filtersets.ImageAttachmentFilterSet
|
filterset_class = filtersets.ImageAttachmentFilterSet
|
||||||
|
|
||||||
|
@action(
|
||||||
|
methods=['GET'],
|
||||||
|
detail=True,
|
||||||
|
url_path='download',
|
||||||
|
url_name='download',
|
||||||
|
)
|
||||||
|
def download(self, request, pk, *args, **kwargs):
|
||||||
|
obj = get_object_or_404(self.queryset, pk=pk)
|
||||||
|
# Render and return the elevation as an SVG drawing with the correct content type
|
||||||
|
return serve(request, obj.image.name, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Journal entries
|
# Journal entries
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import functools
|
import functools
|
||||||
|
import operator
|
||||||
import re
|
import re
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'Condition',
|
'Condition',
|
||||||
'ConditionSet',
|
'ConditionSet',
|
||||||
|
'InvalidCondition',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
AND = 'and'
|
AND = 'and'
|
||||||
OR = 'or'
|
OR = 'or'
|
||||||
|
|
||||||
@@ -19,6 +20,10 @@ def is_ruleset(data):
|
|||||||
return type(data) is dict and len(data) == 1 and list(data.keys())[0] in (AND, OR)
|
return type(data) is dict and len(data) == 1 and list(data.keys())[0] in (AND, OR)
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidCondition(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Condition:
|
class Condition:
|
||||||
"""
|
"""
|
||||||
An individual conditional rule that evaluates a single attribute and its value.
|
An individual conditional rule that evaluates a single attribute and its value.
|
||||||
@@ -61,6 +66,7 @@ class Condition:
|
|||||||
|
|
||||||
self.attr = attr
|
self.attr = attr
|
||||||
self.value = value
|
self.value = value
|
||||||
|
self.op = op
|
||||||
self.eval_func = getattr(self, f'eval_{op}')
|
self.eval_func = getattr(self, f'eval_{op}')
|
||||||
self.negate = negate
|
self.negate = negate
|
||||||
|
|
||||||
@@ -70,16 +76,17 @@ class Condition:
|
|||||||
"""
|
"""
|
||||||
def _get(obj, key):
|
def _get(obj, key):
|
||||||
if isinstance(obj, list):
|
if isinstance(obj, list):
|
||||||
return [dict.get(i, key) for i in obj]
|
return [operator.getitem(item or {}, key) for item in obj]
|
||||||
|
return operator.getitem(obj or {}, key)
|
||||||
return dict.get(obj, key)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
value = functools.reduce(_get, self.attr.split('.'), data)
|
value = functools.reduce(_get, self.attr.split('.'), data)
|
||||||
except TypeError:
|
except KeyError:
|
||||||
# Invalid key path
|
raise InvalidCondition(f"Invalid key path: {self.attr}")
|
||||||
value = None
|
try:
|
||||||
result = self.eval_func(value)
|
result = self.eval_func(value)
|
||||||
|
except TypeError as e:
|
||||||
|
raise InvalidCondition(f"Invalid data type at '{self.attr}' for '{self.op}' evaluation: {e}")
|
||||||
|
|
||||||
if self.negate:
|
if self.negate:
|
||||||
return not result
|
return not result
|
||||||
|
|||||||
@@ -192,5 +192,5 @@ def flush_events(events):
|
|||||||
try:
|
try:
|
||||||
func = import_string(name)
|
func = import_string(name)
|
||||||
func(events)
|
func(events)
|
||||||
except Exception as e:
|
except ImportError as e:
|
||||||
logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))
|
logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))
|
||||||
|
|||||||
@@ -18,9 +18,22 @@ class Empty(Lookup):
|
|||||||
return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
|
return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
|
||||||
|
|
||||||
|
|
||||||
|
class NetHost(Lookup):
|
||||||
|
"""
|
||||||
|
Similar to ipam.lookups.NetHost, but casts the field to INET.
|
||||||
|
"""
|
||||||
|
lookup_name = 'net_host'
|
||||||
|
|
||||||
|
def as_sql(self, qn, connection):
|
||||||
|
lhs, lhs_params = self.process_lhs(qn, connection)
|
||||||
|
rhs, rhs_params = self.process_rhs(qn, connection)
|
||||||
|
params = lhs_params + rhs_params
|
||||||
|
return 'HOST(CAST(%s AS INET)) = HOST(%s)' % (lhs, rhs), params
|
||||||
|
|
||||||
|
|
||||||
class NetContainsOrEquals(Lookup):
|
class NetContainsOrEquals(Lookup):
|
||||||
"""
|
"""
|
||||||
This lookup has the same functionality as the one from the ipam app except lhs is cast to inet
|
Similar to ipam.lookups.NetContainsOrEquals, but casts the field to INET.
|
||||||
"""
|
"""
|
||||||
lookup_name = 'net_contains_or_equals'
|
lookup_name = 'net_contains_or_equals'
|
||||||
|
|
||||||
@@ -32,4 +45,5 @@ class NetContainsOrEquals(Lookup):
|
|||||||
|
|
||||||
|
|
||||||
CharField.register_lookup(Empty)
|
CharField.register_lookup(Empty)
|
||||||
|
CachedValueField.register_lookup(NetHost)
|
||||||
CachedValueField.register_lookup(NetContainsOrEquals)
|
CachedValueField.register_lookup(NetContainsOrEquals)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from rest_framework.utils.encoders import JSONEncoder
|
|||||||
|
|
||||||
from core.models import ObjectType
|
from core.models import ObjectType
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.conditions import ConditionSet
|
from extras.conditions import ConditionSet, InvalidCondition
|
||||||
from extras.constants import *
|
from extras.constants import *
|
||||||
from extras.utils import image_upload
|
from extras.utils import image_upload
|
||||||
from extras.models.mixins import RenderTemplateMixin
|
from extras.models.mixins import RenderTemplateMixin
|
||||||
@@ -142,7 +142,15 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
|
|||||||
if not self.conditions:
|
if not self.conditions:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return ConditionSet(self.conditions).eval(data)
|
logger = logging.getLogger('netbox.event_rules')
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = ConditionSet(self.conditions).eval(data)
|
||||||
|
logger.debug(f'{self.name}: Evaluated as {result}')
|
||||||
|
return result
|
||||||
|
except InvalidCondition as e:
|
||||||
|
logger.error(f"{self.name}: Evaluation failed. {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
|
class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.files.base import File
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.timezone import make_aware, now
|
from django.utils.timezone import make_aware, now
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@@ -616,6 +618,38 @@ class ImageAttachmentTest(
|
|||||||
)
|
)
|
||||||
ImageAttachment.objects.bulk_create(image_attachments)
|
ImageAttachment.objects.bulk_create(image_attachments)
|
||||||
|
|
||||||
|
def test_image_download(self):
|
||||||
|
self.add_permissions('extras.view_imageattachment')
|
||||||
|
ct = ContentType.objects.get_for_model(Site)
|
||||||
|
site = Site.objects.get(name='Site 1', slug='site-1')
|
||||||
|
|
||||||
|
image = Image.new('RGB', size=(1, 1), color=(255, 0, 0))
|
||||||
|
image.save('test_image_download.png', format='PNG')
|
||||||
|
image_file = File(open('test_image_download.png', 'rb'))
|
||||||
|
content = image_file.read()
|
||||||
|
|
||||||
|
attachment = ImageAttachment(
|
||||||
|
object_type=ct,
|
||||||
|
object_id=site.pk,
|
||||||
|
name='Image Attachment 4',
|
||||||
|
image_height=1,
|
||||||
|
image_width=1
|
||||||
|
)
|
||||||
|
attachment.image.save('test_image_download.png', image_file, save=True)
|
||||||
|
attachment.save()
|
||||||
|
|
||||||
|
image = ImageAttachment.objects.get(name='Image Attachment 4')
|
||||||
|
url = reverse('extras-api:imageattachment-download', kwargs={'pk': image.pk})
|
||||||
|
response = self.client.get(url, **self.header)
|
||||||
|
downloaded_content = b''.join(response.streaming_content)
|
||||||
|
|
||||||
|
self.assertEqual(response.headers.get('Content-Type'), 'image/png')
|
||||||
|
self.assertEqual(response.headers.get('Content-Length'), '69')
|
||||||
|
self.assertEqual(
|
||||||
|
response.headers.get('Content-Disposition'), f'inline; filename="site_{site.pk}_Image_Attachment_4.png"'
|
||||||
|
)
|
||||||
|
self.assertEqual(content, downloaded_content)
|
||||||
|
|
||||||
|
|
||||||
class JournalEntryTest(APIViewTestCases.APIViewTestCase):
|
class JournalEntryTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = JournalEntry
|
model = JournalEntry
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from django.test import TestCase
|
|||||||
from core.events import *
|
from core.events import *
|
||||||
from dcim.choices import SiteStatusChoices
|
from dcim.choices import SiteStatusChoices
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from extras.conditions import Condition, ConditionSet
|
from extras.conditions import Condition, ConditionSet, InvalidCondition
|
||||||
from extras.events import serialize_for_event
|
from extras.events import serialize_for_event
|
||||||
from extras.forms import EventRuleForm
|
from extras.forms import EventRuleForm
|
||||||
from extras.models import EventRule, Webhook
|
from extras.models import EventRule, Webhook
|
||||||
@@ -12,16 +12,11 @@ from extras.models import EventRule, Webhook
|
|||||||
|
|
||||||
class ConditionTestCase(TestCase):
|
class ConditionTestCase(TestCase):
|
||||||
|
|
||||||
def test_dotted_path_access(self):
|
|
||||||
c = Condition('a.b.c', 1, 'eq')
|
|
||||||
self.assertTrue(c.eval({'a': {'b': {'c': 1}}}))
|
|
||||||
self.assertFalse(c.eval({'a': {'b': {'c': 2}}}))
|
|
||||||
self.assertFalse(c.eval({'a': {'b': {'x': 1}}}))
|
|
||||||
|
|
||||||
def test_undefined_attr(self):
|
def test_undefined_attr(self):
|
||||||
c = Condition('x', 1, 'eq')
|
c = Condition('x', 1, 'eq')
|
||||||
self.assertFalse(c.eval({}))
|
|
||||||
self.assertTrue(c.eval({'x': 1}))
|
self.assertTrue(c.eval({'x': 1}))
|
||||||
|
with self.assertRaises(InvalidCondition):
|
||||||
|
c.eval({})
|
||||||
|
|
||||||
#
|
#
|
||||||
# Validation tests
|
# Validation tests
|
||||||
@@ -37,10 +32,13 @@ class ConditionTestCase(TestCase):
|
|||||||
# dict type is unsupported
|
# dict type is unsupported
|
||||||
Condition('x', 1, dict())
|
Condition('x', 1, dict())
|
||||||
|
|
||||||
def test_invalid_op_type(self):
|
def test_invalid_op_types(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
# 'gt' supports only numeric values
|
# 'gt' supports only numeric values
|
||||||
Condition('x', 'foo', 'gt')
|
Condition('x', 'foo', 'gt')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
# 'in' supports only iterable values
|
||||||
|
Condition('x', 123, 'in')
|
||||||
|
|
||||||
#
|
#
|
||||||
# Nested attrs tests
|
# Nested attrs tests
|
||||||
@@ -50,7 +48,10 @@ class ConditionTestCase(TestCase):
|
|||||||
c = Condition('x.y.z', 1)
|
c = Condition('x.y.z', 1)
|
||||||
self.assertTrue(c.eval({'x': {'y': {'z': 1}}}))
|
self.assertTrue(c.eval({'x': {'y': {'z': 1}}}))
|
||||||
self.assertFalse(c.eval({'x': {'y': {'z': 2}}}))
|
self.assertFalse(c.eval({'x': {'y': {'z': 2}}}))
|
||||||
self.assertFalse(c.eval({'a': {'b': {'c': 1}}}))
|
with self.assertRaises(InvalidCondition):
|
||||||
|
c.eval({'x': {'y': None}})
|
||||||
|
with self.assertRaises(InvalidCondition):
|
||||||
|
c.eval({'x': {'y': {'a': 1}}})
|
||||||
|
|
||||||
#
|
#
|
||||||
# Operator tests
|
# Operator tests
|
||||||
@@ -74,23 +75,31 @@ class ConditionTestCase(TestCase):
|
|||||||
c = Condition('x', 1, 'gt')
|
c = Condition('x', 1, 'gt')
|
||||||
self.assertTrue(c.eval({'x': 2}))
|
self.assertTrue(c.eval({'x': 2}))
|
||||||
self.assertFalse(c.eval({'x': 1}))
|
self.assertFalse(c.eval({'x': 1}))
|
||||||
|
with self.assertRaises(InvalidCondition):
|
||||||
|
c.eval({'x': 'foo'}) # Invalid type
|
||||||
|
|
||||||
def test_gte(self):
|
def test_gte(self):
|
||||||
c = Condition('x', 1, 'gte')
|
c = Condition('x', 1, 'gte')
|
||||||
self.assertTrue(c.eval({'x': 2}))
|
self.assertTrue(c.eval({'x': 2}))
|
||||||
self.assertTrue(c.eval({'x': 1}))
|
self.assertTrue(c.eval({'x': 1}))
|
||||||
self.assertFalse(c.eval({'x': 0}))
|
self.assertFalse(c.eval({'x': 0}))
|
||||||
|
with self.assertRaises(InvalidCondition):
|
||||||
|
c.eval({'x': 'foo'}) # Invalid type
|
||||||
|
|
||||||
def test_lt(self):
|
def test_lt(self):
|
||||||
c = Condition('x', 2, 'lt')
|
c = Condition('x', 2, 'lt')
|
||||||
self.assertTrue(c.eval({'x': 1}))
|
self.assertTrue(c.eval({'x': 1}))
|
||||||
self.assertFalse(c.eval({'x': 2}))
|
self.assertFalse(c.eval({'x': 2}))
|
||||||
|
with self.assertRaises(InvalidCondition):
|
||||||
|
c.eval({'x': 'foo'}) # Invalid type
|
||||||
|
|
||||||
def test_lte(self):
|
def test_lte(self):
|
||||||
c = Condition('x', 2, 'lte')
|
c = Condition('x', 2, 'lte')
|
||||||
self.assertTrue(c.eval({'x': 1}))
|
self.assertTrue(c.eval({'x': 1}))
|
||||||
self.assertTrue(c.eval({'x': 2}))
|
self.assertTrue(c.eval({'x': 2}))
|
||||||
self.assertFalse(c.eval({'x': 3}))
|
self.assertFalse(c.eval({'x': 3}))
|
||||||
|
with self.assertRaises(InvalidCondition):
|
||||||
|
c.eval({'x': 'foo'}) # Invalid type
|
||||||
|
|
||||||
def test_in(self):
|
def test_in(self):
|
||||||
c = Condition('x', [1, 2, 3], 'in')
|
c = Condition('x', [1, 2, 3], 'in')
|
||||||
@@ -106,6 +115,8 @@ class ConditionTestCase(TestCase):
|
|||||||
c = Condition('x', 1, 'contains')
|
c = Condition('x', 1, 'contains')
|
||||||
self.assertTrue(c.eval({'x': [1, 2, 3]}))
|
self.assertTrue(c.eval({'x': [1, 2, 3]}))
|
||||||
self.assertFalse(c.eval({'x': [2, 3, 4]}))
|
self.assertFalse(c.eval({'x': [2, 3, 4]}))
|
||||||
|
with self.assertRaises(InvalidCondition):
|
||||||
|
c.eval({'x': 123}) # Invalid type
|
||||||
|
|
||||||
def test_contains_negated(self):
|
def test_contains_negated(self):
|
||||||
c = Condition('x', 1, 'contains', negate=True)
|
c = Condition('x', 1, 'contains', negate=True)
|
||||||
|
|||||||
@@ -162,6 +162,11 @@ class Aggregate(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
|
|||||||
return self.prefix.version
|
return self.prefix.version
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ipv6_full(self):
|
||||||
|
if self.prefix and self.prefix.version == 6:
|
||||||
|
return netaddr.IPAddress(self.prefix).format(netaddr.ipv6_full)
|
||||||
|
|
||||||
def get_child_prefixes(self):
|
def get_child_prefixes(self):
|
||||||
"""
|
"""
|
||||||
Return all Prefixes within this Aggregate
|
Return all Prefixes within this Aggregate
|
||||||
@@ -330,6 +335,11 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
|
|||||||
def mask_length(self):
|
def mask_length(self):
|
||||||
return self.prefix.prefixlen if self.prefix else None
|
return self.prefix.prefixlen if self.prefix else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ipv6_full(self):
|
||||||
|
if self.prefix and self.prefix.version == 6:
|
||||||
|
return netaddr.IPAddress(self.prefix).format(netaddr.ipv6_full)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def depth(self):
|
def depth(self):
|
||||||
return self._depth
|
return self._depth
|
||||||
@@ -808,6 +818,11 @@ class IPAddress(ContactsMixin, PrimaryModel):
|
|||||||
self._original_assigned_object_id = self.__dict__.get('assigned_object_id')
|
self._original_assigned_object_id = self.__dict__.get('assigned_object_id')
|
||||||
self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id')
|
self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ipv6_full(self):
|
||||||
|
if self.address and self.address.version == 6:
|
||||||
|
return netaddr.IPAddress(self.address).format(netaddr.ipv6_full)
|
||||||
|
|
||||||
def get_duplicates(self):
|
def get_duplicates(self):
|
||||||
return IPAddress.objects.filter(
|
return IPAddress.objects.filter(
|
||||||
vrf=self.vrf,
|
vrf=self.vrf,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Sequence, Optional
|
from typing import Sequence, Optional
|
||||||
|
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'get_model_item',
|
'get_model_item',
|
||||||
@@ -22,20 +24,46 @@ class MenuItemButton:
|
|||||||
link: str
|
link: str
|
||||||
title: str
|
title: str
|
||||||
icon_class: str
|
icon_class: str
|
||||||
|
_url: Optional[str] = None
|
||||||
permissions: Optional[Sequence[str]] = ()
|
permissions: Optional[Sequence[str]] = ()
|
||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.link:
|
||||||
|
self._url = reverse_lazy(self.link)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
return self._url
|
||||||
|
|
||||||
|
@url.setter
|
||||||
|
def url(self, value):
|
||||||
|
self._url = value
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MenuItem:
|
class MenuItem:
|
||||||
|
|
||||||
link: str
|
link: str
|
||||||
link_text: str
|
link_text: str
|
||||||
|
_url: Optional[str] = None
|
||||||
permissions: Optional[Sequence[str]] = ()
|
permissions: Optional[Sequence[str]] = ()
|
||||||
auth_required: Optional[bool] = False
|
auth_required: Optional[bool] = False
|
||||||
staff_only: Optional[bool] = False
|
staff_only: Optional[bool] = False
|
||||||
buttons: Optional[Sequence[MenuItemButton]] = ()
|
buttons: Optional[Sequence[MenuItemButton]] = ()
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.link:
|
||||||
|
self._url = reverse_lazy(self.link)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
return self._url
|
||||||
|
|
||||||
|
@url.setter
|
||||||
|
def url(self, value):
|
||||||
|
self._url = value
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MenuGroup:
|
class MenuGroup:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from django.urls import reverse_lazy
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
@@ -32,17 +33,23 @@ class PluginMenuItem:
|
|||||||
This class represents a navigation menu item. This constitutes primary link and its text, but also allows for
|
This class represents a navigation menu item. This constitutes primary link and its text, but also allows for
|
||||||
specifying additional link buttons that appear to the right of the item in the van menu.
|
specifying additional link buttons that appear to the right of the item in the van menu.
|
||||||
|
|
||||||
Links are specified as Django reverse URL strings.
|
Links are specified as Django reverse URL strings suitable for rendering via {% url item.link %}.
|
||||||
|
Alternatively, a pre-generated url can be set on the object which will be rendered literally.
|
||||||
Buttons are each specified as a list of PluginMenuButton instances.
|
Buttons are each specified as a list of PluginMenuButton instances.
|
||||||
"""
|
"""
|
||||||
permissions = []
|
permissions = []
|
||||||
buttons = []
|
buttons = []
|
||||||
|
_url = None
|
||||||
|
|
||||||
def __init__(self, link, link_text, auth_required=False, staff_only=False, permissions=None, buttons=None):
|
def __init__(
|
||||||
|
self, link, link_text, auth_required=False, staff_only=False, permissions=None, buttons=None
|
||||||
|
):
|
||||||
self.link = link
|
self.link = link
|
||||||
self.link_text = link_text
|
self.link_text = link_text
|
||||||
self.auth_required = auth_required
|
self.auth_required = auth_required
|
||||||
self.staff_only = staff_only
|
self.staff_only = staff_only
|
||||||
|
if link:
|
||||||
|
self._url = reverse_lazy(link)
|
||||||
if permissions is not None:
|
if permissions is not None:
|
||||||
if type(permissions) not in (list, tuple):
|
if type(permissions) not in (list, tuple):
|
||||||
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
||||||
@@ -52,6 +59,14 @@ class PluginMenuItem:
|
|||||||
raise TypeError(_("Buttons must be passed as a tuple or list."))
|
raise TypeError(_("Buttons must be passed as a tuple or list."))
|
||||||
self.buttons = buttons
|
self.buttons = buttons
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
return self._url
|
||||||
|
|
||||||
|
@url.setter
|
||||||
|
def url(self, value):
|
||||||
|
self._url = value
|
||||||
|
|
||||||
|
|
||||||
class PluginMenuButton:
|
class PluginMenuButton:
|
||||||
"""
|
"""
|
||||||
@@ -60,11 +75,14 @@ class PluginMenuButton:
|
|||||||
"""
|
"""
|
||||||
color = ButtonColorChoices.DEFAULT
|
color = ButtonColorChoices.DEFAULT
|
||||||
permissions = []
|
permissions = []
|
||||||
|
_url = None
|
||||||
|
|
||||||
def __init__(self, link, title, icon_class, color=None, permissions=None):
|
def __init__(self, link, title, icon_class, color=None, permissions=None):
|
||||||
self.link = link
|
self.link = link
|
||||||
self.title = title
|
self.title = title
|
||||||
self.icon_class = icon_class
|
self.icon_class = icon_class
|
||||||
|
if link:
|
||||||
|
self._url = reverse_lazy(link)
|
||||||
if permissions is not None:
|
if permissions is not None:
|
||||||
if type(permissions) not in (list, tuple):
|
if type(permissions) not in (list, tuple):
|
||||||
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
||||||
@@ -73,3 +91,11 @@ class PluginMenuButton:
|
|||||||
if color not in ButtonColorChoices.values():
|
if color not in ButtonColorChoices.values():
|
||||||
raise ValueError(_("Button color must be a choice within ButtonColorChoices."))
|
raise ValueError(_("Button color must be a choice within ButtonColorChoices."))
|
||||||
self.color = color
|
self.color = color
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
return self._url
|
||||||
|
|
||||||
|
@url.setter
|
||||||
|
def url(self, value):
|
||||||
|
self._url = value
|
||||||
|
|||||||
@@ -115,11 +115,13 @@ class CachedValueSearchBackend(SearchBackend):
|
|||||||
if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH):
|
if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH):
|
||||||
# "Starts/ends with" matches are valid only on string values
|
# "Starts/ends with" matches are valid only on string values
|
||||||
query_filter &= Q(type=FieldTypes.STRING)
|
query_filter &= Q(type=FieldTypes.STRING)
|
||||||
elif lookup == LookupTypes.PARTIAL:
|
elif lookup in (LookupTypes.PARTIAL, LookupTypes.EXACT):
|
||||||
try:
|
try:
|
||||||
# If the value looks like an IP address, add an extra match for CIDR values
|
# If the value looks like an IP address, add extra filters for CIDR/INET values
|
||||||
address = str(netaddr.IPNetwork(value.strip()).cidr)
|
address = str(netaddr.IPNetwork(value.strip()).cidr)
|
||||||
query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address)
|
query_filter |= Q(type=FieldTypes.INET) & Q(value__net_host=address)
|
||||||
|
if lookup == LookupTypes.PARTIAL:
|
||||||
|
query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address)
|
||||||
except (AddrFormatError, ValueError):
|
except (AddrFormatError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
2
netbox/project-static/dist/netbox.css
vendored
2
netbox/project-static/dist/netbox.css
vendored
File diff suppressed because one or more lines are too long
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
@@ -23,13 +23,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/font": "7.4.47",
|
"@mdi/font": "7.4.47",
|
||||||
"@tabler/core": "1.3.2",
|
"@tabler/core": "1.4.0",
|
||||||
"bootstrap": "5.3.7",
|
"bootstrap": "5.3.7",
|
||||||
"clipboard": "2.0.11",
|
"clipboard": "2.0.11",
|
||||||
"flatpickr": "4.6.13",
|
"flatpickr": "4.6.13",
|
||||||
"gridstack": "12.2.1",
|
"gridstack": "12.2.2",
|
||||||
"htmx.org": "2.0.5",
|
"htmx.org": "2.0.6",
|
||||||
"query-string": "9.2.1",
|
"query-string": "9.2.2",
|
||||||
"sass": "1.89.2",
|
"sass": "1.89.2",
|
||||||
"tom-select": "2.4.3",
|
"tom-select": "2.4.3",
|
||||||
"typeface-inter": "3.18.1",
|
"typeface-inter": "3.18.1",
|
||||||
@@ -39,15 +39,15 @@
|
|||||||
"@types/bootstrap": "5.2.10",
|
"@types/bootstrap": "5.2.10",
|
||||||
"@types/cookie": "^0.6.0",
|
"@types/cookie": "^0.6.0",
|
||||||
"@types/node": "^22.3.0",
|
"@types/node": "^22.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.1.0",
|
"@typescript-eslint/eslint-plugin": "^8.37.0",
|
||||||
"@typescript-eslint/parser": "^8.1.0",
|
"@typescript-eslint/parser": "^8.37.0",
|
||||||
"esbuild": "^0.25.3",
|
"esbuild": "^0.25.6",
|
||||||
"esbuild-sass-plugin": "^3.3.1",
|
"esbuild-sass-plugin": "^3.3.1",
|
||||||
"eslint": "<9.0",
|
"eslint": "<9.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-import-resolver-typescript": "^3.6.3",
|
"eslint-import-resolver-typescript": "^3.6.3",
|
||||||
"eslint-plugin-import": "^2.30.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"eslint-plugin-prettier": "^5.2.1",
|
"eslint-plugin-prettier": "^5.5.1",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"typescript": "<5.5"
|
"typescript": "<5.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function showRackElements(
|
|||||||
selector: string,
|
selector: string,
|
||||||
elevation: HTMLObjectElement,
|
elevation: HTMLObjectElement,
|
||||||
): void {
|
): void {
|
||||||
const elements = elevation.contentDocument?.querySelectorAll(selector) ?? [];
|
const elements = elevation.querySelectorAll(selector) ?? [];
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
element.classList.remove('hidden');
|
element.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ function hideRackElements(
|
|||||||
selector: string,
|
selector: string,
|
||||||
elevation: HTMLObjectElement,
|
elevation: HTMLObjectElement,
|
||||||
): void {
|
): void {
|
||||||
const elements = elevation.contentDocument?.querySelectorAll(selector) ?? [];
|
const elements = elevation.querySelectorAll(selector) ?? [];
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
element.classList.add('hidden');
|
element.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,3 @@
|
|||||||
version: "4.3.3"
|
version: "4.3.4"
|
||||||
edition: "Community"
|
edition: "Community"
|
||||||
published: "2025-06-26"
|
published: "2025-07-15"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
<div style="margin-left: -30px">
|
<div style="margin-left: -30px" class="rack_elevation">
|
||||||
<div
|
<div
|
||||||
hx-get="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{ face }}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}"
|
hx-get="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{ face }}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}"
|
||||||
hx-trigger="intersect"
|
hx-trigger="intersect"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Description</th>
|
<th scope="row">Description</th>
|
||||||
<td>{{ object.description|markdown|placeholder }}</td>
|
<td>{{ object.description|placeholder }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Base Choices</th>
|
<th scope="row">Base Choices</th>
|
||||||
|
|||||||
@@ -29,11 +29,7 @@
|
|||||||
<div class="hr-text">
|
<div class="hr-text">
|
||||||
<span>{% trans "Custom Fields" %}</span>
|
<span>{% trans "Custom Fields" %}</span>
|
||||||
</div>
|
</div>
|
||||||
{% for name in filter_form.custom_fields %}
|
{% render_custom_fields filter_form %}
|
||||||
{% with field=filter_form|get_item:name %}
|
|
||||||
{% render_field field %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,12 +45,17 @@ class TenantBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
queryset=TenantGroup.objects.all(),
|
queryset=TenantGroup.objects.all(),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
description = forms.CharField(
|
||||||
|
label=_('Description'),
|
||||||
|
max_length=200,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
model = Tenant
|
model = Tenant
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('group'),
|
FieldSet('group', 'description'),
|
||||||
)
|
)
|
||||||
nullable_fields = ('group',)
|
nullable_fields = ('group', 'description')
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ class ContactGroupTable(NetBoxTable):
|
|||||||
verbose_name=_('Name'),
|
verbose_name=_('Name'),
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
parent = tables.Column(
|
||||||
|
verbose_name=_('Parent'),
|
||||||
|
linkify=True,
|
||||||
|
)
|
||||||
contact_count = columns.LinkedCountColumn(
|
contact_count = columns.LinkedCountColumn(
|
||||||
viewname='tenancy:contact_list',
|
viewname='tenancy:contact_list',
|
||||||
url_params={'group_id': 'pk'},
|
url_params={'group_id': 'pk'},
|
||||||
@@ -34,7 +38,7 @@ class ContactGroupTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = ContactGroup
|
model = ContactGroup
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'name', 'contact_count', 'description', 'comments', 'slug', 'tags', 'created',
|
'pk', 'name', 'parent', 'contact_count', 'description', 'comments', 'slug', 'tags', 'created',
|
||||||
'last_updated', 'actions',
|
'last_updated', 'actions',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'contact_count', 'description')
|
default_columns = ('pk', 'name', 'contact_count', 'description')
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ class TenantGroupTable(NetBoxTable):
|
|||||||
verbose_name=_('Name'),
|
verbose_name=_('Name'),
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
parent = tables.Column(
|
||||||
|
verbose_name=_('Parent'),
|
||||||
|
linkify=True,
|
||||||
|
)
|
||||||
tenant_count = columns.LinkedCountColumn(
|
tenant_count = columns.LinkedCountColumn(
|
||||||
viewname='tenancy:tenant_list',
|
viewname='tenancy:tenant_list',
|
||||||
url_params={'group_id': 'pk'},
|
url_params={'group_id': 'pk'},
|
||||||
@@ -31,7 +35,7 @@ class TenantGroupTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = TenantGroup
|
model = TenantGroup
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'tenant_count', 'description', 'comments', 'slug', 'tags', 'created',
|
'pk', 'id', 'name', 'parent', 'tenant_count', 'description', 'comments', 'slug', 'tags', 'created',
|
||||||
'last_updated', 'actions',
|
'last_updated', 'actions',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'tenant_count', 'description')
|
default_columns = ('pk', 'name', 'tenant_count', 'description')
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
|
|
||||||
cls.bulk_edit_data = {
|
cls.bulk_edit_data = {
|
||||||
'group': tenant_groups[1].pk,
|
'group': tenant_groups[1].pk,
|
||||||
|
'description': 'Bulk edit description',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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
@@ -13,6 +13,8 @@ FILTER_CHAR_BASED_LOOKUP_MAP = dict(
|
|||||||
ie='iexact',
|
ie='iexact',
|
||||||
nie='iexact',
|
nie='iexact',
|
||||||
empty='empty',
|
empty='empty',
|
||||||
|
regex='regex',
|
||||||
|
iregex='iregex',
|
||||||
)
|
)
|
||||||
|
|
||||||
FILTER_NUMERIC_BASED_LOOKUP_MAP = dict(
|
FILTER_NUMERIC_BASED_LOOKUP_MAP = dict(
|
||||||
|
|||||||
@@ -41,11 +41,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% for item, buttons in items %}
|
{% for item, buttons in items %}
|
||||||
<div class="dropdown-item d-flex justify-content-between ps-3 py-0">
|
<div class="dropdown-item d-flex justify-content-between ps-3 py-0">
|
||||||
<a href="{% url item.link %}" class="d-inline-flex flex-fill py-1">{{ item.link_text }}</a>
|
<a href="{{ item.url }}" class="d-inline-flex flex-fill py-1">{{ item.link_text }}</a>
|
||||||
{% if buttons %}
|
{% if buttons %}
|
||||||
<div class="btn-group ms-1">
|
<div class="btn-group ms-1">
|
||||||
{% for button in buttons %}
|
{% for button in buttons %}
|
||||||
<a href="{% url button.link %}" class="btn btn-sm btn-{{ button.color|default:"outline" }} lh-2 px-2" title="{{ button.title }}">
|
<a href="{{ button.url }}" class="btn btn-sm btn-{{ button.color|default:"outline" }} lh-2 px-2" title="{{ button.title }}">
|
||||||
<i class="{{ button.icon_class }}"></i>
|
<i class="{{ button.icon_class }}"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -180,6 +180,10 @@ class BaseFilterSetTest(TestCase):
|
|||||||
self.assertEqual(self.filters['charfield__niew'].exclude, True)
|
self.assertEqual(self.filters['charfield__niew'].exclude, True)
|
||||||
self.assertEqual(self.filters['charfield__empty'].lookup_expr, 'empty')
|
self.assertEqual(self.filters['charfield__empty'].lookup_expr, 'empty')
|
||||||
self.assertEqual(self.filters['charfield__empty'].exclude, False)
|
self.assertEqual(self.filters['charfield__empty'].exclude, False)
|
||||||
|
self.assertEqual(self.filters['charfield__regex'].lookup_expr, 'regex')
|
||||||
|
self.assertEqual(self.filters['charfield__regex'].exclude, False)
|
||||||
|
self.assertEqual(self.filters['charfield__iregex'].lookup_expr, 'iregex')
|
||||||
|
self.assertEqual(self.filters['charfield__iregex'].exclude, False)
|
||||||
|
|
||||||
def test_number_filter(self):
|
def test_number_filter(self):
|
||||||
self.assertIsInstance(self.filters['numberfield'], django_filters.NumberFilter)
|
self.assertIsInstance(self.filters['numberfield'], django_filters.NumberFilter)
|
||||||
@@ -220,6 +224,10 @@ class BaseFilterSetTest(TestCase):
|
|||||||
self.assertEqual(self.filters['macaddressfield__iew'].exclude, False)
|
self.assertEqual(self.filters['macaddressfield__iew'].exclude, False)
|
||||||
self.assertEqual(self.filters['macaddressfield__niew'].lookup_expr, 'iendswith')
|
self.assertEqual(self.filters['macaddressfield__niew'].lookup_expr, 'iendswith')
|
||||||
self.assertEqual(self.filters['macaddressfield__niew'].exclude, True)
|
self.assertEqual(self.filters['macaddressfield__niew'].exclude, True)
|
||||||
|
self.assertEqual(self.filters['macaddressfield__regex'].lookup_expr, 'regex')
|
||||||
|
self.assertEqual(self.filters['macaddressfield__regex'].exclude, False)
|
||||||
|
self.assertEqual(self.filters['macaddressfield__iregex'].lookup_expr, 'iregex')
|
||||||
|
self.assertEqual(self.filters['macaddressfield__iregex'].exclude, False)
|
||||||
|
|
||||||
def test_model_choice_filter(self):
|
def test_model_choice_filter(self):
|
||||||
self.assertIsInstance(self.filters['modelchoicefield'], django_filters.ModelChoiceFilter)
|
self.assertIsInstance(self.filters['modelchoicefield'], django_filters.ModelChoiceFilter)
|
||||||
@@ -257,6 +265,10 @@ class BaseFilterSetTest(TestCase):
|
|||||||
self.assertEqual(self.filters['multivaluecharfield__iew'].exclude, False)
|
self.assertEqual(self.filters['multivaluecharfield__iew'].exclude, False)
|
||||||
self.assertEqual(self.filters['multivaluecharfield__niew'].lookup_expr, 'iendswith')
|
self.assertEqual(self.filters['multivaluecharfield__niew'].lookup_expr, 'iendswith')
|
||||||
self.assertEqual(self.filters['multivaluecharfield__niew'].exclude, True)
|
self.assertEqual(self.filters['multivaluecharfield__niew'].exclude, True)
|
||||||
|
self.assertEqual(self.filters['multivaluecharfield__regex'].lookup_expr, 'regex')
|
||||||
|
self.assertEqual(self.filters['multivaluecharfield__regex'].exclude, False)
|
||||||
|
self.assertEqual(self.filters['multivaluecharfield__iregex'].lookup_expr, 'iregex')
|
||||||
|
self.assertEqual(self.filters['multivaluecharfield__iregex'].exclude, False)
|
||||||
|
|
||||||
def test_multi_value_date_filter(self):
|
def test_multi_value_date_filter(self):
|
||||||
self.assertIsInstance(self.filters['datefield'], MultiValueDateFilter)
|
self.assertIsInstance(self.filters['datefield'], MultiValueDateFilter)
|
||||||
@@ -340,6 +352,10 @@ class BaseFilterSetTest(TestCase):
|
|||||||
self.assertEqual(self.filters['multiplechoicefield__iew'].exclude, False)
|
self.assertEqual(self.filters['multiplechoicefield__iew'].exclude, False)
|
||||||
self.assertEqual(self.filters['multiplechoicefield__niew'].lookup_expr, 'iendswith')
|
self.assertEqual(self.filters['multiplechoicefield__niew'].lookup_expr, 'iendswith')
|
||||||
self.assertEqual(self.filters['multiplechoicefield__niew'].exclude, True)
|
self.assertEqual(self.filters['multiplechoicefield__niew'].exclude, True)
|
||||||
|
self.assertEqual(self.filters['multiplechoicefield__regex'].lookup_expr, 'regex')
|
||||||
|
self.assertEqual(self.filters['multiplechoicefield__regex'].exclude, False)
|
||||||
|
self.assertEqual(self.filters['multiplechoicefield__iregex'].lookup_expr, 'iregex')
|
||||||
|
self.assertEqual(self.filters['multiplechoicefield__iregex'].exclude, False)
|
||||||
|
|
||||||
def test_tag_filter(self):
|
def test_tag_filter(self):
|
||||||
self.assertIsInstance(self.filters['tagfield'], TagFilter)
|
self.assertIsInstance(self.filters['tagfield'], TagFilter)
|
||||||
@@ -534,6 +550,14 @@ class DynamicFilterLookupExpressionTest(TestCase):
|
|||||||
params = {'slug__niew': ['-1']}
|
params = {'slug__niew': ['-1']}
|
||||||
self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2)
|
self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_site_slug_regex(self):
|
||||||
|
params = {'slug__regex': ['^def-[a-z]*-2$']}
|
||||||
|
self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 1)
|
||||||
|
|
||||||
|
def test_site_slug_iregex(self):
|
||||||
|
params = {'slug__iregex': ['^DEF-[a-z]*-2$']}
|
||||||
|
self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 1)
|
||||||
|
|
||||||
def test_provider_asn_lt(self):
|
def test_provider_asn_lt(self):
|
||||||
params = {'asn__lt': [65101]}
|
params = {'asn__lt': [65101]}
|
||||||
self.assertEqual(ASNFilterSet(params, ASN.objects.all()).qs.count(), 1)
|
self.assertEqual(ASNFilterSet(params, ASN.objects.all()).qs.count(), 1)
|
||||||
@@ -618,6 +642,14 @@ class DynamicFilterLookupExpressionTest(TestCase):
|
|||||||
params = {'mac_address__nic': ['aa:', 'bb']}
|
params = {'mac_address__nic': ['aa:', 'bb']}
|
||||||
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1)
|
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1)
|
||||||
|
|
||||||
|
def test_device_mac_address_regex(self):
|
||||||
|
params = {'mac_address__regex': ['^cc.*:03$']}
|
||||||
|
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1)
|
||||||
|
|
||||||
|
def test_device_mac_address_iregex(self):
|
||||||
|
params = {'mac_address__iregex': ['^CC.*:03$']}
|
||||||
|
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1)
|
||||||
|
|
||||||
def test_interface_rf_role_empty(self):
|
def test_interface_rf_role_empty(self):
|
||||||
params = {'rf_role__empty': 'true'}
|
params = {'rf_role__empty': 'true'}
|
||||||
self.assertEqual(InterfaceFilterSet(params, Interface.objects.all()).qs.count(), 5)
|
self.assertEqual(InterfaceFilterSet(params, Interface.objects.all()).qs.count(), 5)
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ class WirelessLANGroupTable(NetBoxTable):
|
|||||||
verbose_name=_('Name'),
|
verbose_name=_('Name'),
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
parent = tables.Column(
|
||||||
|
verbose_name=_('Parent'),
|
||||||
|
linkify=True,
|
||||||
|
)
|
||||||
wirelesslan_count = columns.LinkedCountColumn(
|
wirelesslan_count = columns.LinkedCountColumn(
|
||||||
viewname='wireless:wirelesslan_list',
|
viewname='wireless:wirelesslan_list',
|
||||||
url_params={'group_id': 'pk'},
|
url_params={'group_id': 'pk'},
|
||||||
@@ -33,8 +37,8 @@ class WirelessLANGroupTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = WirelessLANGroup
|
model = WirelessLANGroup
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'name', 'wirelesslan_count', 'slug', 'description', 'comments', 'tags', 'created', 'last_updated',
|
'pk', 'name', 'parent', 'slug', 'description', 'comments', 'tags', 'wirelesslan_count', 'created',
|
||||||
'actions',
|
'last_updated', 'actions',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'wirelesslan_count', 'description')
|
default_columns = ('pk', 'name', 'wirelesslan_count', 'description')
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox"
|
name = "netbox"
|
||||||
version = "4.3.3"
|
version = "4.3.4"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "NetBox Community" }
|
{ name = "NetBox Community" }
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
Django==5.2.3
|
Django==5.2.4
|
||||||
django-cors-headers==4.7.0
|
django-cors-headers==4.7.0
|
||||||
django-debug-toolbar==5.2.0
|
django-debug-toolbar==5.2.0
|
||||||
django-filter==25.1
|
django-filter==25.1
|
||||||
django-htmx==1.23.1
|
|
||||||
django-graphiql-debug-toolbar==0.2.0
|
django-graphiql-debug-toolbar==0.2.0
|
||||||
|
django-htmx==1.23.2
|
||||||
django-mptt==0.17.0
|
django-mptt==0.17.0
|
||||||
django-pglocks==1.0.4
|
django-pglocks==1.0.4
|
||||||
django-prometheus==2.4.1
|
django-prometheus==2.4.1
|
||||||
@@ -11,29 +11,29 @@ django-redis==6.0.0
|
|||||||
django-rich==2.0.0
|
django-rich==2.0.0
|
||||||
django-rq==3.0.1
|
django-rq==3.0.1
|
||||||
django-storages==1.14.6
|
django-storages==1.14.6
|
||||||
django-taggit==6.1.0
|
|
||||||
django-tables2==2.7.5
|
django-tables2==2.7.5
|
||||||
|
django-taggit==6.1.0
|
||||||
django-timezone-field==7.1
|
django-timezone-field==7.1
|
||||||
djangorestframework==3.16.0
|
djangorestframework==3.16.0
|
||||||
drf-spectacular==0.28.0
|
drf-spectacular==0.28.0
|
||||||
drf-spectacular-sidecar==2025.6.1
|
drf-spectacular-sidecar==2025.7.1
|
||||||
feedparser==6.0.11
|
feedparser==6.0.11
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
Jinja2==3.1.6
|
Jinja2==3.1.6
|
||||||
jsonschema==4.24.0
|
jsonschema==4.24.0
|
||||||
Markdown==3.8.2
|
Markdown==3.8.2
|
||||||
mkdocs-material==9.6.14
|
mkdocs-material==9.6.15
|
||||||
mkdocstrings[python]==0.29.1
|
mkdocstrings[python]==0.29.1
|
||||||
netaddr==1.3.0
|
netaddr==1.3.0
|
||||||
nh3==0.2.21
|
nh3==0.2.22
|
||||||
Pillow==11.2.1
|
Pillow==11.3.0
|
||||||
psycopg[c,pool]==3.2.9
|
psycopg[c,pool]==3.2.9
|
||||||
PyYAML==6.0.2
|
PyYAML==6.0.2
|
||||||
requests==2.32.4
|
requests==2.32.4
|
||||||
rq==2.4.0
|
rq==2.4.0
|
||||||
social-auth-app-django==5.4.3
|
social-auth-app-django==5.5.1
|
||||||
social-auth-core==4.6.1
|
social-auth-core==4.7.0
|
||||||
strawberry-graphql==0.275.4
|
strawberry-graphql==0.276.0
|
||||||
strawberry-graphql-django==0.60.0
|
strawberry-graphql-django==0.60.0
|
||||||
svgwrite==1.4.3
|
svgwrite==1.4.3
|
||||||
tablib==3.8.0
|
tablib==3.8.0
|
||||||
|
|||||||
Reference in New Issue
Block a user