mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-27 23:57:46 -06:00
Compare commits
55 Commits
19720-maca
...
20660-scri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
285abe7cc0 | ||
|
|
c5124cb2e4 | ||
|
|
d01d7b4156 | ||
|
|
4db6123fb2 | ||
|
|
43648d629b | ||
|
|
0b97df0984 | ||
|
|
5334c8143c | ||
|
|
bbb330becf | ||
|
|
e4c74ce6a3 | ||
|
|
a4868f894d | ||
|
|
531ea34207 | ||
|
|
6747c82a1a | ||
|
|
e251ea10b5 | ||
|
|
a1aaf465ac | ||
|
|
2a1d315d85 | ||
|
|
8cc6589a35 | ||
|
|
bee0080917 | ||
|
|
389c44e5d6 | ||
|
|
9cb2c78e34 | ||
|
|
2ae98f0353 | ||
|
|
addda0538f | ||
|
|
c902a1c510 | ||
|
|
f23ee0a46f | ||
|
|
b4acc3fb36 | ||
|
|
a69bbcf651 | ||
|
|
2edfde5753 | ||
|
|
cfbd9632ac | ||
|
|
c9386bc9c3 | ||
|
|
c826c5cdb0 | ||
|
|
a4ab4f885d | ||
|
|
61d77dff14 | ||
|
|
24a83acc34 | ||
|
|
dbc71158ec | ||
|
|
f0523611d1 | ||
|
|
7719b98697 | ||
|
|
f383067ecb | ||
|
|
20de263565 | ||
|
|
5ceb6a60da | ||
|
|
33d4759871 | ||
|
|
2abc5ac69a | ||
|
|
f8c074045f | ||
|
|
4db3d488ad | ||
|
|
b7cae04572 | ||
|
|
51528ae429 | ||
|
|
d5e8480367 | ||
|
|
05e26b82c1 | ||
|
|
d8e4c95bcc | ||
|
|
faa89a53ff | ||
|
|
d18bbe48c1 | ||
|
|
99e367cbaf | ||
|
|
f5ed095738 | ||
|
|
b70f1211ab | ||
|
|
10e8e7b071 | ||
|
|
c770e6b45d | ||
|
|
c094699dc0 |
@@ -15,7 +15,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.4.2
|
||||
placeholder: v4.4.4
|
||||
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.2
|
||||
placeholder: v4.4.4
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
8
.github/codeql/codeql-config.yml
vendored
8
.github/codeql/codeql-config.yml
vendored
@@ -1,3 +1,11 @@
|
||||
paths-ignore:
|
||||
# Ignore compiled JS
|
||||
- netbox/project-static/dist
|
||||
|
||||
query-filters:
|
||||
# Exclude py/url-redirection: NetBox uses safe_for_redirect() wrapper function
|
||||
# which validates all redirects via Django's url_has_allowed_host_and_scheme().
|
||||
# CodeQL's taint tracking doesn't recognize wrapper functions without custom
|
||||
# query configuration. See #20484.
|
||||
- exclude:
|
||||
id: py/url-redirection
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.9
|
||||
rev: v0.14.1
|
||||
hooks:
|
||||
- id: ruff
|
||||
name: "Ruff linter"
|
||||
|
||||
@@ -12,9 +12,7 @@ django-cors-headers
|
||||
|
||||
# Runtime UI tool for debugging Django
|
||||
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
|
||||
# django-debug-toolbar v6.0.0 raises "Attribute Error at /: 'function' object has no attribute 'set'"
|
||||
# see https://github.com/netbox-community/netbox/issues/19974
|
||||
django-debug-toolbar==5.2.0
|
||||
django-debug-toolbar
|
||||
|
||||
# Library for writing reusable URL query filters
|
||||
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
|
||||
@@ -71,7 +69,8 @@ django-timezone-field
|
||||
|
||||
# A REST API framework for Django projects
|
||||
# https://www.django-rest-framework.org/community/release-notes/
|
||||
djangorestframework
|
||||
# TODO: Re-evaluate the monkey-patch of get_unique_validators() before upgrading
|
||||
djangorestframework==3.16.1
|
||||
|
||||
# Sane and flexible OpenAPI 3 schema generation for Django REST framework.
|
||||
# https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst
|
||||
@@ -167,7 +166,8 @@ strawberry-graphql-django
|
||||
svgwrite
|
||||
|
||||
# Tabular dataset library (for table-based exports)
|
||||
# https://github.com/jazzband/tablib/blob/master/HISTORY.md
|
||||
# Current: https://github.com/jazzband/tablib/releases
|
||||
# Previous: https://github.com/jazzband/tablib/blob/master/HISTORY.md
|
||||
tablib
|
||||
|
||||
# Timezone data (required by django-timezone-field on Python 3.9+)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "NetBox REST API",
|
||||
"version": "4.4.2",
|
||||
"version": "4.4.4",
|
||||
"license": {
|
||||
"name": "Apache v2 License"
|
||||
}
|
||||
@@ -19678,14 +19678,14 @@
|
||||
"in": "query",
|
||||
"name": "object_type",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "object_type__n",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -20507,14 +20507,14 @@
|
||||
"in": "query",
|
||||
"name": "related_object_type",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "related_object_type__n",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -60734,14 +60734,14 @@
|
||||
"in": "query",
|
||||
"name": "assigned_object_type",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "assigned_object_type__n",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -136422,14 +136422,14 @@
|
||||
"in": "query",
|
||||
"name": "assigned_object_type",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "assigned_object_type__n",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -148274,14 +148274,14 @@
|
||||
"in": "query",
|
||||
"name": "parent_object_type",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "parent_object_type__n",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -214760,7 +214760,7 @@
|
||||
},
|
||||
"mark_utilized": {
|
||||
"type": "boolean",
|
||||
"description": "Report space as 100% utilized"
|
||||
"description": "Report space as fully utilized"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -214869,7 +214869,7 @@
|
||||
},
|
||||
"mark_utilized": {
|
||||
"type": "boolean",
|
||||
"description": "Report space as 100% utilized"
|
||||
"description": "Report space as fully utilized"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -215569,24 +215569,26 @@
|
||||
"IntegerRange": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
}
|
||||
"type": "integer"
|
||||
},
|
||||
"minItems": 2,
|
||||
"maxItems": 2,
|
||||
"example": [
|
||||
10,
|
||||
20
|
||||
]
|
||||
},
|
||||
"IntegerRangeRequest": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
}
|
||||
"type": "integer"
|
||||
},
|
||||
"minItems": 2,
|
||||
"maxItems": 2,
|
||||
"example": [
|
||||
10,
|
||||
20
|
||||
]
|
||||
},
|
||||
"Interface": {
|
||||
"type": "object",
|
||||
@@ -231880,7 +231882,7 @@
|
||||
},
|
||||
"mark_utilized": {
|
||||
"type": "boolean",
|
||||
"description": "Report space as 100% utilized"
|
||||
"description": "Report space as fully utilized"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -252203,7 +252205,7 @@
|
||||
},
|
||||
"mark_utilized": {
|
||||
"type": "boolean",
|
||||
"description": "Report space as 100% utilized"
|
||||
"description": "Report space as fully utilized"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -404,6 +404,61 @@ A complete date & time. Returns a `datetime.datetime` object.
|
||||
|
||||
Custom scripts can be run via the web UI by navigating to the script, completing any required form data, and clicking the "run script" button. It is possible to schedule a script to be executed at specified time in the future. A scheduled script can be canceled by deleting the associated job result object.
|
||||
|
||||
#### Prefilling variables via URL parameters
|
||||
|
||||
Script form fields can be prefilled by appending query parameters to the script URL. Each parameter name must match the variable name defined on the script class. Prefilled values are treated as initial values and can be edited before execution. Multiple values can be supplied by repeating the same parameter. Query values must be percent‑encoded where required (for example, spaces as `%20`).
|
||||
|
||||
Examples:
|
||||
|
||||
For string and integer variables, when a script defines:
|
||||
|
||||
```python
|
||||
from extras.scripts import Script, StringVar, IntegerVar
|
||||
|
||||
class MyScript(Script):
|
||||
name = StringVar()
|
||||
count = IntegerVar()
|
||||
```
|
||||
|
||||
the following URL prefills the `name` and `count` fields:
|
||||
|
||||
```
|
||||
https://<netbox>/extras/scripts/<script_id>/?name=Branch42&count=3
|
||||
```
|
||||
|
||||
For object variables (`ObjectVar`), supply the object’s primary key (PK):
|
||||
|
||||
```
|
||||
https://<netbox>/extras/scripts/<script_id>/?device=1
|
||||
```
|
||||
|
||||
If an object ID cannot be resolved or the object is not visible to the requesting user, the field remains unpopulated.
|
||||
|
||||
Supported variable types:
|
||||
|
||||
| Variable class | Expected input | Example query string |
|
||||
|--------------------------|---------------------------------|---------------------------------------------|
|
||||
| `StringVar` | string (percent‑encoded) | `?name=Branch42` |
|
||||
| `TextVar` | string (percent‑encoded) | `?notes=Initial%20value` |
|
||||
| `IntegerVar` | integer | `?count=3` |
|
||||
| `DecimalVar` | decimal number | `?ratio=0.75` |
|
||||
| `BooleanVar` | value → `True`; empty → `False` | `?enabled=true` (True), `?enabled=` (False) |
|
||||
| `ChoiceVar` | choice value (not label) | `?role=edge` |
|
||||
| `MultiChoiceVar` | choice values (repeat) | `?roles=edge&roles=core` |
|
||||
| `ObjectVar(Device)` | PK (integer) | `?device=1` |
|
||||
| `MultiObjectVar(Device)` | PKs (repeat) | `?devices=1&devices=2` |
|
||||
| `IPAddressVar` | IP address | `?ip=198.51.100.10` |
|
||||
| `IPAddressWithMaskVar` | IP address with mask | `?addr=192.0.2.1/24` |
|
||||
| `IPNetworkVar` | IP network prefix | `?network=2001:db8::/64` |
|
||||
| `DateVar` | date `YYYY-MM-DD` | `?date=2025-01-05` |
|
||||
| `DateTimeVar` | ISO datetime | `?when=2025-01-05T14:30:00` |
|
||||
| `FileVar` | — (not supported) | — |
|
||||
|
||||
!!! note
|
||||
- The parameter names above are examples; use the actual variable attribute names defined by the script.
|
||||
- For `BooleanVar`, only an empty value (`?enabled=`) unchecks the box; any other value including `false` or `0` checks it.
|
||||
- File uploads (`FileVar`) cannot be prefilled via URL parameters.
|
||||
|
||||
### Via the API
|
||||
|
||||
To run a script via the REST API, issue a POST request to the script's endpoint specifying the form data and commitment. For example, to run a script named `example.MyReport`, we would make a request such as the following:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Filters & Filter Sets
|
||||
|
||||
Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI or REST API. (Note that the GraphQL API uses a separate filter class.) NetBox employs the [django-filters2](https://django-tables2.readthedocs.io/en/latest/) library to define filter sets.
|
||||
Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI or REST API. (Note that the GraphQL API uses a separate filter class.) NetBox employs the [django-filter](https://django-filter.readthedocs.io/en/stable/) library to define filter sets.
|
||||
|
||||
## FilterSet Classes
|
||||
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# NetBox v4.4
|
||||
|
||||
## v4.4.4 (2025-10-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#20554](https://github.com/netbox-community/netbox/issues/20554) - Fix generic relation filters to accept `<app>.<model>` format matching POST requests
|
||||
* [#20574](https://github.com/netbox-community/netbox/issues/20574) - Fix excessive storage initialization overhead when listing scripts with remote backends
|
||||
* [#20584](https://github.com/netbox-community/netbox/issues/20584) - Enforce PoE mode requirement on interface templates when PoE type is set
|
||||
* [#20585](https://github.com/netbox-community/netbox/issues/20585) - Fix API schema generation crash for models with single-field UniqueConstraints
|
||||
* [#20587](https://github.com/netbox-community/netbox/issues/20587) - Fix upgrade.sh failure when removing stale content types
|
||||
|
||||
---
|
||||
|
||||
## v4.4.3 (2025-10-14)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#20426](https://github.com/netbox-community/netbox/issues/20426) - Add a copy-to-clipboard button for custom script output
|
||||
* [#20516](https://github.com/netbox-community/netbox/issues/20516) - Improve rendering of VLAN ID ranges in VLAN group tables
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#19302](https://github.com/netbox-community/netbox/issues/19302) - Fix uniqueness validation in REST API for nullable fields
|
||||
* [#19615](https://github.com/netbox-community/netbox/issues/19615) - Fix support for static file parameters in templates when external storage is in use
|
||||
* [#19818](https://github.com/netbox-community/netbox/issues/19818) - Hide primary IP assignment fields when creating a new virtual machine in the UI
|
||||
* [#19825](https://github.com/netbox-community/netbox/issues/19825) - Prevent cache for config revisions from being erroneously overwritten when debugging is enabled
|
||||
* [#20140](https://github.com/netbox-community/netbox/issues/20140) - Changing a site's region or group should update any associated circuit terminations
|
||||
* [#20156](https://github.com/netbox-community/netbox/issues/20156) - Fix display of rack elevation labels
|
||||
* [#20290](https://github.com/netbox-community/netbox/issues/20290) - Fix migration error when upgrading to NetBox v4.4 from releases earlier than v4.3
|
||||
* [#20471](https://github.com/netbox-community/netbox/issues/20471) - Saving an unmodified VLAN group should not generate a change record
|
||||
* [#20475](https://github.com/netbox-community/netbox/issues/20475) - Collapse singleton VLAN IDs in VLAN group display
|
||||
* [#20494](https://github.com/netbox-community/netbox/issues/20494) - Correct OpenAPI schema definition for `IntegerRangeSerializer`
|
||||
* [#20496](https://github.com/netbox-community/netbox/issues/20496) - REST API should always honor `MAX_PAGE_SIZE` value
|
||||
* [#20497](https://github.com/netbox-community/netbox/issues/20497) - Fix filtering of VLAN groups by VLAN ID range in GraphQL API
|
||||
* [#20507](https://github.com/netbox-community/netbox/issues/20507) - Fix support for fetching ASN contacts via GraphQL API
|
||||
* [#20523](https://github.com/netbox-community/netbox/issues/20523) - Hide password change form for users authenticated via SSO
|
||||
* [#20542](https://github.com/netbox-community/netbox/issues/20542) - Fix the creation of MAC addresses using the "quick add" form
|
||||
|
||||
---
|
||||
|
||||
## v4.4.2 (2025-09-30)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
from netbox import denormalized
|
||||
|
||||
|
||||
class CircuitsConfig(AppConfig):
|
||||
name = "circuits"
|
||||
@@ -8,6 +10,16 @@ class CircuitsConfig(AppConfig):
|
||||
def ready(self):
|
||||
from netbox.models.features import register_models
|
||||
from . import signals, search # noqa: F401
|
||||
from .models import CircuitTermination
|
||||
|
||||
# Register models
|
||||
register_models(*self.get_models())
|
||||
|
||||
denormalized.register(CircuitTermination, '_site', {
|
||||
'_region': 'region',
|
||||
'_site_group': 'group',
|
||||
})
|
||||
|
||||
denormalized.register(CircuitTermination, '_location', {
|
||||
'_site': 'site',
|
||||
})
|
||||
|
||||
@@ -282,18 +282,18 @@ class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
|
||||
|
||||
class FixIntegerRangeSerializerSchema(OpenApiSerializerExtension):
|
||||
target_class = 'netbox.api.fields.IntegerRangeSerializer'
|
||||
match_subclasses = True
|
||||
|
||||
def map_serializer(self, auto_schema: 'AutoSchema', direction: Direction) -> _SchemaType:
|
||||
# One range = two integers; many=True will wrap this in an outer array
|
||||
return {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
'type': 'integer',
|
||||
},
|
||||
'minItems': 2,
|
||||
'maxItems': 2,
|
||||
'type': 'integer',
|
||||
},
|
||||
'minItems': 2,
|
||||
'maxItems': 2,
|
||||
'example': [10, 20],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ class JobFilterSet(BaseFilterSet):
|
||||
method='search',
|
||||
label=_('Search'),
|
||||
)
|
||||
object_type = ContentTypeFilter()
|
||||
created = django_filters.DateTimeFilter()
|
||||
created__before = django_filters.DateTimeFilter(
|
||||
field_name='created',
|
||||
@@ -169,6 +170,7 @@ class ObjectChangeFilterSet(BaseFilterSet):
|
||||
changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ContentType.objects.all()
|
||||
)
|
||||
related_object_type = ContentTypeFilter()
|
||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=User.objects.all(),
|
||||
label=_('User (ID)'),
|
||||
|
||||
@@ -3,12 +3,12 @@ from typing import Annotated, List, TYPE_CHECKING
|
||||
import strawberry
|
||||
import strawberry_django
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from strawberry.types import Info
|
||||
|
||||
from core.models import ObjectChange
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.graphql.types import DataFileType, DataSourceType
|
||||
from netbox.core.graphql.types import ObjectChangeType
|
||||
from core.graphql.types import DataFileType, DataSourceType, ObjectChangeType
|
||||
|
||||
__all__ = (
|
||||
'ChangelogMixin',
|
||||
@@ -20,7 +20,7 @@ __all__ = (
|
||||
class ChangelogMixin:
|
||||
|
||||
@strawberry_django.field
|
||||
def changelog(self, info) -> List[Annotated["ObjectChangeType", strawberry.lazy('.types')]]: # noqa: F821
|
||||
def changelog(self, info: Info) -> List[Annotated['ObjectChangeType', strawberry.lazy('.types')]]: # noqa: F821
|
||||
content_type = ContentType.objects.get_for_model(self)
|
||||
object_changes = ObjectChange.objects.filter(
|
||||
changed_object_type=content_type,
|
||||
@@ -31,5 +31,5 @@ class ChangelogMixin:
|
||||
|
||||
@strawberry.type
|
||||
class SyncedDataMixin:
|
||||
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
|
||||
data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
|
||||
data_source: Annotated['DataSourceType', strawberry.lazy('core.graphql.types')] | None
|
||||
data_file: Annotated['DataFileType', strawberry.lazy('core.graphql.types')] | None
|
||||
|
||||
48
netbox/core/migrations/0019_configrevision_active.py
Normal file
48
netbox/core/migrations/0019_configrevision_active.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 5.2.5 on 2025-09-09 16:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def get_active(apps, schema_editor):
|
||||
from django.core.cache import cache
|
||||
ConfigRevision = apps.get_model('core', 'ConfigRevision')
|
||||
version = None
|
||||
revision = None
|
||||
|
||||
# Try and get the latest version from cache
|
||||
try:
|
||||
version = cache.get('config_version')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If there is a version in cache, attempt to set revision to the current version from cache
|
||||
# If the version in cache does not exist or there is no version, try the lastest revision in the database
|
||||
if not version or (version and not (revision := ConfigRevision.objects.filter(pk=version).first())):
|
||||
revision = ConfigRevision.objects.order_by('-created').first()
|
||||
|
||||
# If there is a revision set, set the active revision
|
||||
if revision:
|
||||
revision.active = True
|
||||
revision.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0018_concrete_objecttype'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='configrevision',
|
||||
name='active',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.RunPython(code=get_active, reverse_code=migrations.RunPython.noop),
|
||||
migrations.AddConstraint(
|
||||
model_name='configrevision',
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(('active', True)), fields=('active',), name='unique_active_config_revision'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -14,6 +14,9 @@ class ConfigRevision(models.Model):
|
||||
"""
|
||||
An atomic revision of NetBox's configuration.
|
||||
"""
|
||||
active = models.BooleanField(
|
||||
default=False
|
||||
)
|
||||
created = models.DateTimeField(
|
||||
verbose_name=_('created'),
|
||||
auto_now_add=True
|
||||
@@ -35,6 +38,13 @@ class ConfigRevision(models.Model):
|
||||
ordering = ['-created']
|
||||
verbose_name = _('config revision')
|
||||
verbose_name_plural = _('config revisions')
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=('active',),
|
||||
condition=models.Q(active=True),
|
||||
name='unique_active_config_revision',
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
if not self.pk:
|
||||
@@ -59,8 +69,13 @@ class ConfigRevision(models.Model):
|
||||
"""
|
||||
cache.set('config', self.data, None)
|
||||
cache.set('config_version', self.pk, None)
|
||||
|
||||
# Set all instances of ConfigRevision to false and set this instance to true
|
||||
ConfigRevision.objects.all().update(active=False)
|
||||
ConfigRevision.objects.filter(pk=self.pk).update(active=True)
|
||||
|
||||
activate.alters_data = True
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return cache.get('config_version') == self.pk
|
||||
return self.active
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import models
|
||||
from django.db import connection, models
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
@@ -66,6 +66,14 @@ class ObjectTypeManager(models.Manager):
|
||||
"""
|
||||
from netbox.models.features import get_model_features, model_is_public
|
||||
|
||||
# TODO: Remove this in NetBox v5.0
|
||||
# If the ObjectType table has not yet been provisioned (e.g. because we're in a pre-v4.4 migration),
|
||||
# fall back to ContentType.
|
||||
if 'core_objecttype' not in connection.introspection.table_names():
|
||||
ct = ContentType.objects.get_for_model(model, for_concrete_model=for_concrete_model)
|
||||
ct.features = get_model_features(ct.model_class())
|
||||
return ct
|
||||
|
||||
if not inspect.isclass(model):
|
||||
model = model.__class__
|
||||
opts = self._get_opts(model, for_concrete_model)
|
||||
|
||||
@@ -14,16 +14,16 @@ from netbox.filtersets import (
|
||||
AttributeFiltersMixin, BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
|
||||
OrganizationalModelFilterSet,
|
||||
)
|
||||
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
|
||||
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
|
||||
from tenancy.models import *
|
||||
from users.models import User
|
||||
from utilities.filters import (
|
||||
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
|
||||
NumericArrayFilter, TreeNodeMultipleChoiceFilter,
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup, VMInterface, VirtualMachine
|
||||
from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
|
||||
from vpn.models import L2VPN
|
||||
from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
|
||||
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
|
||||
from wireless.models import WirelessLAN, WirelessLink
|
||||
from .choices import *
|
||||
from .constants import *
|
||||
@@ -1764,6 +1764,7 @@ class PowerOutletFilterSet(
|
||||
|
||||
class MACAddressFilterSet(NetBoxModelFilterSet):
|
||||
mac_address = MultiValueMACAddressFilter()
|
||||
assigned_object_type = ContentTypeFilter()
|
||||
device = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
field_name='name',
|
||||
@@ -1806,6 +1807,14 @@ class MACAddressFilterSet(NetBoxModelFilterSet):
|
||||
queryset=VMInterface.objects.all(),
|
||||
label=_('VM interface (ID)'),
|
||||
)
|
||||
assigned = django_filters.BooleanFilter(
|
||||
method='filter_assigned',
|
||||
label=_('Is assigned'),
|
||||
)
|
||||
primary = django_filters.BooleanFilter(
|
||||
method='filter_primary',
|
||||
label=_('Is primary'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = MACAddress
|
||||
@@ -1842,6 +1851,29 @@ class MACAddressFilterSet(NetBoxModelFilterSet):
|
||||
vminterface__in=interface_ids
|
||||
)
|
||||
|
||||
def filter_assigned(self, queryset, name, value):
|
||||
params = {
|
||||
'assigned_object_type__isnull': True,
|
||||
'assigned_object_id__isnull': True,
|
||||
}
|
||||
if value:
|
||||
return queryset.exclude(**params)
|
||||
else:
|
||||
return queryset.filter(**params)
|
||||
|
||||
def filter_primary(self, queryset, name, value):
|
||||
interface_mac_ids = Interface.objects.filter(primary_mac_address_id__isnull=False).values_list(
|
||||
'primary_mac_address_id', flat=True
|
||||
)
|
||||
vminterface_mac_ids = VMInterface.objects.filter(primary_mac_address_id__isnull=False).values_list(
|
||||
'primary_mac_address_id', flat=True
|
||||
)
|
||||
query = Q(pk__in=interface_mac_ids) | Q(pk__in=vminterface_mac_ids)
|
||||
if value:
|
||||
return queryset.filter(query)
|
||||
else:
|
||||
return queryset.exclude(query)
|
||||
|
||||
|
||||
class CommonInterfaceFilterSet(django_filters.FilterSet):
|
||||
mode = django_filters.MultipleChoiceFilter(
|
||||
|
||||
@@ -1676,12 +1676,16 @@ class MACAddressFilterForm(NetBoxModelFilterSetForm):
|
||||
model = MACAddress
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('mac_address', 'device_id', 'virtual_machine_id', name=_('MAC address')),
|
||||
FieldSet('mac_address', name=_('Attributes')),
|
||||
FieldSet(
|
||||
'device_id', 'virtual_machine_id', 'assigned', 'primary',
|
||||
name=_('Assignments'),
|
||||
),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'device_id', 'virtual_machine_id')
|
||||
mac_address = forms.CharField(
|
||||
required=False,
|
||||
label=_('MAC address')
|
||||
label=_('MAC address'),
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
@@ -1693,6 +1697,20 @@ class MACAddressFilterForm(NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('Assigned VM'),
|
||||
)
|
||||
assigned = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_('Assigned to an interface'),
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
),
|
||||
)
|
||||
primary = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_('Primary MAC of an interface'),
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
|
||||
@@ -755,7 +755,10 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm):
|
||||
queryset=ModuleBay.objects.all(),
|
||||
query_params={
|
||||
'device_id': '$device'
|
||||
}
|
||||
},
|
||||
context={
|
||||
'disabled': 'installed_module',
|
||||
},
|
||||
)
|
||||
module_type = DynamicModelChoiceField(
|
||||
label=_('Module type'),
|
||||
|
||||
@@ -18,7 +18,9 @@ from netbox.graphql.filter_mixins import (
|
||||
ImageAttachmentFilterMixin,
|
||||
WeightFilterMixin,
|
||||
)
|
||||
from tenancy.graphql.filter_mixins import TenancyFilterMixin, ContactFilterMixin
|
||||
from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
|
||||
from virtualization.models import VMInterface
|
||||
|
||||
from .filter_mixins import (
|
||||
CabledObjectModelFilterMixin,
|
||||
ComponentModelFilterMixin,
|
||||
@@ -419,6 +421,24 @@ class MACAddressFilter(PrimaryModelFilterMixin):
|
||||
)
|
||||
assigned_object_id: ID | None = strawberry_django.filter_field()
|
||||
|
||||
@strawberry_django.filter_field()
|
||||
def assigned(self, value: bool, prefix) -> Q:
|
||||
return Q(**{f'{prefix}assigned_object_id__isnull': (not value)})
|
||||
|
||||
@strawberry_django.filter_field()
|
||||
def primary(self, value: bool, prefix) -> Q:
|
||||
interface_mac_ids = models.Interface.objects.filter(primary_mac_address_id__isnull=False).values_list(
|
||||
'primary_mac_address_id', flat=True
|
||||
)
|
||||
vminterface_mac_ids = VMInterface.objects.filter(primary_mac_address_id__isnull=False).values_list(
|
||||
'primary_mac_address_id', flat=True
|
||||
)
|
||||
query = Q(**{f'{prefix}pk__in': interface_mac_ids}) | Q(**{f'{prefix}pk__in': vminterface_mac_ids})
|
||||
if value:
|
||||
return Q(query)
|
||||
else:
|
||||
return ~Q(query)
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.Interface, lookups=True)
|
||||
class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin, CabledObjectModelFilterMixin):
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from strawberry.types import Info
|
||||
|
||||
from circuits.graphql.types import CircuitTerminationType, ProviderNetworkType
|
||||
from circuits.models import CircuitTermination, ProviderNetwork
|
||||
from dcim.graphql.types import (
|
||||
@@ -49,7 +51,7 @@ class InventoryItemTemplateComponentType:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve_type(cls, instance, info):
|
||||
def resolve_type(cls, instance, info: Info):
|
||||
if type(instance) is ConsolePortTemplate:
|
||||
return ConsolePortTemplateType
|
||||
if type(instance) is ConsoleServerPortTemplate:
|
||||
@@ -79,7 +81,7 @@ class InventoryItemComponentType:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve_type(cls, instance, info):
|
||||
def resolve_type(cls, instance, info: Info):
|
||||
if type(instance) is ConsolePort:
|
||||
return ConsolePortType
|
||||
if type(instance) is ConsoleServerPort:
|
||||
@@ -112,7 +114,7 @@ class ConnectedEndpointType:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve_type(cls, instance, info):
|
||||
def resolve_type(cls, instance, info: Info):
|
||||
if type(instance) is CircuitTermination:
|
||||
return CircuitTerminationType
|
||||
if type(instance) is ConsolePortType:
|
||||
|
||||
@@ -7,6 +7,7 @@ from mptt.models import MPTTModel, TreeForeignKey
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models.mixins import InterfaceValidationMixin
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.mptt import TreeManager
|
||||
@@ -405,7 +406,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
|
||||
}
|
||||
|
||||
|
||||
class InterfaceTemplate(ModularComponentTemplateModel):
|
||||
class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel):
|
||||
"""
|
||||
A template for a physical data interface on a new Device.
|
||||
"""
|
||||
@@ -469,8 +470,6 @@ class InterfaceTemplate(ModularComponentTemplateModel):
|
||||
super().clean()
|
||||
|
||||
if self.bridge:
|
||||
if self.pk and self.bridge_id == self.pk:
|
||||
raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
|
||||
if self.device_type and self.device_type != self.bridge.device_type:
|
||||
raise ValidationError({
|
||||
'bridge': _(
|
||||
@@ -484,11 +483,6 @@ class InterfaceTemplate(ModularComponentTemplateModel):
|
||||
).format(bridge=self.bridge)
|
||||
})
|
||||
|
||||
if self.rf_role and self.type not in WIRELESS_IFACE_TYPES:
|
||||
raise ValidationError({
|
||||
'rf_role': "Wireless role may be set only on wireless interfaces."
|
||||
})
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
|
||||
@@ -11,6 +11,7 @@ from mptt.models import MPTTModel, TreeForeignKey
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.fields import WWNField
|
||||
from dcim.models.mixins import InterfaceValidationMixin
|
||||
from netbox.choices import ColorChoices
|
||||
from netbox.models import OrganizationalModel, NetBoxModel
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
@@ -676,7 +677,14 @@ class BaseInterface(models.Model):
|
||||
return self.primary_mac_address.mac_address
|
||||
|
||||
|
||||
class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint, TrackingModelMixin):
|
||||
class Interface(
|
||||
InterfaceValidationMixin,
|
||||
ModularComponentModel,
|
||||
BaseInterface,
|
||||
CabledObjectModel,
|
||||
PathEndpoint,
|
||||
TrackingModelMixin,
|
||||
):
|
||||
"""
|
||||
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
|
||||
"""
|
||||
@@ -893,10 +901,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
|
||||
# Bridge validation
|
||||
|
||||
# An interface cannot be bridged to itself
|
||||
if self.pk and self.bridge_id == self.pk:
|
||||
raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
|
||||
|
||||
# A bridged interface belongs to the same device or virtual chassis
|
||||
if self.bridge and self.bridge.device != self.device:
|
||||
if self.device.virtual_chassis is None:
|
||||
@@ -942,29 +946,9 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
)
|
||||
})
|
||||
|
||||
# PoE validation
|
||||
|
||||
# Only physical interfaces may have a PoE mode/type assigned
|
||||
if self.poe_mode and self.is_virtual:
|
||||
raise ValidationError({
|
||||
'poe_mode': _("Virtual interfaces cannot have a PoE mode.")
|
||||
})
|
||||
if self.poe_type and self.is_virtual:
|
||||
raise ValidationError({
|
||||
'poe_type': _("Virtual interfaces cannot have a PoE type.")
|
||||
})
|
||||
|
||||
# An interface with a PoE type set must also specify a mode
|
||||
if self.poe_type and not self.poe_mode:
|
||||
raise ValidationError({
|
||||
'poe_type': _("Must specify PoE mode when designating a PoE type.")
|
||||
})
|
||||
|
||||
# Wireless validation
|
||||
|
||||
# RF role & channel may only be set for wireless interfaces
|
||||
if self.rf_role and not self.is_wireless:
|
||||
raise ValidationError({'rf_role': _("Wireless role may be set only on wireless interfaces.")})
|
||||
# RF channel may only be set for wireless interfaces
|
||||
if self.rf_channel and not self.is_wireless:
|
||||
raise ValidationError({'rf_channel': _("Channel may be set only on wireless interfaces.")})
|
||||
|
||||
|
||||
@@ -4,8 +4,11 @@ from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.constants import VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES
|
||||
|
||||
__all__ = (
|
||||
'CachedScopeMixin',
|
||||
'InterfaceValidationMixin',
|
||||
'RenderConfigMixin',
|
||||
)
|
||||
|
||||
@@ -116,3 +119,33 @@ class CachedScopeMixin(models.Model):
|
||||
self._site = self.scope.site
|
||||
self._location = self.scope
|
||||
cache_related_objects.alters_data = True
|
||||
|
||||
|
||||
class InterfaceValidationMixin:
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# An interface cannot be bridged to itself
|
||||
if self.pk and self.bridge_id == self.pk:
|
||||
raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
|
||||
|
||||
# Only physical interfaces may have a PoE mode/type assigned
|
||||
if self.poe_mode and self.type in VIRTUAL_IFACE_TYPES:
|
||||
raise ValidationError({
|
||||
'poe_mode': _("Virtual interfaces cannot have a PoE mode.")
|
||||
})
|
||||
if self.poe_type and self.type in VIRTUAL_IFACE_TYPES:
|
||||
raise ValidationError({
|
||||
'poe_type': _("Virtual interfaces cannot have a PoE type.")
|
||||
})
|
||||
|
||||
# An interface with a PoE type set must also specify a mode
|
||||
if self.poe_type and not self.poe_mode:
|
||||
raise ValidationError({
|
||||
'poe_type': _("Must specify PoE mode when designating a PoE type.")
|
||||
})
|
||||
|
||||
# RF role may be set only for wireless interfaces
|
||||
if self.rf_role and self.type not in WIRELESS_IFACE_TYPES:
|
||||
raise ValidationError({'rf_role': _("Wireless role may be set only on wireless interfaces.")})
|
||||
|
||||
@@ -196,7 +196,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name=_('Type')
|
||||
)
|
||||
u_height = columns.TemplateColumn(
|
||||
accessor=tables.A('device_type.u_height'),
|
||||
accessor=tables.A('device_type__u_height'),
|
||||
verbose_name=_('U Height'),
|
||||
template_code='{{ value|floatformat }}'
|
||||
)
|
||||
@@ -1174,6 +1174,9 @@ class MACAddressTable(NetBoxTable):
|
||||
orderable=False,
|
||||
verbose_name=_('Parent')
|
||||
)
|
||||
is_primary = columns.BooleanColumn(
|
||||
verbose_name=_('Primary')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:macaddress_list'
|
||||
)
|
||||
@@ -1184,7 +1187,7 @@ class MACAddressTable(NetBoxTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = models.MACAddress
|
||||
fields = (
|
||||
'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description', 'comments', 'tags',
|
||||
'created', 'last_updated',
|
||||
'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description', 'is_primary',
|
||||
'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description')
|
||||
|
||||
@@ -10,7 +10,7 @@ from netbox.choices import ColorChoices, WeightUnitChoices
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from users.models import User
|
||||
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
|
||||
from virtualization.models import Cluster, ClusterType, ClusterGroup, VMInterface, VirtualMachine
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
|
||||
from wireless.models import WirelessLink
|
||||
|
||||
@@ -7164,9 +7164,20 @@ class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
MACAddress(mac_address='00-00-00-05-01-01', assigned_object=vm_interfaces[1]),
|
||||
MACAddress(mac_address='00-00-00-06-01-01', assigned_object=vm_interfaces[2]),
|
||||
MACAddress(mac_address='00-00-00-06-01-02', assigned_object=vm_interfaces[2]),
|
||||
# unassigned
|
||||
MACAddress(mac_address='00-00-00-07-01-01'),
|
||||
)
|
||||
MACAddress.objects.bulk_create(mac_addresses)
|
||||
|
||||
# Set MAC addresses as primary
|
||||
for idx, interface in enumerate(interfaces):
|
||||
interface.primary_mac_address = mac_addresses[idx]
|
||||
interface.save()
|
||||
for idx, vm_interface in enumerate(vm_interfaces):
|
||||
# Offset by 4 for device MACs
|
||||
vm_interface.primary_mac_address = mac_addresses[idx + 4]
|
||||
vm_interface.save()
|
||||
|
||||
def test_mac_address(self):
|
||||
params = {'mac_address': ['00-00-00-01-01-01', '00-00-00-02-01-01']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -7198,3 +7209,15 @@ class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'vminterface': [vm_interfaces[0].name, vm_interfaces[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_assigned(self):
|
||||
params = {'assigned': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
|
||||
params = {'assigned': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_primary(self):
|
||||
params = {'primary': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
params = {'primary': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
@@ -7,13 +7,14 @@ from django.test import override_settings, tag
|
||||
from django.urls import reverse
|
||||
from netaddr import EUI
|
||||
|
||||
from core.models import ObjectType
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from ipam.models import ASN, RIR, VLAN, VRF
|
||||
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, WeightUnitChoices
|
||||
from tenancy.models import Tenant
|
||||
from users.models import User
|
||||
from users.models import ObjectPermission, User
|
||||
from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
|
||||
from wireless.models import WirelessLAN
|
||||
|
||||
@@ -3728,3 +3729,29 @@ class MACAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
cls.bulk_edit_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@tag('regression') # Issue #20542
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
|
||||
def test_create_macaddress_via_quickadd(self):
|
||||
"""
|
||||
Test creating a MAC address via quick-add modal (e.g., from Interface form).
|
||||
Regression test for issue #20542 where form prefix was missing in POST handler.
|
||||
"""
|
||||
obj_perm = ObjectPermission(name='Test permission', actions=['add'])
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
|
||||
|
||||
# Simulate quick-add form submission with 'quickadd-' prefix
|
||||
formatted_data = post_data(self.form_data)
|
||||
quickadd_data = {f'quickadd-{k}': v for k, v in formatted_data.items()}
|
||||
quickadd_data['_quickadd'] = 'True'
|
||||
|
||||
initial_count = self._get_queryset().count()
|
||||
url = f"{self._get_url('add')}?_quickadd=True&target=id_primary_mac_address"
|
||||
response = self.client.post(url, data=quickadd_data)
|
||||
|
||||
# Should successfully create the MAC address and return the quick_add_created template
|
||||
self.assertHttpStatus(response, 200)
|
||||
self.assertIn(b'quick-add-object', response.content)
|
||||
self.assertEqual(initial_count + 1, self._get_queryset().count())
|
||||
|
||||
@@ -5,6 +5,7 @@ from rest_framework import serializers
|
||||
from core.api.serializers_.jobs import JobSerializer
|
||||
from extras.models import Script
|
||||
from netbox.api.serializers import ValidatedModelSerializer
|
||||
from utilities.datetime import local_now
|
||||
|
||||
__all__ = (
|
||||
'ScriptDetailSerializer',
|
||||
@@ -66,11 +67,31 @@ class ScriptInputSerializer(serializers.Serializer):
|
||||
interval = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
def validate_schedule_at(self, value):
|
||||
if value and not self.context['script'].python_class.scheduling_enabled:
|
||||
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
|
||||
"""
|
||||
Validates the specified schedule time for a script execution.
|
||||
"""
|
||||
if value:
|
||||
if not self.context['script'].python_class.scheduling_enabled:
|
||||
raise serializers.ValidationError(_('Scheduling is not enabled for this script.'))
|
||||
if value < local_now():
|
||||
raise serializers.ValidationError(_('Scheduled time must be in the future.'))
|
||||
return value
|
||||
|
||||
def validate_interval(self, value):
|
||||
"""
|
||||
Validates the provided interval based on the script's scheduling configuration.
|
||||
"""
|
||||
if value and not self.context['script'].python_class.scheduling_enabled:
|
||||
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
|
||||
raise serializers.ValidationError(_('Scheduling is not enabled for this script.'))
|
||||
return value
|
||||
|
||||
def validate(self, data):
|
||||
"""
|
||||
Validates the given data and ensures the necessary fields are populated.
|
||||
"""
|
||||
# Set the schedule_at time to now if only an interval is provided
|
||||
# while handling the case where schedule_at is null.
|
||||
if data.get('interval') and not data.get('schedule_at'):
|
||||
data['schedule_at'] = local_now()
|
||||
|
||||
return super().validate(data)
|
||||
|
||||
@@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Annotated, List
|
||||
|
||||
import strawberry
|
||||
import strawberry_django
|
||||
from strawberry.types import Info
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextMixin',
|
||||
@@ -37,7 +38,7 @@ class CustomFieldsMixin:
|
||||
class ImageAttachmentsMixin:
|
||||
|
||||
@strawberry_django.field
|
||||
def image_attachments(self, info) -> List[Annotated["ImageAttachmentType", strawberry.lazy('.types')]]:
|
||||
def image_attachments(self, info: Info) -> List[Annotated['ImageAttachmentType', strawberry.lazy('.types')]]:
|
||||
return self.images.restrict(info.context.request.user, 'view')
|
||||
|
||||
|
||||
@@ -45,17 +46,17 @@ class ImageAttachmentsMixin:
|
||||
class JournalEntriesMixin:
|
||||
|
||||
@strawberry_django.field
|
||||
def journal_entries(self, info) -> List[Annotated["JournalEntryType", strawberry.lazy('.types')]]:
|
||||
def journal_entries(self, info: Info) -> List[Annotated['JournalEntryType', strawberry.lazy('.types')]]:
|
||||
return self.journal_entries.all()
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class TagsMixin:
|
||||
|
||||
tags: List[Annotated["TagType", strawberry.lazy('.types')]]
|
||||
tags: List[Annotated['TagType', strawberry.lazy('.types')]]
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class ContactsMixin:
|
||||
|
||||
contacts: List[Annotated["ContactAssignmentType", strawberry.lazy('tenancy.graphql.types')]]
|
||||
contacts: List[Annotated['ContactAssignmentType', strawberry.lazy('tenancy.graphql.types')]]
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.contrib.postgres.fields.ranges import RangeField
|
||||
from django.db.models import CharField, JSONField, Lookup
|
||||
from django.db.models.fields.json import KeyTextTransform
|
||||
|
||||
from .fields import CachedValueField
|
||||
|
||||
|
||||
class RangeContains(Lookup):
|
||||
"""
|
||||
Filter ArrayField(RangeField) columns where ANY element-range contains the scalar RHS.
|
||||
|
||||
Usage (ORM):
|
||||
Model.objects.filter(<range_array_field>__range_contains=<scalar>)
|
||||
|
||||
Works with int4range[], int8range[], daterange[], tstzrange[], etc.
|
||||
"""
|
||||
|
||||
lookup_name = 'range_contains'
|
||||
|
||||
def as_sql(self, compiler, connection):
|
||||
# Compile LHS (the array-of-ranges column/expression) and RHS (scalar)
|
||||
lhs, lhs_params = self.process_lhs(compiler, connection)
|
||||
rhs, rhs_params = self.process_rhs(compiler, connection)
|
||||
|
||||
# Guard: only allow ArrayField whose base_field is a PostgreSQL RangeField
|
||||
field = getattr(self.lhs, 'output_field', None)
|
||||
if not (isinstance(field, ArrayField) and isinstance(field.base_field, RangeField)):
|
||||
raise TypeError('range_contains is only valid for ArrayField(RangeField) columns')
|
||||
|
||||
# Range-contains-element using EXISTS + UNNEST keeps the range on the LHS: r @> value
|
||||
sql = f"EXISTS (SELECT 1 FROM unnest({lhs}) AS r WHERE r @> {rhs})"
|
||||
params = lhs_params + rhs_params
|
||||
return sql, params
|
||||
|
||||
|
||||
class Empty(Lookup):
|
||||
"""
|
||||
Filter on whether a string is empty.
|
||||
@@ -25,7 +55,7 @@ class JSONEmpty(Lookup):
|
||||
|
||||
A key is considered empty if it is "", null, or does not exist.
|
||||
"""
|
||||
lookup_name = "empty"
|
||||
lookup_name = 'empty'
|
||||
|
||||
def as_sql(self, compiler, connection):
|
||||
# self.lhs.lhs is the parent expression (could be a JSONField or another KeyTransform)
|
||||
@@ -69,6 +99,7 @@ class NetContainsOrEquals(Lookup):
|
||||
return 'CAST(%s AS INET) >>= %s' % (lhs, rhs), params
|
||||
|
||||
|
||||
ArrayField.register_lookup(RangeContains)
|
||||
CharField.register_lookup(Empty)
|
||||
JSONField.register_lookup(JSONEmpty)
|
||||
CachedValueField.register_lookup(NetHost)
|
||||
|
||||
@@ -535,6 +535,15 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
# URL
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_URL:
|
||||
field = LaxURLField(assume_scheme='https', required=required, initial=initial)
|
||||
if self.validation_regex:
|
||||
field.validators = [
|
||||
RegexValidator(
|
||||
regex=self.validation_regex,
|
||||
message=mark_safe(_("Values must match this regex: <code>{regex}</code>").format(
|
||||
regex=escape(self.validation_regex)
|
||||
))
|
||||
)
|
||||
]
|
||||
|
||||
# JSON
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
|
||||
@@ -684,6 +693,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
if self.validation_regex and not re.match(self.validation_regex, value):
|
||||
raise ValidationError(_("Value must match regex '{regex}'").format(regex=self.validation_regex))
|
||||
|
||||
# Validate URL field
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_URL:
|
||||
if type(value) is not str:
|
||||
raise ValidationError(_("Value must be a string."))
|
||||
if self.validation_regex and not re.match(self.validation_regex, value):
|
||||
raise ValidationError(_("Value must match regex '{regex}'").format(regex=self.validation_regex))
|
||||
|
||||
# Validate integer
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
|
||||
if type(value) is not int:
|
||||
|
||||
@@ -3,6 +3,7 @@ import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.core.files.storage import storages
|
||||
from django.db import models
|
||||
from django.http import HttpResponse
|
||||
@@ -30,7 +31,14 @@ class CustomStoragesLoader(importlib.abc.Loader):
|
||||
return None # Use default module creation
|
||||
|
||||
def exec_module(self, module):
|
||||
storage = storages.create_storage(storages.backends["scripts"])
|
||||
# Cache storage for 5 minutes (300 seconds)
|
||||
cache_key = "storage_scripts"
|
||||
storage = cache.get(cache_key)
|
||||
|
||||
if storage is None:
|
||||
storage = storages['scripts']
|
||||
cache.set(cache_key, storage, timeout=300) # 5 minutes
|
||||
|
||||
with storage.open(self.filename, 'rb') as f:
|
||||
code = f.read()
|
||||
exec(code, module.__dict__)
|
||||
|
||||
@@ -90,7 +90,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
||||
ConfigContext.objects.filter(
|
||||
self._get_config_context_filters()
|
||||
).annotate(
|
||||
_data=EmptyGroupByJSONBAgg('data', ordering=['weight', 'name'])
|
||||
_data=EmptyGroupByJSONBAgg('data', order_by=['weight', 'name'])
|
||||
).values("_data").order_by()
|
||||
)
|
||||
)
|
||||
|
||||
@@ -329,6 +329,9 @@ class BaseScript:
|
||||
# Declare the placeholder for the current request
|
||||
self.request = None
|
||||
|
||||
# Initiate the storage backend (local, S3, etc) as a class attr
|
||||
self.storage = storages.create_storage(storages.backends["scripts"])
|
||||
|
||||
# Compile test methods and initialize results skeleton
|
||||
for method in dir(self):
|
||||
if method.startswith('test_') and callable(getattr(self, method)):
|
||||
@@ -394,8 +397,7 @@ class BaseScript:
|
||||
return inspect.getfile(self.__class__)
|
||||
|
||||
def findsource(self, object):
|
||||
storage = storages.create_storage(storages.backends["scripts"])
|
||||
with storage.open(os.path.basename(self.filename), 'r') as f:
|
||||
with self.storage.open(os.path.basename(self.filename), 'r') as f:
|
||||
data = f.read()
|
||||
|
||||
# Break the source code into lines
|
||||
|
||||
@@ -3,6 +3,7 @@ import datetime
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import make_aware, now
|
||||
from rest_framework import status
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.events import *
|
||||
@@ -858,16 +859,16 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
class ScriptTest(APITestCase):
|
||||
|
||||
class TestScriptClass(PythonClass):
|
||||
|
||||
class Meta:
|
||||
name = "Test script"
|
||||
name = 'Test script'
|
||||
commit = True
|
||||
scheduling_enabled = True
|
||||
|
||||
var1 = StringVar()
|
||||
var2 = IntegerVar()
|
||||
var3 = BooleanVar()
|
||||
|
||||
def run(self, data, commit=True):
|
||||
|
||||
self.log_info(data['var1'])
|
||||
self.log_success(data['var2'])
|
||||
self.log_failure(data['var3'])
|
||||
@@ -878,14 +879,16 @@ class ScriptTest(APITestCase):
|
||||
def setUpTestData(cls):
|
||||
module = ScriptModule.objects.create(
|
||||
file_root=ManagedFileRootPathChoices.SCRIPTS,
|
||||
file_path='/var/tmp/script.py'
|
||||
file_path='script.py',
|
||||
)
|
||||
Script.objects.create(
|
||||
script = Script.objects.create(
|
||||
module=module,
|
||||
name="Test script",
|
||||
name='Test script',
|
||||
is_executable=True,
|
||||
)
|
||||
cls.url = reverse('extras-api:script-detail', kwargs={'pk': script.pk})
|
||||
|
||||
@property
|
||||
def python_class(self):
|
||||
return self.TestScriptClass
|
||||
|
||||
@@ -898,7 +901,7 @@ class ScriptTest(APITestCase):
|
||||
def test_get_script(self):
|
||||
module = ScriptModule.objects.get(
|
||||
file_root=ManagedFileRootPathChoices.SCRIPTS,
|
||||
file_path='/var/tmp/script.py'
|
||||
file_path='script.py',
|
||||
)
|
||||
script = module.scripts.all().first()
|
||||
url = reverse('extras-api:script-detail', kwargs={'pk': script.pk})
|
||||
@@ -909,6 +912,76 @@ class ScriptTest(APITestCase):
|
||||
self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
|
||||
self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
|
||||
|
||||
def test_schedule_script_past_time_rejected(self):
|
||||
"""
|
||||
Scheduling with past schedule_at should fail.
|
||||
"""
|
||||
self.add_permissions('extras.run_script')
|
||||
|
||||
payload = {
|
||||
'data': {'var1': 'hello', 'var2': 1, 'var3': False},
|
||||
'commit': True,
|
||||
'schedule_at': now() - datetime.timedelta(hours=1),
|
||||
}
|
||||
response = self.client.post(self.url, payload, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('schedule_at', response.data)
|
||||
# Be tolerant of exact wording but ensure we failed on schedule_at being in the past
|
||||
self.assertIn('future', str(response.data['schedule_at']).lower())
|
||||
|
||||
def test_schedule_script_interval_only(self):
|
||||
"""
|
||||
Interval without schedule_at should auto-set schedule_at now.
|
||||
"""
|
||||
self.add_permissions('extras.run_script')
|
||||
|
||||
payload = {
|
||||
'data': {'var1': 'hello', 'var2': 1, 'var3': False},
|
||||
'commit': True,
|
||||
'interval': 60,
|
||||
}
|
||||
response = self.client.post(self.url, payload, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
# The latest job is returned in the script detail serializer under "result"
|
||||
self.assertIn('result', response.data)
|
||||
self.assertEqual(response.data['result']['interval'], 60)
|
||||
# Ensure a start time was autopopulated
|
||||
self.assertIsNotNone(response.data['result']['scheduled'])
|
||||
|
||||
def test_schedule_script_when_disabled(self):
|
||||
"""
|
||||
Scheduling should fail when script.scheduling_enabled=False.
|
||||
"""
|
||||
self.add_permissions('extras.run_script')
|
||||
|
||||
# Temporarily disable scheduling on the in-test Python class
|
||||
original = getattr(self.TestScriptClass.Meta, 'scheduling_enabled', True)
|
||||
self.TestScriptClass.Meta.scheduling_enabled = False
|
||||
base = {
|
||||
'data': {'var1': 'hello', 'var2': 1, 'var3': False},
|
||||
'commit': True,
|
||||
}
|
||||
# Check both schedule_at and interval paths
|
||||
cases = [
|
||||
{**base, 'schedule_at': now() + datetime.timedelta(minutes=5)},
|
||||
{**base, 'interval': 60},
|
||||
]
|
||||
try:
|
||||
for case in cases:
|
||||
with self.subTest(case=list(case.keys())):
|
||||
response = self.client.post(self.url, case, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
# Error should be attached to whichever field we used
|
||||
key = 'schedule_at' if 'schedule_at' in case else 'interval'
|
||||
self.assertIn(key, response.data)
|
||||
self.assertIn('scheduling is not enabled', str(response.data[key]).lower())
|
||||
finally:
|
||||
# Restore the original setting for other tests
|
||||
self.TestScriptClass.Meta.scheduling_enabled = original
|
||||
|
||||
|
||||
class CreatedUpdatedFilterTest(APITestCase):
|
||||
|
||||
|
||||
@@ -1300,6 +1300,28 @@ class CustomFieldAPITest(APITestCase):
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
def test_url_regex_validation(self):
|
||||
"""
|
||||
Test that validation_regex is applied to URL custom fields (fixes #20498).
|
||||
"""
|
||||
site2 = Site.objects.get(name='Site 2')
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
|
||||
self.add_permissions('dcim.change_site')
|
||||
|
||||
cf_url = CustomField.objects.get(name='url_field')
|
||||
cf_url.validation_regex = r'^https://' # Require HTTPS
|
||||
cf_url.save()
|
||||
|
||||
# Test invalid URL (http instead of https)
|
||||
data = {'custom_fields': {'url_field': 'http://example.com'}}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Test valid URL (https)
|
||||
data = {'custom_fields': {'url_field': 'https://example.com'}}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
def test_uniqueness_validation(self):
|
||||
# Create a unique custom field
|
||||
cf_text = CustomField.objects.get(name='text_field')
|
||||
|
||||
@@ -595,6 +595,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFil
|
||||
to_field_name='rd',
|
||||
label=_('VRF (RD)'),
|
||||
)
|
||||
assigned_object_type = ContentTypeFilter()
|
||||
device = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
field_name='name',
|
||||
@@ -908,7 +909,8 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
method='filter_scope'
|
||||
)
|
||||
contains_vid = django_filters.NumberFilter(
|
||||
method='filter_contains_vid'
|
||||
field_name='vid_ranges',
|
||||
lookup_expr='range_contains',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -931,21 +933,6 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
scope_id=value
|
||||
)
|
||||
|
||||
def filter_contains_vid(self, queryset, name, value):
|
||||
"""
|
||||
Return all VLANGroups which contain the given VLAN ID.
|
||||
"""
|
||||
table_name = VLANGroup._meta.db_table
|
||||
# TODO: See if this can be optimized without compromising queryset integrity
|
||||
# Expand VLAN ID ranges to query by integer
|
||||
groups = VLANGroup.objects.raw(
|
||||
f'SELECT id FROM {table_name}, unnest(vid_ranges) vid_range WHERE %s <@ vid_range',
|
||||
params=(value,)
|
||||
)
|
||||
return queryset.filter(
|
||||
pk__in=[g.id for g in groups]
|
||||
)
|
||||
|
||||
|
||||
class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
@@ -1166,6 +1153,7 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet):
|
||||
|
||||
|
||||
class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):
|
||||
parent_object_type = ContentTypeFilter()
|
||||
device = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
field_name='name',
|
||||
|
||||
@@ -19,7 +19,7 @@ from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
|
||||
from virtualization.models import VMInterface
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from netbox.graphql.filter_lookups import IntegerArrayLookup, IntegerLookup
|
||||
from netbox.graphql.filter_lookups import IntegerLookup, IntegerRangeArrayLookup
|
||||
from circuits.graphql.filters import ProviderFilter
|
||||
from core.graphql.filters import ContentTypeFilter
|
||||
from dcim.graphql.filters import SiteFilter
|
||||
@@ -170,7 +170,7 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
|
||||
|
||||
@strawberry_django.filter_field()
|
||||
def assigned(self, value: bool, prefix) -> Q:
|
||||
return Q(assigned_object_id__isnull=(not value))
|
||||
return Q(**{f"{prefix}assigned_object_id__isnull": not value})
|
||||
|
||||
@strawberry_django.filter_field()
|
||||
def parent(self, value: list[str], prefix) -> Q:
|
||||
@@ -340,7 +340,7 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
|
||||
|
||||
@strawberry_django.filter_type(models.VLANGroup, lookups=True)
|
||||
class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilterMixin):
|
||||
vid_ranges: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
vid_ranges: Annotated['IntegerRangeArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ class BaseIPAddressFamilyType:
|
||||
filters=ASNFilter,
|
||||
pagination=True
|
||||
)
|
||||
class ASNType(NetBoxObjectType):
|
||||
class ASNType(NetBoxObjectType, ContactsMixin):
|
||||
asn: BigInt
|
||||
rir: Annotated["RIRType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||
|
||||
@@ -10,9 +10,9 @@ from django.utils.translation import gettext_lazy as _
|
||||
from dcim.models import Interface, Site, SiteGroup
|
||||
from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
|
||||
from ipam.querysets import VLANGroupQuerySet, VLANQuerySet
|
||||
from netbox.models import OrganizationalModel, PrimaryModel, NetBoxModel
|
||||
from utilities.data import check_ranges_overlap, ranges_to_string
|
||||
from utilities.data import check_ranges_overlap, ranges_to_string, ranges_to_string_list
|
||||
from virtualization.models import VMInterface
|
||||
|
||||
__all__ = (
|
||||
@@ -164,8 +164,18 @@ class VLANGroup(OrganizationalModel):
|
||||
"""
|
||||
return VLAN.objects.filter(group=self).order_by('vid')
|
||||
|
||||
@property
|
||||
def vid_ranges_items(self):
|
||||
"""
|
||||
Property that converts VID ranges to a list of string representations.
|
||||
"""
|
||||
return ranges_to_string_list(self.vid_ranges)
|
||||
|
||||
@property
|
||||
def vid_ranges_list(self):
|
||||
"""
|
||||
Property that converts VID ranges into a string representation.
|
||||
"""
|
||||
return ranges_to_string(self.vid_ranges)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import django_tables2 as tables
|
||||
|
||||
from ipam.models import *
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import ContactsColumnMixin
|
||||
|
||||
__all__ = (
|
||||
'ServiceTable',
|
||||
@@ -35,7 +36,7 @@ class ServiceTemplateTable(NetBoxTable):
|
||||
default_columns = ('pk', 'name', 'protocol', 'ports', 'description')
|
||||
|
||||
|
||||
class ServiceTable(NetBoxTable):
|
||||
class ServiceTable(ContactsColumnMixin, NetBoxTable):
|
||||
name = tables.Column(
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
@@ -60,7 +61,7 @@ class ServiceTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Service
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags',
|
||||
'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'contacts', 'comments',
|
||||
'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')
|
||||
|
||||
@@ -41,7 +41,8 @@ class VLANGroupTable(TenancyColumnsMixin, NetBoxTable):
|
||||
linkify=True,
|
||||
orderable=False
|
||||
)
|
||||
vid_ranges_list = tables.Column(
|
||||
vid_ranges_list = columns.ArrayColumn(
|
||||
accessor='vid_ranges_items',
|
||||
verbose_name=_('VID Ranges'),
|
||||
orderable=False
|
||||
)
|
||||
|
||||
@@ -1723,6 +1723,10 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'contains_vid': 1}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
|
||||
params = {'contains_vid': 12} # 11 is NOT in [1,11)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'contains_vid': 4095}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
||||
def test_region(self):
|
||||
params = {'region': Region.objects.first().pk}
|
||||
|
||||
66
netbox/ipam/tests/test_lookups.py
Normal file
66
netbox/ipam/tests/test_lookups.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from django.test import TestCase
|
||||
from django.db.backends.postgresql.psycopg_any import NumericRange
|
||||
from ipam.models import VLANGroup
|
||||
|
||||
|
||||
class VLANGroupRangeContainsLookupTests(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
# Two ranges: [1,11) and [20,31)
|
||||
cls.g1 = VLANGroup.objects.create(
|
||||
name='VlanGroup-A',
|
||||
slug='VlanGroup-A',
|
||||
vid_ranges=[NumericRange(1, 11), NumericRange(20, 31)],
|
||||
)
|
||||
# One range: [100,201)
|
||||
cls.g2 = VLANGroup.objects.create(
|
||||
name='VlanGroup-B',
|
||||
slug='VlanGroup-B',
|
||||
vid_ranges=[NumericRange(100, 201)],
|
||||
)
|
||||
cls.g_empty = VLANGroup.objects.create(
|
||||
name='VlanGroup-empty',
|
||||
slug='VlanGroup-empty',
|
||||
vid_ranges=[],
|
||||
)
|
||||
|
||||
def test_contains_value_in_first_range(self):
|
||||
"""
|
||||
Tests whether a specific value is contained within the first range in a queried
|
||||
set of VLANGroup objects.
|
||||
"""
|
||||
names = list(
|
||||
VLANGroup.objects.filter(vid_ranges__range_contains=10).values_list('name', flat=True).order_by('name')
|
||||
)
|
||||
self.assertEqual(names, ['VlanGroup-A'])
|
||||
|
||||
def test_contains_value_in_second_range(self):
|
||||
"""
|
||||
Tests if a value exists in the second range of VLANGroup objects and
|
||||
validates the result against the expected list of names.
|
||||
"""
|
||||
names = list(
|
||||
VLANGroup.objects.filter(vid_ranges__range_contains=25).values_list('name', flat=True).order_by('name')
|
||||
)
|
||||
self.assertEqual(names, ['VlanGroup-A'])
|
||||
|
||||
def test_upper_bound_is_exclusive(self):
|
||||
"""
|
||||
Tests if the upper bound of the range is exclusive in the filter method.
|
||||
"""
|
||||
# 11 is NOT in [1,11)
|
||||
self.assertFalse(VLANGroup.objects.filter(vid_ranges__range_contains=11).exists())
|
||||
|
||||
def test_no_match_far_outside(self):
|
||||
"""
|
||||
Tests that no VLANGroup contains a VID within a specified range far outside
|
||||
common VID bounds and returns `False`.
|
||||
"""
|
||||
self.assertFalse(VLANGroup.objects.filter(vid_ranges__range_contains=4095).exists())
|
||||
|
||||
def test_empty_array_never_matches(self):
|
||||
"""
|
||||
Tests the behavior of VLANGroup objects when an empty array is used to match a
|
||||
specific condition.
|
||||
"""
|
||||
self.assertFalse(VLANGroup.objects.filter(pk=self.g_empty.pk, vid_ranges__range_contains=1).exists())
|
||||
@@ -169,7 +169,7 @@ class IntegerRangeSerializer(serializers.Serializer):
|
||||
if type(data[0]) is not int or type(data[1]) is not int:
|
||||
raise ValidationError(_("Range boundaries must be defined as integers."))
|
||||
|
||||
return NumericRange(data[0], data[1], bounds='[]')
|
||||
return NumericRange(data[0], data[1] + 1, bounds='[)')
|
||||
|
||||
def to_representation(self, instance):
|
||||
return instance.lower, instance.upper - 1
|
||||
|
||||
@@ -44,22 +44,28 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
return list(queryset[self.offset:])
|
||||
|
||||
def get_limit(self, request):
|
||||
max_limit = self.default_limit
|
||||
MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
|
||||
if MAX_PAGE_SIZE:
|
||||
max_limit = min(max_limit, MAX_PAGE_SIZE)
|
||||
|
||||
if self.limit_query_param:
|
||||
MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
|
||||
if MAX_PAGE_SIZE:
|
||||
MAX_PAGE_SIZE = max(MAX_PAGE_SIZE, self.default_limit)
|
||||
try:
|
||||
limit = int(request.query_params[self.limit_query_param])
|
||||
if limit < 0:
|
||||
raise ValueError()
|
||||
# Enforce maximum page size, if defined
|
||||
|
||||
if MAX_PAGE_SIZE:
|
||||
return MAX_PAGE_SIZE if limit == 0 else min(limit, MAX_PAGE_SIZE)
|
||||
return limit
|
||||
if limit == 0:
|
||||
max_limit = MAX_PAGE_SIZE
|
||||
else:
|
||||
max_limit = min(MAX_PAGE_SIZE, limit)
|
||||
else:
|
||||
max_limit = limit
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
|
||||
return self.default_limit
|
||||
return max_limit
|
||||
|
||||
def get_queryset_count(self, queryset):
|
||||
return queryset.count()
|
||||
|
||||
@@ -78,11 +78,16 @@ class Config:
|
||||
from core.models import ConfigRevision
|
||||
|
||||
try:
|
||||
revision = ConfigRevision.objects.last()
|
||||
# Enforce the creation date as the ordering parameter
|
||||
revision = ConfigRevision.objects.get(active=True)
|
||||
logger.debug(f"Loaded active configuration revision #{revision.pk}")
|
||||
except (ConfigRevision.DoesNotExist, ConfigRevision.MultipleObjectsReturned):
|
||||
logger.warning("No active configuration revision found - falling back to most recent")
|
||||
revision = ConfigRevision.objects.order_by('-created').first()
|
||||
if revision is None:
|
||||
logger.debug("No previous configuration found in database; proceeding with default values")
|
||||
return
|
||||
logger.debug("Loaded configuration data from database")
|
||||
logger.debug(f"Using fallback configuration revision #{revision.pk}")
|
||||
except DatabaseError:
|
||||
# The database may not be available yet (e.g. when running a management command)
|
||||
logger.warning("Skipping config initialization (database unavailable)")
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.core.exceptions import FieldDoesNotExist
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.db.models.fields.related import ForeignKey, ManyToManyField, ManyToManyRel, ManyToOneRel
|
||||
from strawberry import ID
|
||||
from strawberry.directive import DirectiveValue
|
||||
from strawberry.types import Info
|
||||
from strawberry_django import (
|
||||
ComparisonFilterLookup,
|
||||
@@ -24,6 +25,7 @@ __all__ = (
|
||||
'FloatLookup',
|
||||
'IntegerArrayLookup',
|
||||
'IntegerLookup',
|
||||
'IntegerRangeArrayLookup',
|
||||
'JSONFilter',
|
||||
'StringArrayLookup',
|
||||
'TreeNodeFilter',
|
||||
@@ -67,7 +69,7 @@ class IntegerLookup:
|
||||
return None
|
||||
|
||||
@strawberry_django.filter_field
|
||||
def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
|
||||
def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> Tuple[QuerySet, Q]:
|
||||
filters = self.get_filter()
|
||||
|
||||
if not filters:
|
||||
@@ -90,7 +92,7 @@ class FloatLookup:
|
||||
return None
|
||||
|
||||
@strawberry_django.filter_field
|
||||
def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
|
||||
def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> Tuple[QuerySet, Q]:
|
||||
filters = self.get_filter()
|
||||
|
||||
if not filters:
|
||||
@@ -109,7 +111,7 @@ class JSONFilter:
|
||||
lookup: JSONLookup
|
||||
|
||||
@strawberry_django.filter_field
|
||||
def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
|
||||
def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> Tuple[QuerySet, Q]:
|
||||
filters = self.lookup.get_filter()
|
||||
|
||||
if not filters:
|
||||
@@ -136,7 +138,7 @@ class TreeNodeFilter:
|
||||
match_type: TreeNodeMatch
|
||||
|
||||
@strawberry_django.filter_field
|
||||
def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
|
||||
def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> Tuple[QuerySet, Q]:
|
||||
model_field_name = prefix.removesuffix('__').removesuffix('_id')
|
||||
model_field = None
|
||||
try:
|
||||
@@ -217,3 +219,30 @@ class FloatArrayLookup(ArrayLookup[float]):
|
||||
@strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.')
|
||||
class StringArrayLookup(ArrayLookup[str]):
|
||||
pass
|
||||
|
||||
|
||||
@strawberry.input(one_of=True, description='Lookups for an ArrayField(RangeField). Only one may be set.')
|
||||
class RangeArrayValueLookup(Generic[T]):
|
||||
"""
|
||||
class for Array field of Range fields lookups
|
||||
"""
|
||||
|
||||
contains: T | None = strawberry.field(
|
||||
default=strawberry.UNSET, description='Return rows where any stored range contains this value.'
|
||||
)
|
||||
|
||||
@strawberry_django.filter_field
|
||||
def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
|
||||
"""
|
||||
Map GraphQL: { <field>: { contains: <T> } } To Django ORM: <field>__range_contains=<T>
|
||||
"""
|
||||
if self.contains is strawberry.UNSET or self.contains is None:
|
||||
return queryset, Q()
|
||||
|
||||
# Build '<prefix>range_contains' so it works for nested paths too
|
||||
return queryset, Q(**{f'{prefix}range_contains': self.contains})
|
||||
|
||||
|
||||
@strawberry.input(one_of=True, description='Lookups for an ArrayField(IntegerRangeField). Only one may be set.')
|
||||
class IntegerRangeArrayLookup(RangeArrayValueLookup[int]):
|
||||
pass
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import strawberry
|
||||
import strawberry_django
|
||||
from strawberry.types import Info
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from core.graphql.mixins import ChangelogMixin
|
||||
@@ -26,7 +27,7 @@ class BaseObjectType:
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_queryset(cls, queryset, info, **kwargs):
|
||||
def get_queryset(cls, queryset, info: Info, **kwargs):
|
||||
# Enforce object permissions on the queryset
|
||||
if hasattr(queryset, 'restrict'):
|
||||
return queryset.restrict(info.context.request.user, 'view')
|
||||
|
||||
@@ -673,10 +673,17 @@ def has_feature(model_or_ct, feature):
|
||||
# If an ObjectType was passed, we can use it directly
|
||||
if type(model_or_ct) is ObjectType:
|
||||
ot = model_or_ct
|
||||
# If a ContentType was passed, resolve its model class
|
||||
# If a ContentType was passed, resolve its model class and run the associated feature test
|
||||
elif type(model_or_ct) is ContentType:
|
||||
model_class = model_or_ct.model_class()
|
||||
ot = ObjectType.objects.get_for_model(model_class) if model_class else None
|
||||
model = model_or_ct.model_class()
|
||||
if model is None: # Stale content type
|
||||
return False
|
||||
try:
|
||||
test_func = registry['model_features'][feature]
|
||||
except KeyError:
|
||||
# Unknown feature
|
||||
return False
|
||||
return test_func(model)
|
||||
# For anything else, look up the ObjectType
|
||||
else:
|
||||
ot = ObjectType.objects.get_for_model(model_or_ct)
|
||||
|
||||
39
netbox/netbox/monkey.py
Normal file
39
netbox/netbox/monkey.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from django.db.models import UniqueConstraint
|
||||
from rest_framework.utils.field_mapping import get_unique_error_message
|
||||
from rest_framework.validators import UniqueValidator
|
||||
|
||||
__all__ = (
|
||||
'get_unique_validators',
|
||||
)
|
||||
|
||||
|
||||
def get_unique_validators(field_name, model_field):
|
||||
"""
|
||||
Extend Django REST Framework's get_unique_validators() function to attach a UniqueValidator to a field *only* if the
|
||||
associated UniqueConstraint does NOT have a condition which references another field. See bug #19302.
|
||||
"""
|
||||
field_set = {field_name}
|
||||
conditions = {
|
||||
c.condition
|
||||
for c in model_field.model._meta.constraints
|
||||
if isinstance(c, UniqueConstraint) and set(c.fields) == field_set
|
||||
}
|
||||
|
||||
# START custom logic
|
||||
conditions = {
|
||||
cond for cond in conditions
|
||||
if cond is None or cond.referenced_base_fields == field_set
|
||||
}
|
||||
# END custom logic
|
||||
|
||||
if getattr(model_field, 'unique', False):
|
||||
conditions.add(None)
|
||||
if not conditions:
|
||||
return
|
||||
unique_error_message = get_unique_error_message(model_field)
|
||||
queryset = model_field.model._default_manager
|
||||
for condition in conditions:
|
||||
yield UniqueValidator(
|
||||
queryset=queryset if condition is None else queryset.filter(condition),
|
||||
message=unique_error_message
|
||||
)
|
||||
@@ -11,6 +11,7 @@ from django.core.exceptions import ImproperlyConfigured, ValidationError
|
||||
from django.core.validators import URLValidator
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.utils import field_mapping
|
||||
|
||||
from core.exceptions import IncompatiblePluginError
|
||||
from netbox.config import PARAMS as CONFIG_PARAMS
|
||||
@@ -20,6 +21,17 @@ from netbox.registry import registry
|
||||
import storages.utils # type: ignore
|
||||
from utilities.release import load_release_data
|
||||
from utilities.string import trailing_slash
|
||||
from .monkey import get_unique_validators
|
||||
|
||||
|
||||
#
|
||||
# Monkey-patching
|
||||
#
|
||||
|
||||
# TODO: Remove this once #20547 has been implemented
|
||||
# Override DRF's get_unique_validators() function with our own (see bug #19302)
|
||||
field_mapping.get_unique_validators = get_unique_validators
|
||||
|
||||
|
||||
#
|
||||
# Environment setup
|
||||
|
||||
@@ -281,7 +281,8 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
|
||||
|
||||
obj = self.alter_object(obj, request, args, kwargs)
|
||||
|
||||
form = self.form(data=request.POST, files=request.FILES, instance=obj)
|
||||
form_prefix = 'quickadd' if request.GET.get('_quickadd') else None
|
||||
form = self.form(data=request.POST, files=request.FILES, instance=obj, prefix=form_prefix)
|
||||
restrict_form_fields(form, request.user)
|
||||
|
||||
if form.is_valid():
|
||||
|
||||
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
8
netbox/project-static/dist/netbox.js
vendored
8
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
4
netbox/project-static/dist/netbox.js.map
vendored
4
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -41,7 +41,7 @@
|
||||
"@types/node": "^22.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.37.0",
|
||||
"@typescript-eslint/parser": "^8.37.0",
|
||||
"esbuild": "^0.25.6",
|
||||
"esbuild": "^0.25.11",
|
||||
"esbuild-sass-plugin": "^3.3.1",
|
||||
"eslint": "<9.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
|
||||
@@ -20,11 +20,13 @@ function slugify(slug: string, chars: number): string {
|
||||
* For any slug fields, add event listeners to handle automatically generating slug values.
|
||||
*/
|
||||
export function initReslug(): void {
|
||||
for (const slugButton of getElements<HTMLButtonElement>('button#reslug')) {
|
||||
for (const slugButton of getElements<HTMLButtonElement>('button.reslug')) {
|
||||
const form = slugButton.form;
|
||||
if (form == null) continue;
|
||||
const slugField = form.querySelector('#id_slug') as HTMLInputElement;
|
||||
|
||||
const slugField = form.querySelector('input.slug-field') as HTMLInputElement;
|
||||
if (slugField == null) continue;
|
||||
|
||||
const sourceId = slugField.getAttribute('slug-source');
|
||||
const sourceField = form.querySelector(`#id_${sourceId}`) as HTMLInputElement;
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ export function initRackElevation(): void {
|
||||
}
|
||||
|
||||
for (const element of getElements<HTMLObjectElement>('.rack_elevation')) {
|
||||
element.addEventListener('load', () => {
|
||||
element.addEventListener('htmx:afterSettle', () => {
|
||||
setRackView(initialView, element);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ pre {
|
||||
background: var(--#{$prefix}bg-surface);
|
||||
}
|
||||
|
||||
// Permit copying of badge text
|
||||
.badge {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
// Button adjustments
|
||||
.btn {
|
||||
// Tabler sets display: flex
|
||||
|
||||
@@ -19,135 +19,135 @@
|
||||
resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb"
|
||||
integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==
|
||||
|
||||
"@esbuild/aix-ppc64@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz#a1414903bb38027382f85f03dda6065056757727"
|
||||
integrity sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==
|
||||
"@esbuild/aix-ppc64@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz#2ae33300598132cc4cf580dbbb28d30fed3c5c49"
|
||||
integrity sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==
|
||||
|
||||
"@esbuild/android-arm64@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz#c859994089e9767224269884061f89dae6fb51c6"
|
||||
integrity sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==
|
||||
"@esbuild/android-arm64@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz#927708b3db5d739d6cb7709136924cc81bec9b03"
|
||||
integrity sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==
|
||||
|
||||
"@esbuild/android-arm@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.8.tgz#96a8f2ca91c6cd29ea90b1af79d83761c8ba0059"
|
||||
integrity sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==
|
||||
"@esbuild/android-arm@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.11.tgz#571f94e7f4068957ec4c2cfb907deae3d01b55ae"
|
||||
integrity sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==
|
||||
|
||||
"@esbuild/android-x64@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.8.tgz#a3a626c4fec4a024a9fa8c7679c39996e92916f0"
|
||||
integrity sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==
|
||||
"@esbuild/android-x64@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.11.tgz#8a3bf5cae6c560c7ececa3150b2bde76e0fb81e6"
|
||||
integrity sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==
|
||||
|
||||
"@esbuild/darwin-arm64@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz#a5e1252ca2983d566af1c0ea39aded65736fc66d"
|
||||
integrity sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==
|
||||
"@esbuild/darwin-arm64@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz#0a678c4ac4bf8717e67481e1a797e6c152f93c84"
|
||||
integrity sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==
|
||||
|
||||
"@esbuild/darwin-x64@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz#5271b0df2bb12ce8df886704bfdd1c7cc01385d2"
|
||||
integrity sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==
|
||||
"@esbuild/darwin-x64@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz#70f5e925a30c8309f1294d407a5e5e002e0315fe"
|
||||
integrity sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==
|
||||
|
||||
"@esbuild/freebsd-arm64@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz#d0a0e7fdf19733b8bb1566b81df1aa0bb7e46ada"
|
||||
integrity sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==
|
||||
"@esbuild/freebsd-arm64@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz#4ec1db687c5b2b78b44148025da9632397553e8a"
|
||||
integrity sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==
|
||||
|
||||
"@esbuild/freebsd-x64@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz#2de8b2e0899d08f1cb1ef3128e159616e7e85343"
|
||||
integrity sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==
|
||||
"@esbuild/freebsd-x64@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz#4c81abd1b142f1e9acfef8c5153d438ca53f44bb"
|
||||
integrity sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==
|
||||
|
||||
"@esbuild/linux-arm64@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz#a4209efadc0c2975716458484a4e90c237c48ae9"
|
||||
integrity sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==
|
||||
"@esbuild/linux-arm64@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz#69517a111acfc2b93aa0fb5eaeb834c0202ccda5"
|
||||
integrity sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==
|
||||
|
||||
"@esbuild/linux-arm@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz#ccd9e291c24cd8d9142d819d463e2e7200d25b19"
|
||||
integrity sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==
|
||||
"@esbuild/linux-arm@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz#58dac26eae2dba0fac5405052b9002dac088d38f"
|
||||
integrity sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==
|
||||
|
||||
"@esbuild/linux-ia32@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz#006ad1536d0c2b28fb3a1cf0b53bcb85aaf92c4d"
|
||||
integrity sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==
|
||||
"@esbuild/linux-ia32@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz#b89d4efe9bdad46ba944f0f3b8ddd40834268c2b"
|
||||
integrity sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==
|
||||
|
||||
"@esbuild/linux-loong64@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz#127b3fbfb2c2e08b1397e985932f718f09a8f5c4"
|
||||
integrity sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==
|
||||
"@esbuild/linux-loong64@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz#11f603cb60ad14392c3f5c94d64b3cc8b630fbeb"
|
||||
integrity sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==
|
||||
|
||||
"@esbuild/linux-mips64el@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz#837d1449517791e3fa7d82675a2d06d9f56cb340"
|
||||
integrity sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==
|
||||
"@esbuild/linux-mips64el@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz#b7d447ff0676b8ab247d69dac40a5cf08e5eeaf5"
|
||||
integrity sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==
|
||||
|
||||
"@esbuild/linux-ppc64@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz#aa2e3bd93ab8df084212f1895ca4b03c42d9e0fe"
|
||||
integrity sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==
|
||||
"@esbuild/linux-ppc64@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz#b3a28ed7cc252a61b07ff7c8fd8a984ffd3a2f74"
|
||||
integrity sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==
|
||||
|
||||
"@esbuild/linux-riscv64@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz#a340620e31093fef72767dd28ab04214b3442083"
|
||||
integrity sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==
|
||||
"@esbuild/linux-riscv64@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz#ce75b08f7d871a75edcf4d2125f50b21dc9dc273"
|
||||
integrity sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==
|
||||
|
||||
"@esbuild/linux-s390x@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz#ddfed266c8c13f5efb3105a0cd47f6dcd0e79e71"
|
||||
integrity sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==
|
||||
"@esbuild/linux-s390x@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz#cd08f6c73b6b6ff9ccdaabbd3ff6ad3dca99c263"
|
||||
integrity sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==
|
||||
|
||||
"@esbuild/linux-x64@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz#9a4f78c75c051e8c060183ebb39a269ba936a2ac"
|
||||
integrity sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==
|
||||
"@esbuild/linux-x64@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz#3c3718af31a95d8946ebd3c32bb1e699bdf74910"
|
||||
integrity sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==
|
||||
|
||||
"@esbuild/netbsd-arm64@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz#902c80e1d678047926387230bc037e63e00697d0"
|
||||
integrity sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==
|
||||
"@esbuild/netbsd-arm64@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz#b4c767082401e3a4e8595fe53c47cd7f097c8077"
|
||||
integrity sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==
|
||||
|
||||
"@esbuild/netbsd-x64@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz#2d9eb4692add2681ff05a14ce99de54fbed7079c"
|
||||
integrity sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==
|
||||
"@esbuild/netbsd-x64@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz#f2a930458ed2941d1f11ebc34b9c7d61f7a4d034"
|
||||
integrity sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==
|
||||
|
||||
"@esbuild/openbsd-arm64@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz#89c3b998c6de739db38ab7fb71a8a76b3fa84a45"
|
||||
integrity sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==
|
||||
"@esbuild/openbsd-arm64@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz#b4ae93c75aec48bc1e8a0154957a05f0641f2dad"
|
||||
integrity sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==
|
||||
|
||||
"@esbuild/openbsd-x64@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz#2f01615cf472b0e48c077045cfd96b5c149365cc"
|
||||
integrity sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==
|
||||
"@esbuild/openbsd-x64@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz#b42863959c8dcf9b01581522e40012d2c70045e2"
|
||||
integrity sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==
|
||||
|
||||
"@esbuild/openharmony-arm64@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz#a201f720cd2c3ebf9a6033fcc3feb069a54b509a"
|
||||
integrity sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==
|
||||
"@esbuild/openharmony-arm64@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz#b2e717141c8fdf6bddd4010f0912e6b39e1640f1"
|
||||
integrity sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==
|
||||
|
||||
"@esbuild/sunos-x64@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz#07046c977985a3334667f19e6ab3a01a80862afb"
|
||||
integrity sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==
|
||||
"@esbuild/sunos-x64@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz#9fbea1febe8778927804828883ec0f6dd80eb244"
|
||||
integrity sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==
|
||||
|
||||
"@esbuild/win32-arm64@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz#4a5470caf0d16127c05d4833d4934213c69392d1"
|
||||
integrity sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==
|
||||
"@esbuild/win32-arm64@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz#501539cedb24468336073383989a7323005a8935"
|
||||
integrity sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==
|
||||
|
||||
"@esbuild/win32-ia32@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz#3de3e8470b7b328d99dbc3e9ec1eace207e5bbc4"
|
||||
integrity sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==
|
||||
"@esbuild/win32-ia32@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz#8ac7229aa82cef8f16ffb58f1176a973a7a15343"
|
||||
integrity sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==
|
||||
|
||||
"@esbuild/win32-x64@0.25.8":
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz#610d7ea539d2fcdbe39237b5cc175eb2c4451f9c"
|
||||
integrity sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==
|
||||
"@esbuild/win32-x64@0.25.11":
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz#5ecda6f3fe138b7e456f4e429edde33c823f392f"
|
||||
integrity sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==
|
||||
|
||||
"@eslint-community/eslint-utils@^4.2.0":
|
||||
version "4.4.0"
|
||||
@@ -1642,37 +1642,37 @@ esbuild-sass-plugin@^3.3.1:
|
||||
safe-identifier "^0.4.2"
|
||||
sass "^1.71.1"
|
||||
|
||||
esbuild@^0.25.6:
|
||||
version "0.25.8"
|
||||
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.8.tgz#482d42198b427c9c2f3a81b63d7663aecb1dda07"
|
||||
integrity sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==
|
||||
esbuild@^0.25.11:
|
||||
version "0.25.11"
|
||||
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.11.tgz#0f31b82f335652580f75ef6897bba81962d9ae3d"
|
||||
integrity sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==
|
||||
optionalDependencies:
|
||||
"@esbuild/aix-ppc64" "0.25.8"
|
||||
"@esbuild/android-arm" "0.25.8"
|
||||
"@esbuild/android-arm64" "0.25.8"
|
||||
"@esbuild/android-x64" "0.25.8"
|
||||
"@esbuild/darwin-arm64" "0.25.8"
|
||||
"@esbuild/darwin-x64" "0.25.8"
|
||||
"@esbuild/freebsd-arm64" "0.25.8"
|
||||
"@esbuild/freebsd-x64" "0.25.8"
|
||||
"@esbuild/linux-arm" "0.25.8"
|
||||
"@esbuild/linux-arm64" "0.25.8"
|
||||
"@esbuild/linux-ia32" "0.25.8"
|
||||
"@esbuild/linux-loong64" "0.25.8"
|
||||
"@esbuild/linux-mips64el" "0.25.8"
|
||||
"@esbuild/linux-ppc64" "0.25.8"
|
||||
"@esbuild/linux-riscv64" "0.25.8"
|
||||
"@esbuild/linux-s390x" "0.25.8"
|
||||
"@esbuild/linux-x64" "0.25.8"
|
||||
"@esbuild/netbsd-arm64" "0.25.8"
|
||||
"@esbuild/netbsd-x64" "0.25.8"
|
||||
"@esbuild/openbsd-arm64" "0.25.8"
|
||||
"@esbuild/openbsd-x64" "0.25.8"
|
||||
"@esbuild/openharmony-arm64" "0.25.8"
|
||||
"@esbuild/sunos-x64" "0.25.8"
|
||||
"@esbuild/win32-arm64" "0.25.8"
|
||||
"@esbuild/win32-ia32" "0.25.8"
|
||||
"@esbuild/win32-x64" "0.25.8"
|
||||
"@esbuild/aix-ppc64" "0.25.11"
|
||||
"@esbuild/android-arm" "0.25.11"
|
||||
"@esbuild/android-arm64" "0.25.11"
|
||||
"@esbuild/android-x64" "0.25.11"
|
||||
"@esbuild/darwin-arm64" "0.25.11"
|
||||
"@esbuild/darwin-x64" "0.25.11"
|
||||
"@esbuild/freebsd-arm64" "0.25.11"
|
||||
"@esbuild/freebsd-x64" "0.25.11"
|
||||
"@esbuild/linux-arm" "0.25.11"
|
||||
"@esbuild/linux-arm64" "0.25.11"
|
||||
"@esbuild/linux-ia32" "0.25.11"
|
||||
"@esbuild/linux-loong64" "0.25.11"
|
||||
"@esbuild/linux-mips64el" "0.25.11"
|
||||
"@esbuild/linux-ppc64" "0.25.11"
|
||||
"@esbuild/linux-riscv64" "0.25.11"
|
||||
"@esbuild/linux-s390x" "0.25.11"
|
||||
"@esbuild/linux-x64" "0.25.11"
|
||||
"@esbuild/netbsd-arm64" "0.25.11"
|
||||
"@esbuild/netbsd-x64" "0.25.11"
|
||||
"@esbuild/openbsd-arm64" "0.25.11"
|
||||
"@esbuild/openbsd-x64" "0.25.11"
|
||||
"@esbuild/openharmony-arm64" "0.25.11"
|
||||
"@esbuild/sunos-x64" "0.25.11"
|
||||
"@esbuild/win32-arm64" "0.25.11"
|
||||
"@esbuild/win32-ia32" "0.25.11"
|
||||
"@esbuild/win32-x64" "0.25.11"
|
||||
|
||||
escape-string-regexp@^4.0.0:
|
||||
version "4.0.0"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version: "4.4.2"
|
||||
version: "4.4.4"
|
||||
edition: "Community"
|
||||
published: "2025-09-30"
|
||||
published: "2025-10-15"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'account:preferences' %}">{% trans "Preferences" %}</a>
|
||||
</li>
|
||||
{% if not request.user.ldap_username %}
|
||||
{% if request.user.has_usable_password %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{% if active_tab == 'password' %} active{% endif %}" href="{% url 'account:change_password' %}">{% trans "Password" %}</a>
|
||||
</li>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
{# Initialize color mode #}
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="{% static 'setmode.js' %}?v={{ settings.RELEASE.version }}"
|
||||
src="{% static_with_params 'setmode.js' v=settings.RELEASE.version %}"
|
||||
onerror="window.location='{% url 'media_failure' %}?filename=setmode.js'">
|
||||
</script>
|
||||
<script type="text/javascript">
|
||||
@@ -39,12 +39,12 @@
|
||||
{# Static resources #}
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{% static 'netbox-external.css'%}?v={{ settings.RELEASE.version }}"
|
||||
href="{% static_with_params 'netbox-external.css' v=settings.RELEASE.version %}"
|
||||
onerror="window.location='{% url 'media_failure' %}?filename=netbox-external.css'"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{% static 'netbox.css'%}?v={{ settings.RELEASE.version }}"
|
||||
href="{% static_with_params 'netbox.css' v=settings.RELEASE.version %}"
|
||||
onerror="window.location='{% url 'media_failure' %}?filename=netbox.css'"
|
||||
/>
|
||||
<link rel="icon" type="image/png" href="{% static 'netbox.ico' %}" />
|
||||
@@ -53,7 +53,7 @@
|
||||
{# Javascript #}
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="{% static 'netbox.js' %}?v={{ settings.RELEASE.version }}"
|
||||
src="{% static_with_params 'netbox.js' v=settings.RELEASE.version %}"
|
||||
onerror="window.location='{% url 'media_failure' %}?filename=netbox.js'">
|
||||
</script>
|
||||
{% django_htmx_script %}
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
<div class="htmx-container table-responsive"
|
||||
hx-get="{% url 'extras:script_result' job_pk=job.pk %}?embedded=True&log=True&log_threshold={{log_threshold}}"
|
||||
hx-target="this"
|
||||
hx-trigger="load" hx-select=".htmx-container" hx-swap="outerHTML"
|
||||
></div>
|
||||
hx-trigger="load" hx-select=".htmx-container" hx-swap="outerHTML">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -60,11 +60,12 @@
|
||||
<a href="?export=output" class="btn btn-sm btn-primary" role="button">
|
||||
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
|
||||
</a>
|
||||
{% copy_content "job_data_output" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% if job.data.output %}
|
||||
<pre class="card-body font-monospace">{{ job.data.output }}</pre>
|
||||
<pre class="card-body font-monospace" id="job_data_output">{{ job.data.output }}</pre>
|
||||
{% else %}
|
||||
<div class="card-body text-muted">{% trans "None" %}</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -37,23 +37,23 @@
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow" {% htmx_boost %}>
|
||||
<a href="{% url 'account:profile' %}" class="dropdown-item">
|
||||
<i class="mdi mdi-account"></i> {% trans "Profile" %}
|
||||
<i class="dropdown-item-icon mdi mdi-account"></i> {% trans "Profile" %}
|
||||
</a>
|
||||
<a href="{% url 'account:bookmarks' %}" class="dropdown-item">
|
||||
<i class="mdi mdi-bookmark"></i> {% trans "Bookmarks" %}
|
||||
<i class="dropdown-item-icon mdi mdi-bookmark"></i> {% trans "Bookmarks" %}
|
||||
</a>
|
||||
<a href="{% url 'account:subscriptions' %}" class="dropdown-item">
|
||||
<i class="mdi mdi-bell"></i> {% trans "Subscriptions" %}
|
||||
<i class="dropdown-item-icon mdi mdi-bell"></i> {% trans "Subscriptions" %}
|
||||
</a>
|
||||
<a href="{% url 'account:preferences' %}" class="dropdown-item">
|
||||
<i class="mdi mdi-wrench"></i> {% trans "Preferences" %}
|
||||
<i class="dropdown-item-icon mdi mdi-wrench"></i> {% trans "Preferences" %}
|
||||
</a>
|
||||
<a href="{% url 'account:usertoken_list' %}" class="dropdown-item">
|
||||
<i class="mdi mdi-key"></i> {% trans "API Tokens" %}
|
||||
<i class="dropdown-item-icon mdi mdi-key"></i> {% trans "API Tokens" %}
|
||||
</a>
|
||||
<hr class="dropdown-divider" />
|
||||
<a href="{% url 'logout' %}" hx-disable="true" class="dropdown-item">
|
||||
<i class="mdi mdi-logout-variant"></i> {% trans "Log Out" %}
|
||||
<i class="dropdown-item-icon mdi mdi-logout-variant"></i> {% trans "Log Out" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "VLAN IDs" %}</th>
|
||||
<td>{{ object.vid_ranges_list }}</td>
|
||||
<td>{{ object.vid_ranges_items|join:", " }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Utilization</th>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -25,7 +26,7 @@ class TenantGroupImportForm(NetBoxModelImportForm):
|
||||
queryset=TenantGroup.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Parent group')
|
||||
help_text=_('Parent group'),
|
||||
)
|
||||
slug = SlugField()
|
||||
|
||||
@@ -41,7 +42,7 @@ class TenantImportForm(NetBoxModelImportForm):
|
||||
queryset=TenantGroup.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Assigned group')
|
||||
help_text=_('Assigned group'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -59,7 +60,7 @@ class ContactGroupImportForm(NetBoxModelImportForm):
|
||||
queryset=ContactGroup.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Parent group')
|
||||
help_text=_('Parent group'),
|
||||
)
|
||||
slug = SlugField()
|
||||
|
||||
@@ -81,7 +82,12 @@ class ContactImportForm(NetBoxModelImportForm):
|
||||
queryset=ContactGroup.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Group names separated by commas, encased with double quotes (e.g. "Group 1,Group 2")')
|
||||
help_text=_('Group names separated by commas, encased with double quotes (e.g. "Group 1,Group 2")'),
|
||||
)
|
||||
link = forms.URLField(
|
||||
label=_('Link'),
|
||||
assume_scheme='https',
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -100,6 +100,11 @@ class ContactForm(NetBoxModelForm):
|
||||
queryset=ContactGroup.objects.all(),
|
||||
required=False
|
||||
)
|
||||
link = forms.URLField(
|
||||
label=_('Link'),
|
||||
assume_scheme='https',
|
||||
required=False,
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
fieldsets = (
|
||||
|
||||
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
@@ -1,7 +1,8 @@
|
||||
import decimal
|
||||
from django.db.backends.postgresql.psycopg_any import NumericRange
|
||||
from itertools import count, groupby
|
||||
|
||||
from django.db.backends.postgresql.psycopg_any import NumericRange
|
||||
|
||||
__all__ = (
|
||||
'array_to_ranges',
|
||||
'array_to_string',
|
||||
@@ -10,6 +11,7 @@ __all__ = (
|
||||
'drange',
|
||||
'flatten_dict',
|
||||
'ranges_to_string',
|
||||
'ranges_to_string_list',
|
||||
'shallow_compare_dict',
|
||||
'string_to_ranges',
|
||||
)
|
||||
@@ -73,8 +75,10 @@ def shallow_compare_dict(source_dict, destination_dict, exclude=tuple()):
|
||||
def array_to_ranges(array):
|
||||
"""
|
||||
Convert an arbitrary array of integers to a list of consecutive values. Nonconsecutive values are returned as
|
||||
single-item tuples. For example:
|
||||
[0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]"
|
||||
single-item tuples.
|
||||
|
||||
Example:
|
||||
[0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]
|
||||
"""
|
||||
group = (
|
||||
list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x)
|
||||
@@ -87,7 +91,8 @@ def array_to_ranges(array):
|
||||
def array_to_string(array):
|
||||
"""
|
||||
Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField.
|
||||
For example:
|
||||
|
||||
Example:
|
||||
[0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
|
||||
"""
|
||||
ret = []
|
||||
@@ -135,26 +140,60 @@ def check_ranges_overlap(ranges):
|
||||
return False
|
||||
|
||||
|
||||
def ranges_to_string_list(ranges):
|
||||
"""
|
||||
Convert numeric ranges to a list of display strings.
|
||||
|
||||
Each range is rendered as "lower-upper" or "lower" (for singletons).
|
||||
Bounds are normalized to inclusive values using ``lower_inc``/``upper_inc``.
|
||||
This underpins ``ranges_to_string()``, which joins the result with commas.
|
||||
|
||||
Example:
|
||||
[NumericRange(1, 6), NumericRange(8, 9), NumericRange(10, 13)] => ["1-5", "8", "10-12"]
|
||||
"""
|
||||
if not ranges:
|
||||
return []
|
||||
|
||||
output: list[str] = []
|
||||
for r in ranges:
|
||||
# Compute inclusive bounds regardless of how the DB range is stored.
|
||||
lower = r.lower if r.lower_inc else r.lower + 1
|
||||
upper = r.upper if r.upper_inc else r.upper - 1
|
||||
output.append(f"{lower}-{upper}" if lower != upper else str(lower))
|
||||
return output
|
||||
|
||||
|
||||
def ranges_to_string(ranges):
|
||||
"""
|
||||
Generate a human-friendly string from a set of ranges. Intended for use with ArrayField. For example:
|
||||
[[1, 100)], [200, 300)] => "1-99,200-299"
|
||||
Converts a list of ranges into a string representation.
|
||||
|
||||
This function takes a list of range objects and produces a string
|
||||
representation of those ranges. Each range is represented as a
|
||||
hyphen-separated pair of lower and upper bounds, with inclusive or
|
||||
exclusive bounds adjusted accordingly. If the lower and upper bounds
|
||||
of a range are the same, only the single value is added to the string.
|
||||
Intended for use with ArrayField.
|
||||
|
||||
Example:
|
||||
[NumericRange(1, 5), NumericRange(8, 9), NumericRange(10, 12)] => "1-5,8,10-12"
|
||||
"""
|
||||
if not ranges:
|
||||
return ''
|
||||
output = []
|
||||
for r in ranges:
|
||||
lower = r.lower if r.lower_inc else r.lower + 1
|
||||
upper = r.upper if r.upper_inc else r.upper - 1
|
||||
output.append(f'{lower}-{upper}')
|
||||
return ','.join(output)
|
||||
return ','.join(ranges_to_string_list(ranges))
|
||||
|
||||
|
||||
def string_to_ranges(value):
|
||||
"""
|
||||
Given a string in the format "1-100, 200-300" return an list of NumericRanges. Intended for use with ArrayField.
|
||||
For example:
|
||||
"1-99,200-299" => [NumericRange(1, 100), NumericRange(200, 300)]
|
||||
Converts a string representation of numeric ranges into a list of NumericRange objects.
|
||||
|
||||
This function parses a string containing numeric values and ranges separated by commas (e.g.,
|
||||
"1-5,8,10-12") and converts it into a list of NumericRange objects.
|
||||
In the case of a single integer, it is treated as a range where the start and end
|
||||
are equal. The returned ranges are represented as half-open intervals [lower, upper).
|
||||
Intended for use with ArrayField.
|
||||
|
||||
Example:
|
||||
"1-5,8,10-12" => [NumericRange(1, 6), NumericRange(8, 9), NumericRange(10, 13)]
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
@@ -172,5 +211,5 @@ def string_to_ranges(value):
|
||||
upper = dash_range[1]
|
||||
else:
|
||||
return None
|
||||
values.append(NumericRange(int(lower), int(upper), bounds='[]'))
|
||||
values.append(NumericRange(int(lower), int(upper) + 1, bounds='[)'))
|
||||
return values
|
||||
|
||||
@@ -53,6 +53,14 @@ class SlugField(forms.SlugField):
|
||||
|
||||
self.widget.attrs['slug-source'] = slug_source
|
||||
|
||||
def get_bound_field(self, form, field_name):
|
||||
if prefix := form.prefix:
|
||||
slug_source = self.widget.attrs.get('slug-source')
|
||||
if slug_source and not slug_source.startswith(f'{prefix}-'):
|
||||
self.widget.attrs['slug-source'] = f"{prefix}-{slug_source}"
|
||||
|
||||
return super().get_bound_field(form, field_name)
|
||||
|
||||
|
||||
class ColorField(forms.CharField):
|
||||
"""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user