Merge branch 'netbox-community:main' into feat-19492

This commit is contained in:
Omripresent 2025-06-04 17:20:38 -04:00 committed by GitHub
commit b0f61c6288
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 773 additions and 407 deletions

View File

@ -52,7 +52,7 @@ Although it is not recommended, the default validation rules can be disabled by
Default: `False`
If True, cross-origin resource sharing (CORS) requests will be accepted from all origins. If False, a whitelist will be used (see below).
If `True`, cross-origin resource sharing (CORS) requests will be accepted from all origins. If False, a whitelist will be used (see below).
---
@ -84,7 +84,7 @@ The name of the cookie to use for the cross-site request forgery (CSRF) authenti
Default: `False`
If true, the cookie employed for cross-site request forgery (CSRF) protection will be marked as secure, meaning that it can only be sent across an HTTPS connection.
If `True`, the cookie employed for cross-site request forgery (CSRF) protection will be marked as secure, meaning that it can only be sent across an HTTPS connection.
---
@ -164,7 +164,7 @@ EXEMPT_VIEW_PERMISSIONS = ['*']
Default: `False`
If true, the lifetime of a user's authentication session will be automatically reset upon each valid request. For example, if [`LOGIN_TIMEOUT`](#login_timeout) is configured to 14 days (the default), and a user whose session is due to expire in five days makes a NetBox request (with a valid session cookie), the session's lifetime will be reset to 14 days.
If `True`, the lifetime of a user's authentication session will be automatically reset upon each valid request. For example, if [`LOGIN_TIMEOUT`](#login_timeout) is configured to 14 days (the default), and a user whose session is due to expire in five days makes a NetBox request (with a valid session cookie), the session's lifetime will be reset to 14 days.
Note that enabling this setting causes NetBox to update a user's session in the database (or file, as configured per [`SESSION_FILE_PATH`](#session_file_path)) with each request, which may introduce significant overhead in very active environments. It also permits an active user to remain authenticated to NetBox indefinitely.
@ -212,7 +212,7 @@ The view name or URL to which a user is redirected after logging out.
Default: `False`
If true, the `includeSubDomains` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to apply the HSTS policy to all subdomains of the current domain.
If `True`, the `includeSubDomains` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to apply the HSTS policy to all subdomains of the current domain.
---
@ -220,7 +220,7 @@ If true, the `includeSubDomains` directive will be included in the HTTP Strict T
Default: `False`
If true, the `preload` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to preload the site in HTTPS. Browsers that use the HSTS preload list will force the site to be accessed via HTTPS even if the user types HTTP in the address bar.
If `True`, the `preload` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to preload the site in HTTPS. Browsers that use the HSTS preload list will force the site to be accessed via HTTPS even if the user types HTTP in the address bar.
---
@ -236,7 +236,7 @@ If set to a non-zero integer value, the SecurityMiddleware sets the HTTP Strict
Default: `False`
If true, all non-HTTPS requests will be automatically redirected to use HTTPS.
If `True`, all non-HTTPS requests will be automatically redirected to use HTTPS.
!!! warning
Ensure that your frontend HTTP daemon has been configured to forward the HTTP scheme correctly before enabling this option. An incorrectly configured frontend may result in a looping redirect.
@ -255,7 +255,7 @@ The name used for the session cookie. See the [Django documentation](https://doc
Default: `False`
If true, the cookie employed for session authentication will be marked as secure, meaning that it can only be sent across an HTTPS connection.
If `True`, the cookie employed for session authentication will be marked as secure, meaning that it can only be sent across an HTTPS connection.
---

View File

@ -95,7 +95,7 @@ Default: `('127.0.0.1', '::1')`
A list of IP addresses recognized as internal to the system, used to control the display of debugging output. For
example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP
addresses (and [`DEBUG`](./development.md#debug) is true).
addresses (and [`DEBUG`](./development.md#debug) is `True`).
---
@ -103,7 +103,7 @@ addresses (and [`DEBUG`](./development.md#debug) is true).
Default: `False`
Set this configuration parameter to True for NetBox deployments which do not have Internet access. This will disable miscellaneous functionality which depends on access to the Internet.
Set this configuration parameter to `True` for NetBox deployments which do not have Internet access. This will disable miscellaneous functionality which depends on access to the Internet.
!!! note
If Internet access is available via a proxy, set [`HTTP_PROXIES`](#http_proxies) instead.
@ -114,7 +114,7 @@ Set this configuration parameter to True for NetBox deployments which do not hav
Default: `{}`
A dictionary of custom jinja2 filters with the key being the filter name and the value being a callable. For more information see the [Jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example:
A dictionary of custom Jinja2 filters with the key being the filter name and the value being a callable. For more information see the [Jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example:
```python
def uppercase(x):

View File

@ -54,6 +54,7 @@ If a new Django release is adopted or other major dependencies (Python, PostgreS
* Update the installation guide (`docs/installation/index.md`) with the new minimum versions.
* Update the upgrade guide (`docs/installation/upgrading.md`) for the current version accordingly.
* Update the minimum PostgreSQL version in the programming error template (`netbox/templates/exceptions/programming_error.html`).
* Update the minimum and supported Python versions in the project metadata file (`pyproject.toml`)
### Manually Perform a New Install
@ -165,7 +166,7 @@ Then, compile these portable (`.po`) files for use in the application:
### Update Version and Changelog
* Update the version number and date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable.
* Update the version number and date in `netbox/release.yaml` and `pyproject.toml`. Add or remove the designation (e.g. `beta1`) if applicable.
* Update the example version numbers in the feature request and bug report templates under `.github/ISSUE_TEMPLATES/`.
* Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release.

View File

@ -9,7 +9,7 @@ NetBox is the leading solution for modeling and documenting modern networks. By
## :material-server-network: Built for Networks
Unlike general-purpose CMDBs, NetBox has curated a data model which caters specifically to the needs of network engineers and operators. It delivers a wide assortment of object types carefully crafted to best serve the needs of infrastructure design and documentation. These cover all facets of network technology, from IP address managements to cabling to overlays and more:
Unlike general-purpose configuration management databases (CMDBs), NetBox has curated a data model which caters specifically to the needs of network engineers and operators. It delivers a wide assortment of object types carefully crafted to best serve the needs of infrastructure design and documentation. These cover all facets of network technology, from IP address managements to cabling to overlays and more:
* Hierarchical regions, sites, and locations
* Racks, devices, and device components

View File

@ -122,7 +122,7 @@ sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/
### Option B: Check Out a Git Release
This guide assumes that NetBox is installed at `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following command:
This guide assumes that NetBox is installed in `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following command:
```
git ls-remote --tags https://github.com/netbox-community/netbox.git \
@ -134,6 +134,8 @@ git ls-remote --tags https://github.com/netbox-community/netbox.git \
Check out the desired release by specifying its tag. For example:
```
cd /opt/netbox && \
sudo git fetch && \
sudo git checkout v4.2.7
```

View File

@ -1,6 +1,6 @@
# Views
## Writing Views
## Writing Basic Views
If your plugin will provide its own page or pages within the NetBox web UI, you'll need to define views. A view is a piece of business logic which performs an action and/or renders a page when a request is made to a particular URL. HTML content is rendered using a [template](./templates.md). Views are typically defined in `views.py`, and URL patterns in `urls.py`.
@ -47,9 +47,13 @@ A URL pattern has three components:
This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it.
## NetBox Model Views
NetBox provides several generic view classes and additional helper functions, to simplify the implementation of plugin logic. These are recommended to be used whenever possible to keep the maintenance overhead of plugins low.
### View Classes
NetBox provides several generic view classes (documented below) to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use.
Generic view classes (documented below) facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use.
| View Class | Description |
|----------------------|--------------------------------------------------------|
@ -65,18 +69,51 @@ NetBox provides several generic view classes (documented below) to facilitate co
!!! warning
Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins.
#### Example Usage
### URL registration
The NetBox URL registration process has two parts:
1. View classes can be decorated with `@register_model_view()`. This registers a new URL for the model.
2. All of a model's URLs can be included in `urls.py` using the `get_model_urls()` function. This call is usually required twice: once to import general views for the model and again to import model detail views tied to the object's primary key.
::: utilities.views.register_model_view
!!! note "Changed in NetBox v4.2"
In NetBox v4.2, the `register_model_view()` function was extended to support the registration of list views by passing `detail=False`.
::: utilities.urls.get_model_urls
!!! note "Changed in NetBox v4.2"
In NetBox v4.2, the `get_model_urls()` function was extended to support retrieving registered general model views (e.g. for listing objects) by passing `detail=False`.
### Example Usage
```python
# views.py
from netbox.views.generic import ObjectEditView
from utilities.views import register_model_view
from .models import Thing
@register_model_view(Thing, name='add', detail=False)
@register_model_view(Thing, name='edit')
class ThingEditView(ObjectEditView):
queryset = Thing.objects.all()
template_name = 'myplugin/thing.html'
...
```
```python
# urls.py
from django.urls import include, path
from utilities.urls import get_model_urls
urlpatterns = [
path('thing/', include(get_model_urls('myplugin', 'thing', detail=False))),
path('thing/<int:pk>/', include(get_model_urls('myplugin', 'thing'))),
...
]
```
## Object Views
Below are the class definitions for NetBox's object views. These views handle CRUD actions for individual objects. The view, add/edit, and delete views each inherit from `BaseObjectView`, which is not intended to be used directly.
@ -143,6 +180,9 @@ Below are the class definitions for NetBox's multi-object views. These views han
These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path.
!!! note
These feature views are automatically registered for all models that implement the respective feature. There is usually no need to override them. However, if that's the case, the URL must be registered manually in `urls.py` instead of using the `register_model_view()` function or decorator.
::: netbox.views.generic.ObjectChangeLogView
options:
members:
@ -157,7 +197,7 @@ These views are provided to enable or enhance certain NetBox model features, suc
### Additional Tabs
Plugins can "attach" a custom view to a core NetBox model by registering it with `register_model_view()`. To include a tab for this view within the NetBox UI, declare a TabView instance named `tab`, and add it to the template context dict:
Plugins can "attach" a custom view to a NetBox model by registering it with `register_model_view()`. To include a tab for this view within the NetBox UI, declare a TabView instance named `tab`, and add it to the template context dict:
```python
from dcim.models import Site
@ -185,11 +225,6 @@ class MyView(generic.ObjectView):
)
```
!!! note "Changed in NetBox v4.2"
The `register_model_view()` function was extended in NetBox v4.2 to support registration of list views by passing `detail=False`.
::: utilities.views.register_model_view
::: utilities.views.ViewTab
### Extra Template Content

View File

@ -191,12 +191,9 @@ class ProfileView(LoginRequiredMixin, View):
def get(self, request):
# Compile changelog table
changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
user=request.user
).prefetch_related(
'changed_object_type'
)[:20]
changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(user=request.user)[:20]
changelog_table = ObjectChangeTable(changelog)
changelog_table.orderable = False
changelog_table.configure(request)
return render(request, self.template_name, {

View File

@ -16,6 +16,7 @@ from utilities.forms import get_field_value
from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField,
)
from utilities.forms.mixins import DistanceValidationMixin
from utilities.forms.rendering import FieldSet, InlineFields
from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
from utilities.templatetags.builtins.filters import bettertitle
@ -105,7 +106,7 @@ class CircuitTypeForm(NetBoxModelForm):
]
class CircuitForm(TenancyForm, NetBoxModelForm):
class CircuitForm(DistanceValidationMixin, TenancyForm, NetBoxModelForm):
provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(),

View File

@ -41,7 +41,7 @@ __all__ = (
)
@strawberry_django.filter(models.CircuitTermination, lookups=True)
@strawberry_django.filter_type(models.CircuitTermination, lookups=True)
class CircuitTerminationFilter(
BaseObjectTypeFilterMixin,
CustomFieldsFilterMixin,
@ -87,7 +87,7 @@ class CircuitTerminationFilter(
)
@strawberry_django.filter(models.Circuit, lookups=True)
@strawberry_django.filter_type(models.Circuit, lookups=True)
class CircuitFilter(
ContactFilterMixin,
ImageAttachmentFilterMixin,
@ -121,17 +121,17 @@ class CircuitFilter(
)
@strawberry_django.filter(models.CircuitType, lookups=True)
@strawberry_django.filter_type(models.CircuitType, lookups=True)
class CircuitTypeFilter(BaseCircuitTypeFilterMixin):
pass
@strawberry_django.filter(models.CircuitGroup, lookups=True)
@strawberry_django.filter_type(models.CircuitGroup, lookups=True)
class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
pass
@strawberry_django.filter(models.CircuitGroupAssignment, lookups=True)
@strawberry_django.filter_type(models.CircuitGroupAssignment, lookups=True)
class CircuitGroupAssignmentFilter(
BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin
):
@ -148,7 +148,7 @@ class CircuitGroupAssignmentFilter(
)
@strawberry_django.filter(models.Provider, lookups=True)
@strawberry_django.filter_type(models.Provider, lookups=True)
class ProviderFilter(ContactFilterMixin, PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
@ -158,7 +158,7 @@ class ProviderFilter(ContactFilterMixin, PrimaryModelFilterMixin):
)
@strawberry_django.filter(models.ProviderAccount, lookups=True)
@strawberry_django.filter_type(models.ProviderAccount, lookups=True)
class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilterMixin):
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -168,7 +168,7 @@ class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ProviderNetwork, lookups=True)
@strawberry_django.filter_type(models.ProviderNetwork, lookups=True)
class ProviderNetworkFilter(PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@ -178,12 +178,12 @@ class ProviderNetworkFilter(PrimaryModelFilterMixin):
service_id: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.VirtualCircuitType, lookups=True)
@strawberry_django.filter_type(models.VirtualCircuitType, lookups=True)
class VirtualCircuitTypeFilter(BaseCircuitTypeFilterMixin):
pass
@strawberry_django.filter(models.VirtualCircuit, lookups=True)
@strawberry_django.filter_type(models.VirtualCircuit, lookups=True)
class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
cid: FilterLookup[str] | None = strawberry_django.filter_field()
provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@ -206,7 +206,7 @@ class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
)
@strawberry_django.filter(models.VirtualCircuitTermination, lookups=True)
@strawberry_django.filter_type(models.VirtualCircuitTermination, lookups=True)
class VirtualCircuitTerminationFilter(
BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin
):

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0051_virtualcircuit_group_assignment'),
]
operations = [
migrations.AlterField(
model_name='circuit',
name='_abs_distance',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=13, null=True),
),
]

View File

@ -120,7 +120,8 @@ class CircuitTerminationTable(NetBoxTable):
)
termination = tables.Column(
verbose_name=_('Termination Point'),
linkify=True
linkify=True,
orderable=False,
)
# Termination types
@ -132,7 +133,7 @@ class CircuitTerminationTable(NetBoxTable):
site_group = tables.Column(
verbose_name=_('Site Group'),
linkify=True,
accessor='_sitegroup'
accessor='_site_group'
)
region = tables.Column(
verbose_name=_('Region'),

View File

@ -54,9 +54,8 @@ class VirtualCircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
linkify=True,
verbose_name=_('Account')
)
type = tables.Column(
type = columns.ColoredLabelColumn(
verbose_name=_('Type'),
linkify=True
)
status = columns.ChoiceFieldColumn()
termination_count = columns.LinkedCountColumn(

View File

@ -0,0 +1,23 @@
from django.test import RequestFactory, tag, TestCase
from circuits.models import CircuitTermination
from circuits.tables import CircuitTerminationTable
@tag('regression')
class CircuitTerminationTableTest(TestCase):
def test_every_orderable_field_does_not_throw_exception(self):
terminations = CircuitTermination.objects.all()
disallowed = {'actions', }
orderable_columns = [
column.name for column in CircuitTerminationTable(terminations).columns
if column.orderable and column.name not in disallowed
]
fake_request = RequestFactory().get("/")
for col in orderable_columns:
for dir in ('-', ''):
table = CircuitTerminationTable(terminations)
table.order_by = f'{dir}{col}'
table.as_html(fake_request)

View File

@ -23,7 +23,7 @@ __all__ = (
)
@strawberry_django.filter(models.DataFile, lookups=True)
@strawberry_django.filter_type(models.DataFile, lookups=True)
class DataFileFilter(BaseFilterMixin):
id: ID | None = strawberry_django.filter_field()
created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
@ -39,7 +39,7 @@ class DataFileFilter(BaseFilterMixin):
hash: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.DataSource, lookups=True)
@strawberry_django.filter_type(models.DataSource, lookups=True)
class DataSourceFilter(PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
type: FilterLookup[str] | None = strawberry_django.filter_field()
@ -56,7 +56,7 @@ class DataSourceFilter(PrimaryModelFilterMixin):
)
@strawberry_django.filter(models.ObjectChange, lookups=True)
@strawberry_django.filter_type(models.ObjectChange, lookups=True)
class ObjectChangeFilter(BaseFilterMixin):
id: ID | None = strawberry_django.filter_field()
time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
@ -82,7 +82,7 @@ class ObjectChangeFilter(BaseFilterMixin):
)
@strawberry_django.filter(DjangoContentType, lookups=True)
@strawberry_django.filter_type(DjangoContentType, lookups=True)
class ContentTypeFilter(BaseFilterMixin):
id: ID | None = strawberry_django.filter_field()
app_label: FilterLookup[str] | None = strawberry_django.filter_field()

View File

@ -9,6 +9,7 @@ from rq.registry import FailedJobRegistry, StartedJobRegistry
from users.models import Token, User
from utilities.testing import APITestCase, APIViewTestCases, TestCase
from utilities.testing.utils import disable_logging
from ..models import *
@ -189,7 +190,8 @@ class BackgroundTaskTestCase(TestCase):
# Enqueue & run a job that will fail
job = queue.enqueue(self.dummy_job_failing)
worker = get_worker('default')
worker.work(burst=True)
with disable_logging():
worker.work(burst=True)
self.assertTrue(job.is_failed)
# Re-enqueue the failed job and check that its status has been reset
@ -231,7 +233,8 @@ class BackgroundTaskTestCase(TestCase):
self.assertEqual(job.get_status(), JobStatus.STARTED)
response = self.client.post(reverse('core-api:rqtask-stop', args=[job.id]), **self.header)
self.assertEqual(response.status_code, 200)
worker.monitor_work_horse(job, queue) # Sets the job as Failed and removes from Started
with disable_logging():
worker.monitor_work_horse(job, queue) # Sets the job as Failed and removes from Started
started_job_registry = StartedJobRegistry(queue.name, connection=queue.connection)
self.assertEqual(len(started_job_registry), 0)

View File

@ -14,7 +14,7 @@ from core.choices import ObjectChangeActionChoices
from core.models import *
from dcim.models import Site
from users.models import User
from utilities.testing import TestCase, ViewTestCases, create_tags
from utilities.testing import TestCase, ViewTestCases, create_tags, disable_logging
class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@ -271,7 +271,8 @@ class BackgroundTaskTestCase(TestCase):
# Enqueue & run a job that will fail
job = queue.enqueue(self.dummy_job_failing)
worker = get_worker('default')
worker.work(burst=True)
with disable_logging():
worker.work(burst=True)
self.assertTrue(job.is_failed)
# Re-enqueue the failed job and check that its status has been reset
@ -317,7 +318,8 @@ class BackgroundTaskTestCase(TestCase):
self.assertEqual(len(started_job_registry), 1)
response = self.client.get(reverse('core:background_task_stop', args=[job.id]))
self.assertEqual(response.status_code, 302)
worker.monitor_work_horse(job, queue) # Sets the job as Failed and removes from Started
with disable_logging():
worker.monitor_work_horse(job, queue) # Sets the job as Failed and removes from Started
self.assertEqual(len(started_job_registry), 0)
canceled_job_registry = FailedJobRegistry(queue.name, connection=queue.connection)

View File

@ -2012,6 +2012,21 @@ class InterfaceFilterSet(
'wireless': queryset.filter(type__in=WIRELESS_IFACE_TYPES),
}.get(value, queryset.none())
# Override the method on CabledObjectFilterSet to also check for wireless links
def filter_occupied(self, queryset, name, value):
if value:
return queryset.filter(
Q(cable__isnull=False) |
Q(wireless_link__isnull=False) |
Q(mark_connected=True)
)
else:
return queryset.filter(
cable__isnull=True,
wireless_link__isnull=True,
mark_connected=False
)
class FrontPortFilterSet(
ModularDeviceComponentFilterSet,

View File

@ -90,7 +90,7 @@ __all__ = (
)
@strawberry_django.filter(models.Cable, lookups=True)
@strawberry_django.filter_type(models.Cable, lookups=True)
class CableFilter(PrimaryModelFilterMixin, TenancyFilterMixin):
type: Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
status: Annotated['LinkStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
@ -107,7 +107,7 @@ class CableFilter(PrimaryModelFilterMixin, TenancyFilterMixin):
)
@strawberry_django.filter(models.CableTermination, lookups=True)
@strawberry_django.filter_type(models.CableTermination, lookups=True)
class CableTerminationFilter(ChangeLogFilterMixin):
cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
cable_id: ID | None = strawberry_django.filter_field()
@ -120,7 +120,7 @@ class CableTerminationFilter(ChangeLogFilterMixin):
termination_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ConsolePort, lookups=True)
@strawberry_django.filter_type(models.ConsolePort, lookups=True)
class ConsolePortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field()
@ -130,14 +130,14 @@ class ConsolePortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilte
)
@strawberry_django.filter(models.ConsolePortTemplate, lookups=True)
@strawberry_django.filter_type(models.ConsolePortTemplate, lookups=True)
class ConsolePortTemplateFilter(ModularComponentTemplateFilterMixin):
type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter(models.ConsoleServerPort, lookups=True)
@strawberry_django.filter_type(models.ConsoleServerPort, lookups=True)
class ConsoleServerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field()
@ -147,14 +147,14 @@ class ConsoleServerPortFilter(ModularComponentModelFilterMixin, CabledObjectMode
)
@strawberry_django.filter(models.ConsoleServerPortTemplate, lookups=True)
@strawberry_django.filter_type(models.ConsoleServerPortTemplate, lookups=True)
class ConsoleServerPortTemplateFilter(ModularComponentTemplateFilterMixin):
type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter(models.Device, lookups=True)
@strawberry_django.filter_type(models.Device, lookups=True)
class DeviceFilter(
ContactFilterMixin,
TenancyFilterMixin,
@ -271,7 +271,7 @@ class DeviceFilter(
inventory_item_count: FilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.DeviceBay, lookups=True)
@strawberry_django.filter_type(models.DeviceBay, lookups=True)
class DeviceBayFilter(ComponentModelFilterMixin):
installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -279,12 +279,12 @@ class DeviceBayFilter(ComponentModelFilterMixin):
installed_device_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter(models.DeviceBayTemplate, lookups=True)
@strawberry_django.filter_type(models.DeviceBayTemplate, lookups=True)
class DeviceBayTemplateFilter(ComponentTemplateFilterMixin):
pass
@strawberry_django.filter(models.InventoryItemTemplate, lookups=True)
@strawberry_django.filter_type(models.InventoryItemTemplate, lookups=True)
class InventoryItemTemplateFilter(ComponentTemplateFilterMixin):
parent: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -304,13 +304,13 @@ class InventoryItemTemplateFilter(ComponentTemplateFilterMixin):
part_id: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.DeviceRole, lookups=True)
@strawberry_django.filter_type(models.DeviceRole, lookups=True)
class DeviceRoleFilter(OrganizationalModelFilterMixin, RenderConfigFilterMixin):
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
vm_role: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.DeviceType, lookups=True)
@strawberry_django.filter_type(models.DeviceType, lookups=True)
class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin):
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -382,7 +382,7 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
inventory_item_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.FrontPort, lookups=True)
@strawberry_django.filter_type(models.FrontPort, lookups=True)
class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
@ -395,7 +395,7 @@ class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterM
)
@strawberry_django.filter(models.FrontPortTemplate, lookups=True)
@strawberry_django.filter_type(models.FrontPortTemplate, lookups=True)
class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin):
type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
@ -408,7 +408,7 @@ class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin):
)
@strawberry_django.filter(models.MACAddress, lookups=True)
@strawberry_django.filter_type(models.MACAddress, lookups=True)
class MACAddressFilter(PrimaryModelFilterMixin):
mac_address: FilterLookup[str] | None = strawberry_django.filter_field()
assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
@ -417,7 +417,7 @@ class MACAddressFilter(PrimaryModelFilterMixin):
assigned_object_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Interface, lookups=True)
@strawberry_django.filter_type(models.Interface, lookups=True)
class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin, CabledObjectModelFilterMixin):
vcdcs: Annotated['VirtualDeviceContextFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -486,7 +486,7 @@ class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin
)
@strawberry_django.filter(models.InterfaceTemplate, lookups=True)
@strawberry_django.filter_type(models.InterfaceTemplate, lookups=True)
class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin):
type: Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field()
@ -508,7 +508,7 @@ class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin):
)
@strawberry_django.filter(models.InventoryItem, lookups=True)
@strawberry_django.filter_type(models.InventoryItem, lookups=True)
class InventoryItemFilter(ComponentModelFilterMixin):
parent: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -535,12 +535,12 @@ class InventoryItemFilter(ComponentModelFilterMixin):
discovered: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.InventoryItemRole, lookups=True)
@strawberry_django.filter_type(models.InventoryItemRole, lookups=True)
class InventoryItemRoleFilter(OrganizationalModelFilterMixin):
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Location, lookups=True)
@strawberry_django.filter_type(models.Location, lookups=True)
class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, NestedGroupModelFilterMixin):
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
site_id: ID | None = strawberry_django.filter_field()
@ -556,12 +556,12 @@ class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilt
)
@strawberry_django.filter(models.Manufacturer, lookups=True)
@strawberry_django.filter_type(models.Manufacturer, lookups=True)
class ManufacturerFilter(ContactFilterMixin, OrganizationalModelFilterMixin):
pass
@strawberry_django.filter(models.Module, lookups=True)
@strawberry_django.filter_type(models.Module, lookups=True)
class ModuleFilter(PrimaryModelFilterMixin, ConfigContextFilterMixin):
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
device_id: ID | None = strawberry_django.filter_field()
@ -610,7 +610,7 @@ class ModuleFilter(PrimaryModelFilterMixin, ConfigContextFilterMixin):
)
@strawberry_django.filter(models.ModuleBay, lookups=True)
@strawberry_django.filter_type(models.ModuleBay, lookups=True)
class ModuleBayFilter(ModularComponentModelFilterMixin):
parent: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -619,17 +619,17 @@ class ModuleBayFilter(ModularComponentModelFilterMixin):
position: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ModuleBayTemplate, lookups=True)
@strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True)
class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin):
position: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ModuleTypeProfile, lookups=True)
@strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True)
class ModuleTypeProfileFilter(PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ModuleType, lookups=True)
@strawberry_django.filter_type(models.ModuleType, lookups=True)
class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin):
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -676,7 +676,7 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
) = strawberry_django.filter_field()
@strawberry_django.filter(models.Platform, lookups=True)
@strawberry_django.filter_type(models.Platform, lookups=True)
class PlatformFilter(OrganizationalModelFilterMixin):
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -688,7 +688,7 @@ class PlatformFilter(OrganizationalModelFilterMixin):
config_template_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter(models.PowerFeed, lookups=True)
@strawberry_django.filter_type(models.PowerFeed, lookups=True)
class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
power_panel: Annotated['PowerPanelFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -723,7 +723,7 @@ class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryM
)
@strawberry_django.filter(models.PowerOutlet, lookups=True)
@strawberry_django.filter_type(models.PowerOutlet, lookups=True)
class PowerOutletFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field()
@ -738,7 +738,7 @@ class PowerOutletFilter(ModularComponentModelFilterMixin, CabledObjectModelFilte
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.PowerOutletTemplate, lookups=True)
@strawberry_django.filter_type(models.PowerOutletTemplate, lookups=True)
class PowerOutletTemplateFilter(ModularComponentModelFilterMixin):
type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field()
@ -752,7 +752,7 @@ class PowerOutletTemplateFilter(ModularComponentModelFilterMixin):
)
@strawberry_django.filter(models.PowerPanel, lookups=True)
@strawberry_django.filter_type(models.PowerPanel, lookups=True)
class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryModelFilterMixin):
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
site_id: ID | None = strawberry_django.filter_field()
@ -765,7 +765,7 @@ class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryMo
name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.PowerPort, lookups=True)
@strawberry_django.filter_type(models.PowerPort, lookups=True)
class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field()
@ -778,7 +778,7 @@ class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterM
)
@strawberry_django.filter(models.PowerPortTemplate, lookups=True)
@strawberry_django.filter_type(models.PowerPortTemplate, lookups=True)
class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin):
type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field()
@ -791,7 +791,7 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin):
)
@strawberry_django.filter(models.RackType, lookups=True)
@strawberry_django.filter_type(models.RackType, lookups=True)
class RackTypeFilter(RackBaseFilterMixin):
form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field()
@ -804,7 +804,7 @@ class RackTypeFilter(RackBaseFilterMixin):
slug: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Rack, lookups=True)
@strawberry_django.filter_type(models.Rack, lookups=True)
class RackFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, RackBaseFilterMixin):
form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
strawberry_django.filter_field()
@ -836,7 +836,7 @@ class RackFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMi
)
@strawberry_django.filter(models.RackReservation, lookups=True)
@strawberry_django.filter_type(models.RackReservation, lookups=True)
class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
rack_id: ID | None = strawberry_django.filter_field()
@ -848,12 +848,12 @@ class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
description: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.RackRole, lookups=True)
@strawberry_django.filter_type(models.RackRole, lookups=True)
class RackRoleFilter(OrganizationalModelFilterMixin):
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.RearPort, lookups=True)
@strawberry_django.filter_type(models.RearPort, lookups=True)
class RearPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
@ -862,7 +862,7 @@ class RearPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMi
)
@strawberry_django.filter(models.RearPortTemplate, lookups=True)
@strawberry_django.filter_type(models.RearPortTemplate, lookups=True)
class RearPortTemplateFilter(ModularComponentTemplateFilterMixin):
type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
@ -871,7 +871,7 @@ class RearPortTemplateFilter(ModularComponentTemplateFilterMixin):
)
@strawberry_django.filter(models.Region, lookups=True)
@strawberry_django.filter_type(models.Region, lookups=True)
class RegionFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -881,7 +881,7 @@ class RegionFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
)
@strawberry_django.filter(models.Site, lookups=True)
@strawberry_django.filter_type(models.Site, lookups=True)
class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
@ -915,7 +915,7 @@ class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMi
)
@strawberry_django.filter(models.SiteGroup, lookups=True)
@strawberry_django.filter_type(models.SiteGroup, lookups=True)
class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -925,7 +925,7 @@ class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
)
@strawberry_django.filter(models.VirtualChassis, lookups=True)
@strawberry_django.filter_type(models.VirtualChassis, lookups=True)
class VirtualChassisFilter(PrimaryModelFilterMixin):
master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
master_id: ID | None = strawberry_django.filter_field()
@ -937,7 +937,7 @@ class VirtualChassisFilter(PrimaryModelFilterMixin):
member_count: FilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.VirtualDeviceContext, lookups=True)
@strawberry_django.filter_type(models.VirtualDeviceContext, lookups=True)
class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
device_id: ID | None = strawberry_django.filter_field()

View File

@ -85,7 +85,7 @@ class CachedScopeMixin(models.Model):
abstract = True
def clean(self):
if self.scope_type and not self.scope:
if self.scope_type and not (self.scope or self.scope_id):
scope_type = self.scope_type.model_class()
raise ValidationError({
'scope': _(

View File

@ -14,7 +14,7 @@ from ipam.models import ASN, RIR, VLAN, VRF
from netbox.api.serializers import GenericObjectSerializer
from tenancy.models import Tenant
from users.models import User
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_logging
from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices
from wireless.models import WirelessLAN
@ -1858,7 +1858,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
# Attempt to delete only the parent interface
url = self._get_detail_url(interface1)
self.client.delete(url, **self.header)
with disable_logging():
self.client.delete(url, **self.header)
self.assertEqual(device.interfaces.count(), 4) # Parent was not deleted
# Attempt to bulk delete parent & child together

View File

@ -12,6 +12,7 @@ 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 wireless.choices import WirelessChannelChoices, WirelessRoleChoices
from wireless.models import WirelessLink
class DeviceComponentFilterSetTests:
@ -4496,7 +4497,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
# Cables
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[5]]).save()
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[6]]).save()
# Third pair is not connected
# Wireless links
WirelessLink(interface_a=interfaces[7], interface_b=interfaces[8]).save()
def test_name(self):
params = {'name': ['Interface 1', 'Interface 2']}
@ -4684,15 +4687,15 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
def test_occupied(self):
params = {'occupied': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'occupied': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_connected(self):
params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_kind(self):
params = {'kind': 'physical'}

View File

@ -40,7 +40,7 @@ __all__ = (
)
@strawberry_django.filter(models.ConfigContext, lookups=True)
@strawberry_django.filter_type(models.ConfigContext, lookups=True)
class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] = strawberry_django.filter_field()
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@ -97,7 +97,7 @@ class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Chan
)
@strawberry_django.filter(models.ConfigTemplate, lookups=True)
@strawberry_django.filter_type(models.ConfigTemplate, lookups=True)
class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
@ -111,7 +111,7 @@ class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Cha
as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.CustomField, lookups=True)
@strawberry_django.filter_type(models.CustomField, lookups=True)
class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
type: Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')] | None = (
strawberry_django.filter_field()
@ -164,7 +164,7 @@ class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
comments: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.CustomFieldChoiceSet, lookups=True)
@strawberry_django.filter_type(models.CustomFieldChoiceSet, lookups=True)
class CustomFieldChoiceSetFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
@ -177,7 +177,7 @@ class CustomFieldChoiceSetFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin
order_alphabetically: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.CustomLink, lookups=True)
@strawberry_django.filter_type(models.CustomLink, lookups=True)
class CustomLinkFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
@ -193,7 +193,7 @@ class CustomLinkFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
new_window: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ExportTemplate, lookups=True)
@strawberry_django.filter_type(models.ExportTemplate, lookups=True)
class ExportTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
@ -207,7 +207,7 @@ class ExportTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Cha
as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ImageAttachment, lookups=True)
@strawberry_django.filter_type(models.ImageAttachment, lookups=True)
class ImageAttachmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -222,7 +222,7 @@ class ImageAttachmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.JournalEntry, lookups=True)
@strawberry_django.filter_type(models.JournalEntry, lookups=True)
class JournalEntryFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -238,7 +238,7 @@ class JournalEntryFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, Tag
comments: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.NotificationGroup, lookups=True)
@strawberry_django.filter_type(models.NotificationGroup, lookups=True)
class NotificationGroupFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
@ -246,7 +246,7 @@ class NotificationGroupFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.SavedFilter, lookups=True)
@strawberry_django.filter_type(models.SavedFilter, lookups=True)
class SavedFilterFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
@ -263,7 +263,7 @@ class SavedFilterFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
)
@strawberry_django.filter(models.TableConfig, lookups=True)
@strawberry_django.filter_type(models.TableConfig, lookups=True)
class TableConfigFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
@ -276,13 +276,13 @@ class TableConfigFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
shared: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Tag, lookups=True)
@strawberry_django.filter_type(models.Tag, lookups=True)
class TagFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin, TagBaseFilterMixin):
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Webhook, lookups=True)
@strawberry_django.filter_type(models.Webhook, lookups=True)
class WebhookFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
@ -301,7 +301,7 @@ class WebhookFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilt
)
@strawberry_django.filter(models.EventRule, lookups=True)
@strawberry_django.filter_type(models.EventRule, lookups=True)
class EventRuleFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()

View File

@ -131,7 +131,7 @@ class RenderTemplateMixin(models.Model):
"""
context = self.get_context(context=context, queryset=queryset)
env_params = self.environment_params or {}
output = render_jinja2(self.template_code, context, env_params)
output = render_jinja2(self.template_code, context, env_params, getattr(self, 'data_file', None))
# Replace CRLF-style line terminators
output = output.replace('\r\n', '\n')

View File

@ -2,7 +2,7 @@ import datetime
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils.timezone import make_aware
from django.utils.timezone import make_aware, now
from rest_framework import status
from core.choices import ManagedFileRootPathChoices
@ -991,6 +991,10 @@ class SubscriptionTest(APIViewTestCases.APIViewTestCase):
},
]
cls.bulk_update_data = {
'user': users[3].pk,
}
class NotificationGroupTest(APIViewTestCases.APIViewTestCase):
model = NotificationGroup
@ -1072,6 +1076,9 @@ class NotificationGroupTest(APIViewTestCases.APIViewTestCase):
class NotificationTest(APIViewTestCases.APIViewTestCase):
model = Notification
brief_fields = ['display', 'event_type', 'id', 'object_id', 'object_type', 'read', 'url', 'user']
bulk_update_data = {
'read': now(),
}
@classmethod
def setUpTestData(cls):

View File

@ -1,9 +1,12 @@
from django.forms import ValidationError
from django.test import TestCase
import tempfile
from pathlib import Path
from core.models import ObjectType
from django.forms import ValidationError
from django.test import tag, TestCase
from core.models import DataSource, ObjectType
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
from extras.models import ConfigContext, Tag
from extras.models import ConfigContext, ConfigTemplate, Tag
from tenancy.models import Tenant, TenantGroup
from utilities.exceptions import AbortRequest
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@ -33,8 +36,8 @@ class TagTest(TestCase):
]
site = Site.objects.create(name='Site 1')
for tag in tags:
site.tags.add(tag)
for _tag in tags:
site.tags.add(_tag)
site.save()
site = Site.objects.first()
@ -540,3 +543,66 @@ class ConfigContextTest(TestCase):
device.local_context_data = 'foo'
with self.assertRaises(ValidationError):
device.clean()
class ConfigTemplateTest(TestCase):
"""
TODO: These test cases deal with the weighting, ordering, and deep merge logic of config context data.
"""
MAIN_TEMPLATE = """
{%- include 'base.j2' %}
""".strip()
BASE_TEMPLATE = """
Hi
""".strip()
@classmethod
def _create_template_file(cls, templates_dir, file_name, content):
template_file_name = file_name
if not template_file_name.endswith('j2'):
template_file_name += '.j2'
temp_file_path = templates_dir / template_file_name
with open(temp_file_path, 'w') as f:
f.write(content)
@classmethod
def setUpTestData(cls):
temp_dir = tempfile.TemporaryDirectory()
templates_dir = Path(temp_dir.name) / "templates"
templates_dir.mkdir(parents=True, exist_ok=True)
cls._create_template_file(templates_dir, 'base.j2', cls.BASE_TEMPLATE)
cls._create_template_file(templates_dir, 'main.j2', cls.MAIN_TEMPLATE)
data_source = DataSource(
name="Test DataSource",
type="local",
source_url=str(templates_dir),
)
data_source.save()
data_source.sync()
base_config_template = ConfigTemplate(
name="BaseTemplate",
data_file=data_source.datafiles.filter(path__endswith='base.j2').first()
)
base_config_template.clean()
base_config_template.save()
cls.base_config_template = base_config_template
main_config_template = ConfigTemplate(
name="MainTemplate",
data_file=data_source.datafiles.filter(path__endswith='main.j2').first()
)
main_config_template.clean()
main_config_template.save()
cls.main_config_template = main_config_template
@tag('regression')
def test_config_template_with_data_source(self):
self.assertEqual(self.BASE_TEMPLATE, self.base_config_template.render({}))
@tag('regression')
def test_config_template_with_data_source_nested_templates(self):
self.assertEqual(self.BASE_TEMPLATE, self.main_config_template.render({}))

View File

@ -1,3 +1,4 @@
import logging
import tempfile
from datetime import date, datetime, timezone
@ -7,6 +8,7 @@ from netaddr import IPAddress, IPNetwork
from dcim.models import DeviceRole
from extras.scripts import *
from utilities.testing import disable_logging
CHOICES = (
('ff0000', 'Red'),
@ -39,7 +41,8 @@ class ScriptTest(TestCase):
datafile.write(bytes(YAML_DATA, 'UTF-8'))
datafile.seek(0)
data = Script().load_yaml(datafile.name)
with disable_logging(level=logging.WARNING):
data = Script().load_yaml(datafile.name)
self.assertEqual(data, {
'Foo': 123,
'Bar': 456,
@ -51,7 +54,8 @@ class ScriptTest(TestCase):
datafile.write(bytes(JSON_DATA, 'UTF-8'))
datafile.seek(0)
data = Script().load_json(datafile.name)
with disable_logging(level=logging.WARNING):
data = Script().load_json(datafile.name)
self.assertEqual(data, {
'Foo': 123,
'Bar': 456,

View File

@ -969,7 +969,7 @@ class ObjectRenderConfigView(generic.ObjectView):
# Render the config template
rendered_config = None
error_message = None
error_message = ''
if config_template := instance.get_config_template():
try:
rendered_config = config_template.render(context=context_data)

View File

@ -46,7 +46,7 @@ __all__ = (
)
@strawberry_django.filter(models.ASN, lookups=True)
@strawberry_django.filter_type(models.ASN, lookups=True)
class ASNFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
rir_id: ID | None = strawberry_django.filter_field()
@ -61,7 +61,7 @@ class ASNFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
) = strawberry_django.filter_field()
@strawberry_django.filter(models.ASNRange, lookups=True)
@strawberry_django.filter_type(models.ASNRange, lookups=True)
class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
@ -75,7 +75,7 @@ class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
)
@strawberry_django.filter(models.Aggregate, lookups=True)
@strawberry_django.filter_type(models.Aggregate, lookups=True)
class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
prefix: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
prefix_id: ID | None = strawberry_django.filter_field()
@ -84,7 +84,7 @@ class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
date_added: DateFilterLookup[date] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.FHRPGroup, lookups=True)
@strawberry_django.filter_type(models.FHRPGroup, lookups=True)
class FHRPGroupFilter(PrimaryModelFilterMixin):
group_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
@ -102,7 +102,7 @@ class FHRPGroupFilter(PrimaryModelFilterMixin):
)
@strawberry_django.filter(models.FHRPGroupAssignment, lookups=True)
@strawberry_django.filter_type(models.FHRPGroupAssignment, lookups=True)
class FHRPGroupAssignmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
interface_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -117,7 +117,7 @@ class FHRPGroupAssignmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin)
)
@strawberry_django.filter(models.IPAddress, lookups=True)
@strawberry_django.filter_type(models.IPAddress, lookups=True)
class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
address: FilterLookup[str] | None = strawberry_django.filter_field()
vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
@ -142,6 +142,10 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
nat_outside_id: ID | None = strawberry_django.filter_field()
dns_name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_field()
def assigned(self, value: bool, prefix) -> Q:
return Q(assigned_object_id__isnull=(not value))
@strawberry_django.filter_field()
def parent(self, value: list[str], prefix) -> Q:
if not value:
@ -155,8 +159,16 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
return Q()
return q
@strawberry_django.filter_field()
def family(
self,
value: Annotated['IPAddressFamilyEnum', strawberry.lazy('ipam.graphql.enums')],
prefix,
) -> Q:
return Q(**{f"{prefix}address__family": value.value})
@strawberry_django.filter(models.IPRange, lookups=True)
@strawberry_django.filter_type(models.IPRange, lookups=True)
class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
start_address: FilterLookup[str] | None = strawberry_django.filter_field()
end_address: FilterLookup[str] | None = strawberry_django.filter_field()
@ -185,7 +197,7 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMi
return q
@strawberry_django.filter(models.Prefix, lookups=True)
@strawberry_django.filter_type(models.Prefix, lookups=True)
class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
prefix: FilterLookup[str] | None = strawberry_django.filter_field()
vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
@ -201,19 +213,19 @@ class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, Pr
mark_utilized: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.RIR, lookups=True)
@strawberry_django.filter_type(models.RIR, lookups=True)
class RIRFilter(OrganizationalModelFilterMixin):
is_private: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Role, lookups=True)
@strawberry_django.filter_type(models.Role, lookups=True)
class RoleFilter(OrganizationalModelFilterMixin):
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter(models.RouteTarget, lookups=True)
@strawberry_django.filter_type(models.RouteTarget, lookups=True)
class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
importing_vrfs: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
@ -230,7 +242,7 @@ class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
)
@strawberry_django.filter(models.Service, lookups=True)
@strawberry_django.filter_type(models.Service, lookups=True)
class ServiceFilter(ContactFilterMixin, ServiceBaseFilterMixin, PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
@ -242,12 +254,12 @@ class ServiceFilter(ContactFilterMixin, ServiceBaseFilterMixin, PrimaryModelFilt
parent_object_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ServiceTemplate, lookups=True)
@strawberry_django.filter_type(models.ServiceTemplate, lookups=True)
class ServiceTemplateFilter(ServiceBaseFilterMixin, PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.VLAN, lookups=True)
@strawberry_django.filter_type(models.VLAN, lookups=True)
class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
site_id: ID | None = strawberry_django.filter_field()
@ -277,19 +289,19 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
)
@strawberry_django.filter(models.VLANGroup, lookups=True)
@strawberry_django.filter_type(models.VLANGroup, lookups=True)
class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilterMixin):
vid_ranges: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter(models.VLANTranslationPolicy, lookups=True)
@strawberry_django.filter_type(models.VLANTranslationPolicy, lookups=True)
class VLANTranslationPolicyFilter(PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.VLANTranslationRule, lookups=True)
@strawberry_django.filter_type(models.VLANTranslationRule, lookups=True)
class VLANTranslationRuleFilter(NetBoxModelFilterMixin):
policy: Annotated['VLANTranslationPolicyFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -304,7 +316,7 @@ class VLANTranslationRuleFilter(NetBoxModelFilterMixin):
)
@strawberry_django.filter(models.VRF, lookups=True)
@strawberry_django.filter_type(models.VRF, lookups=True)
class VRFFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
rd: FilterLookup[str] | None = strawberry_django.filter_field()

View File

@ -148,7 +148,7 @@ class VLANQuerySet(RestrictedQuerySet):
# Find all relevant VLANGroups
q = Q()
site = vm.site or vm.cluster._site
site = vm.site
if vm.cluster:
# Add VLANGroups scoped to the assigned cluster (or its group)
q |= Q(
@ -160,6 +160,30 @@ class VLANQuerySet(RestrictedQuerySet):
scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'),
scope_id=vm.cluster.group_id
)
# Looking all possible cluster scopes
if vm.cluster.scope_type == ContentType.objects.get_by_natural_key('dcim', 'location'):
site = site or vm.cluster.scope.site
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('dcim', 'location'),
scope_id__in=vm.cluster.scope.get_ancestors(include_self=True)
)
elif vm.cluster.scope_type == ContentType.objects.get_by_natural_key('dcim', 'site'):
site = site or vm.cluster.scope
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
scope_id=vm.cluster.scope.pk
)
elif vm.cluster.scope_type == ContentType.objects.get_by_natural_key('dcim', 'sitegroup'):
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
scope_id__in=vm.cluster.scope.get_ancestors(include_self=True)
)
elif vm.cluster.scope_type == ContentType.objects.get_by_natural_key('dcim', 'region'):
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
scope_id__in=vm.cluster.scope.get_ancestors(include_self=True)
)
# VM can be assigned to a site without a cluster so checking assigned site independently
if site:
# Add VLANGroups scoped to the assigned site (or its group or region)
q |= Q(

View File

@ -1,5 +1,7 @@
import json
import logging
from django.test import tag
from django.urls import reverse
from netaddr import IPNetwork
from rest_framework import status
@ -9,7 +11,7 @@ from ipam.choices import *
from ipam.models import *
from tenancy.models import Tenant
from utilities.data import string_to_ranges
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_warnings
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_logging
class AppTest(APITestCase):
@ -382,6 +384,18 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
)
Prefix.objects.bulk_create(prefixes)
@tag('regression')
def test_clean_validates_scope(self):
prefix = Prefix.objects.first()
site = Site.objects.create(name='Test Site', slug='test-site')
data = {'scope_type': 'dcim.site', 'scope_id': site.id}
url = reverse('ipam-api:prefix-detail', kwargs={'pk': prefix.pk})
self.add_permissions('ipam.change_prefix')
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
def test_list_available_prefixes(self):
"""
Test retrieval of all available prefixes within a parent prefix.
@ -1026,7 +1040,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
self.add_permissions('ipam.delete_vlan')
url = reverse('ipam-api:vlan-detail', kwargs={'pk': vlan.pk})
with disable_warnings('netbox.api.views.ModelViewSet'):
with disable_logging(level=logging.WARNING):
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)

View File

@ -1849,6 +1849,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
Cluster(name='Cluster 1', type=cluster_type, group=cluster_groups[0], scope=sites[0]),
Cluster(name='Cluster 2', type=cluster_type, group=cluster_groups[1], scope=sites[1]),
Cluster(name='Cluster 3', type=cluster_type, group=cluster_groups[2], scope=sites[2]),
Cluster(name='Cluster 4', type=cluster_type, group=cluster_groups[0], scope=locations[0]),
)
for cluster in clusters:
cluster.save()
@ -1857,6 +1858,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
VirtualMachine(name='Virtual Machine 1', cluster=clusters[0]),
VirtualMachine(name='Virtual Machine 2', cluster=clusters[1]),
VirtualMachine(name='Virtual Machine 3', cluster=clusters[2]),
VirtualMachine(name='Virtual Machine 4', cluster=clusters[3]),
)
VirtualMachine.objects.bulk_create(virtual_machines)
@ -1864,6 +1866,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
VMInterface(virtual_machine=virtual_machines[0], name='VM Interface 1'),
VMInterface(virtual_machine=virtual_machines[1], name='VM Interface 2'),
VMInterface(virtual_machine=virtual_machines[2], name='VM Interface 3'),
VMInterface(virtual_machine=virtual_machines[3], name='VM Interface 4'),
)
VMInterface.objects.bulk_create(vm_interfaces)
@ -1890,6 +1893,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
VLANGroup(name='Cluster 1', slug='cluster-1', scope=clusters[0]),
VLANGroup(name='Cluster 2', slug='cluster-2', scope=clusters[1]),
VLANGroup(name='Cluster 3', slug='cluster-3', scope=clusters[2]),
VLANGroup(name='Cluster 4', slug='cluster-4', scope=clusters[3]),
# General purpose VLAN groups
VLANGroup(name='VLAN Group 1', slug='vlan-group-1'),
@ -1944,11 +1948,12 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
VLAN(vid=19, name='Cluster 1', group=groups[18]),
VLAN(vid=20, name='Cluster 2', group=groups[19]),
VLAN(vid=21, name='Cluster 3', group=groups[20]),
VLAN(vid=22, name='Cluster 4', group=groups[21]),
VLAN(
vid=101,
name='VLAN 101',
site=sites[3],
group=groups[21],
group=groups[22],
role=roles[0],
tenant=tenants[0],
status=VLANStatusChoices.STATUS_ACTIVE,
@ -1957,7 +1962,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
vid=102,
name='VLAN 102',
site=sites[3],
group=groups[21],
group=groups[22],
role=roles[0],
tenant=tenants[0],
status=VLANStatusChoices.STATUS_ACTIVE,
@ -1966,7 +1971,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
vid=201,
name='VLAN 201',
site=sites[4],
group=groups[22],
group=groups[23],
role=roles[1],
tenant=tenants[1],
status=VLANStatusChoices.STATUS_DEPRECATED,
@ -1975,7 +1980,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
vid=202,
name='VLAN 202',
site=sites[4],
group=groups[22],
group=groups[23],
role=roles[1],
tenant=tenants[1],
status=VLANStatusChoices.STATUS_DEPRECATED,
@ -1984,7 +1989,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
vid=301,
name='VLAN 301',
site=sites[5],
group=groups[23],
group=groups[24],
role=roles[2],
tenant=tenants[2],
status=VLANStatusChoices.STATUS_RESERVED,
@ -1993,13 +1998,13 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
vid=302,
name='VLAN 302',
site=sites[5],
group=groups[23],
group=groups[24],
role=roles[2],
tenant=tenants[2],
status=VLANStatusChoices.STATUS_RESERVED,
),
# Create one globally available VLAN on a VLAN group
VLAN(vid=500, name='VLAN Group 1', group=groups[24]),
VLAN(vid=500, name='VLAN Group 1', group=groups[25]),
# Create one globally available VLAN
VLAN(vid=1000, name='Global VLAN'),
# Create some Q-in-Q service VLANs
@ -2130,6 +2135,9 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
vm_id = VirtualMachine.objects.first().pk
params = {'available_on_virtualmachine': vm_id}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) # 5 scoped + 1 global group + 1 global
vm_id = VirtualMachine.objects.get(name='Virtual Machine 4').pk
params = {'available_on_virtualmachine': vm_id}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) # 6 scoped + 1 global group + 1 global
def test_available_at_site(self):
site_id = Site.objects.first().pk

View File

@ -1,6 +1,7 @@
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from netbox.choices import *
from utilities.conversion import to_grams, to_meters
@ -58,7 +59,7 @@ class DistanceMixin(models.Model):
max_digits=8,
decimal_places=2,
blank=True,
null=True
null=True,
)
distance_unit = models.CharField(
verbose_name=_('distance unit'),
@ -69,7 +70,7 @@ class DistanceMixin(models.Model):
)
# Stores the normalized distance (in meters) for database ordering
_abs_distance = models.DecimalField(
max_digits=10,
max_digits=13,
decimal_places=4,
blank=True,
null=True

View File

@ -1,12 +1,10 @@
{% extends 'account/base.html' %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block title %}{% trans "User Profile" %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="row">
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Account Details" %}</h2>
@ -64,12 +62,7 @@
{% if perms.core.view_objectchange %}
<div class="row">
<div class="col-md-12">
<div class="card">
<h2 class="card-header text-center">{% trans "Recent Activity" %}</h2>
<div class="table-responsive">
{% render_table changelog_table 'inc/table.html' %}
</div>
</div>
{% include 'users/inc/user_activity.html' with user=user table=changelog_table %}
</div>
</div>
{% endif %}

View File

@ -28,6 +28,10 @@
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}

View File

@ -53,7 +53,6 @@
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Connection" %}</h2>
<div class="card-body">
{% if object.mark_connected %}
<div class="card-body">
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>

View File

@ -26,6 +26,12 @@
<th scope="row">{% trans "Location" %}</th>
<td>{% nested_tree object.location %}</td>
</tr>
{% if object.virtual_chassis %}
<tr>
<th scope="row">{% trans "Virtual Chassis" %}</th>
<td>{{ object.virtual_chassis|linkify }}</td>
</tr>
{% endif %}
<tr>
<th scope="row">{% trans "Rack" %}</th>
<td class="d-flex justify-content-between align-items-start">

View File

@ -63,11 +63,15 @@
</h2>
<pre class="card-body" id="rendered_config">{{ rendered_config }}</pre>
</div>
{% else %}
{% elif error_message %}
<div class="alert alert-warning">
<h4 class="alert-title mb-1">{% trans "Error rendering template" %}</h4>
{% trans error_message %}
</div>
{% else %}
<div class="alert alert-warning">
<h4 class="alert-title mb-1">{% trans "Template output is empty" %}</h4>
</div>
{% endif %}
{% else %}
<div class="alert alert-info">

View File

@ -0,0 +1,16 @@
{% load i18n %}
{% load render_table from django_tables2 %}
<div class="card">
<h2 class="card-header text-center">
{% trans "Recent Activity" %}
<div class="card-actions">
<a href="{% url 'core:objectchange_list' %}?user_id={{ user.pk }}" class="btn btn-ghost-primary btn-sm">
<i class="mdi mdi-arrow-right-thick" aria-hidden="true"></i> {% trans "View All" %}
</a>
</div>
</h2>
<div class="table-responsive">
{% render_table table 'inc/table.html' %}
</div>
</div>

View File

@ -1,14 +1,12 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block title %}{% trans "User" %} {{ object.username }}{% endblock %}
{% block subtitle %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="row">
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "User" %}</h2>
@ -74,12 +72,7 @@
{% if perms.core.view_objectchange %}
<div class="row">
<div class="col-md-12">
<div class="card">
<h2 class="text-center">{% trans "Recent Activity" %}</h2>
<div class="card-body table-responsive">
{% render_table changelog_table 'inc/table.html' %}
</div>
</div>
{% include 'users/inc/user_activity.html' with user=object table=changelog_table %}
</div>
</div>
{% endif %}

View File

@ -56,7 +56,7 @@ __all__ = (
)
@strawberry_django.filter(models.Tenant, lookups=True)
@strawberry_django.filter_type(models.Tenant, lookups=True)
class TenantFilter(PrimaryModelFilterMixin, ContactFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
@ -135,7 +135,7 @@ class TenantFilter(PrimaryModelFilterMixin, ContactFilterMixin):
)
@strawberry_django.filter(models.TenantGroup, lookups=True)
@strawberry_django.filter_type(models.TenantGroup, lookups=True)
class TenantGroupFilter(OrganizationalModelFilterMixin):
parent: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -149,7 +149,7 @@ class TenantGroupFilter(OrganizationalModelFilterMixin):
)
@strawberry_django.filter(models.Contact, lookups=True)
@strawberry_django.filter_type(models.Contact, lookups=True)
class ContactFilter(PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
title: FilterLookup[str] | None = strawberry_django.filter_field()
@ -165,19 +165,19 @@ class ContactFilter(PrimaryModelFilterMixin):
)
@strawberry_django.filter(models.ContactRole, lookups=True)
@strawberry_django.filter_type(models.ContactRole, lookups=True)
class ContactRoleFilter(OrganizationalModelFilterMixin):
pass
@strawberry_django.filter(models.ContactGroup, lookups=True)
@strawberry_django.filter_type(models.ContactGroup, lookups=True)
class ContactGroupFilter(NestedGroupModelFilterMixin):
parent: Annotated['ContactGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter(models.ContactAssignment, lookups=True)
@strawberry_django.filter_type(models.ContactAssignment, lookups=True)
class ContactAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()

File diff suppressed because it is too large Load Diff

View File

@ -14,13 +14,13 @@ __all__ = (
)
@strawberry_django.filter(models.Group, lookups=True)
@strawberry_django.filter_type(models.Group, lookups=True)
class GroupFilter(BaseObjectTypeFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.User, lookups=True)
@strawberry_django.filter_type(models.User, lookups=True)
class UserFilter(BaseObjectTypeFilterMixin):
username: FilterLookup[str] | None = strawberry_django.filter_field()
first_name: FilterLookup[str] | None = strawberry_django.filter_field()

View File

@ -75,8 +75,9 @@ class UserView(generic.ObjectView):
template_name = 'users/user.html'
def get_extra_context(self, request, instance):
changelog = ObjectChange.objects.restrict(request.user, 'view').filter(user=instance)[:20]
changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(user=instance)[:20]
changelog_table = ObjectChangeTable(changelog)
changelog_table.orderable = False
changelog_table.configure(request)
return {

View File

@ -1,10 +1,13 @@
import time
from decimal import Decimal
from django import forms
from django.core.validators import MaxValueValidator, MinValueValidator
from django.utils.translation import gettext_lazy as _
__all__ = (
'CheckLastUpdatedMixin',
'DistanceValidationMixin',
)
@ -44,3 +47,13 @@ class CheckLastUpdatedMixin(forms.Form):
"This object has been modified since the form was rendered. Please consult the object's change "
"log for details."
))
class DistanceValidationMixin(forms.Form):
distance = forms.DecimalField(
required=False,
validators=[
MinValueValidator(Decimal(0)),
MaxValueValidator(Decimal(100000)),
]
)

View File

@ -49,11 +49,27 @@ class DataFileLoader(BaseLoader):
# Utility functions
#
def render_jinja2(template_code, context, environment_params=None):
def render_jinja2(template_code, context, environment_params=None, data_file=None):
"""
Render a Jinja2 template with the provided context. Return the rendered content.
"""
environment_params = environment_params or {}
if 'loader' not in environment_params:
if data_file:
loader = DataFileLoader(data_file.source)
loader.cache_templates({
data_file.path: template_code
})
else:
loader = BaseLoader()
environment_params['loader'] = loader
environment = SandboxedEnvironment(**environment_params)
environment.filters.update(get_config().JINJA2_FILTERS)
return environment.from_string(source=template_code).render(**context)
if data_file:
template = environment.get_template(data_file.path)
else:
template = environment.from_string(source=template_code)
return template.render(**context)

View File

@ -31,6 +31,11 @@
<div class="col mb-1">
{{ field }}
<div class="form-text">{% trans field.label %}</div>
{% if field.errors %}
<div class="form-text text-danger">
{% for error in field.errors %}{{ error }}{% if not forloop.last %}<br />{% endif %}{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>

View File

@ -1,5 +1,6 @@
from django.test import Client, TestCase, override_settings
from django.urls import reverse
from drf_spectacular.drainage import GENERATOR_STATS
from rest_framework import status
from core.models import ObjectType
@ -264,5 +265,6 @@ class APIDocsTestCase(TestCase):
self.assertEqual(response.status_code, 200)
url = reverse('schema')
response = self.client.get(url)
with GENERATOR_STATS.silence(): # Suppress schema generator warnings
response = self.client.get(url)
self.assertEqual(response.status_code, 200)

View File

@ -39,7 +39,7 @@ __all__ = (
)
@strawberry_django.filter(models.Cluster, lookups=True)
@strawberry_django.filter_type(models.Cluster, lookups=True)
class ClusterFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
type: Annotated['ClusterTypeFilter', strawberry.lazy('virtualization.graphql.filters')] | None = (
@ -58,19 +58,19 @@ class ClusterFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, P
)
@strawberry_django.filter(models.ClusterGroup, lookups=True)
@strawberry_django.filter_type(models.ClusterGroup, lookups=True)
class ClusterGroupFilter(ContactFilterMixin, OrganizationalModelFilterMixin):
vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter(models.ClusterType, lookups=True)
@strawberry_django.filter_type(models.ClusterType, lookups=True)
class ClusterTypeFilter(OrganizationalModelFilterMixin):
pass
@strawberry_django.filter(models.VirtualMachine, lookups=True)
@strawberry_django.filter_type(models.VirtualMachine, lookups=True)
class VirtualMachineFilter(
ContactFilterMixin,
ImageAttachmentFilterMixin,
@ -130,7 +130,7 @@ class VirtualMachineFilter(
)
@strawberry_django.filter(models.VMInterface, lookups=True)
@strawberry_django.filter_type(models.VMInterface, lookups=True)
class VMInterfaceFilter(VMComponentFilterMixin, InterfaceBaseFilterMixin):
ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -155,7 +155,7 @@ class VMInterfaceFilter(VMComponentFilterMixin, InterfaceBaseFilterMixin):
)
@strawberry_django.filter(models.VirtualDisk, lookups=True)
@strawberry_django.filter_type(models.VirtualDisk, lookups=True)
class VirtualDiskFilter(VMComponentFilterMixin):
size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()

View File

@ -1,3 +1,5 @@
import logging
from django.test import tag
from django.urls import reverse
from netaddr import IPNetwork
@ -10,7 +12,9 @@ from extras.choices import CustomFieldTypeChoices
from extras.models import ConfigTemplate, CustomField
from ipam.choices import VLANQinQRoleChoices
from ipam.models import Prefix, VLAN, VRF
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine
from utilities.testing import (
APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine, disable_logging,
)
from virtualization.choices import *
from virtualization.models import *
@ -402,7 +406,8 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
# Attempt to delete only the parent interface
url = self._get_detail_url(interface1)
self.client.delete(url, **self.header)
with disable_logging(level=logging.WARNING):
self.client.delete(url, **self.header)
self.assertEqual(virtual_machine.interfaces.count(), 4) # Parent was not deleted
# Attempt to bulk delete parent & child together

View File

@ -31,12 +31,12 @@ __all__ = (
)
@strawberry_django.filter(models.TunnelGroup, lookups=True)
@strawberry_django.filter_type(models.TunnelGroup, lookups=True)
class TunnelGroupFilter(OrganizationalModelFilterMixin):
pass
@strawberry_django.filter(models.TunnelTermination, lookups=True)
@strawberry_django.filter_type(models.TunnelTermination, lookups=True)
class TunnelTerminationFilter(
BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin
):
@ -56,7 +56,7 @@ class TunnelTerminationFilter(
outside_ip_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Tunnel, lookups=True)
@strawberry_django.filter_type(models.Tunnel, lookups=True)
class TunnelFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
status: Annotated['TunnelStatusEnum', strawberry.lazy('vpn.graphql.enums')] | None = (
@ -80,7 +80,7 @@ class TunnelFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
)
@strawberry_django.filter(models.IKEProposal, lookups=True)
@strawberry_django.filter_type(models.IKEProposal, lookups=True)
class IKEProposalFilter(PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
authentication_method: Annotated['AuthenticationMethodEnum', strawberry.lazy('vpn.graphql.enums')] | None = (
@ -101,7 +101,7 @@ class IKEProposalFilter(PrimaryModelFilterMixin):
)
@strawberry_django.filter(models.IKEPolicy, lookups=True)
@strawberry_django.filter_type(models.IKEPolicy, lookups=True)
class IKEPolicyFilter(PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
version: Annotated['IKEVersionEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field()
@ -112,7 +112,7 @@ class IKEPolicyFilter(PrimaryModelFilterMixin):
preshared_key: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.IPSecProposal, lookups=True)
@strawberry_django.filter_type(models.IPSecProposal, lookups=True)
class IPSecProposalFilter(PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
encryption_algorithm: Annotated['EncryptionAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = (
@ -132,7 +132,7 @@ class IPSecProposalFilter(PrimaryModelFilterMixin):
)
@strawberry_django.filter(models.IPSecPolicy, lookups=True)
@strawberry_django.filter_type(models.IPSecPolicy, lookups=True)
class IPSecPolicyFilter(PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
proposals: Annotated['IPSecProposalFilter', strawberry.lazy('vpn.graphql.filters')] | None = (
@ -141,7 +141,7 @@ class IPSecPolicyFilter(PrimaryModelFilterMixin):
pfs_group: Annotated['DHGroupEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.IPSecProfile, lookups=True)
@strawberry_django.filter_type(models.IPSecProfile, lookups=True)
class IPSecProfileFilter(PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
mode: Annotated['IPSecModeEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field()
@ -155,7 +155,7 @@ class IPSecProfileFilter(PrimaryModelFilterMixin):
ipsec_policy_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter(models.L2VPN, lookups=True)
@strawberry_django.filter_type(models.L2VPN, lookups=True)
class L2VPNFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
@ -174,7 +174,7 @@ class L2VPNFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixi
)
@strawberry_django.filter(models.L2VPNTermination, lookups=True)
@strawberry_django.filter_type(models.L2VPNTermination, lookups=True)
class L2VPNTerminationFilter(NetBoxModelFilterMixin):
l2vpn: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = strawberry_django.filter_field()
l2vpn_id: ID | None = strawberry_django.filter_field()

View File

@ -73,7 +73,7 @@ class TunnelTable(TenancyColumnsMixin, NetBoxTable):
default_columns = ('pk', 'name', 'group', 'status', 'encapsulation', 'tenant', 'terminations_count')
class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable):
class TunnelTerminationTable(NetBoxTable):
tunnel = tables.Column(
verbose_name=_('Tunnel'),
linkify=True
@ -89,7 +89,8 @@ class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable):
)
termination = tables.Column(
verbose_name=_('Tunnel interface'),
linkify=True
linkify=True,
orderable=False,
)
ip_addresses = columns.ManyToManyColumn(
accessor=tables.A('termination__ip_addresses'),

View File

@ -0,0 +1,23 @@
from django.test import RequestFactory, tag, TestCase
from vpn.models import TunnelTermination
from vpn.tables import TunnelTerminationTable
@tag('regression')
class TunnelTerminationTableTest(TestCase):
def test_every_orderable_field_does_not_throw_exception(self):
terminations = TunnelTermination.objects.all()
fake_request = RequestFactory().get("/")
disallowed = {'actions'}
orderable_columns = [
column.name for column in TunnelTerminationTable(terminations).columns
if column.orderable and column.name not in disallowed
]
for col in orderable_columns:
for dir in ('-', ''):
table = TunnelTerminationTable(terminations)
table.order_by = f'{dir}{col}'
table.as_html(fake_request)

View File

@ -7,6 +7,7 @@ from ipam.models import VLAN
from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField
from utilities.forms.mixins import DistanceValidationMixin
from utilities.forms.rendering import FieldSet, InlineFields
from wireless.models import *
@ -73,7 +74,7 @@ class WirelessLANForm(ScopedForm, TenancyForm, NetBoxModelForm):
}
class WirelessLinkForm(TenancyForm, NetBoxModelForm):
class WirelessLinkForm(DistanceValidationMixin, TenancyForm, NetBoxModelForm):
site_a = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,

View File

@ -23,12 +23,12 @@ __all__ = (
)
@strawberry_django.filter(models.WirelessLANGroup, lookups=True)
@strawberry_django.filter_type(models.WirelessLANGroup, lookups=True)
class WirelessLANGroupFilter(NestedGroupModelFilterMixin):
pass
@strawberry_django.filter(models.WirelessLAN, lookups=True)
@strawberry_django.filter_type(models.WirelessLAN, lookups=True)
class WirelessLANFilter(
WirelessAuthenticationBaseFilterMixin,
ScopedFilterMixin,
@ -47,7 +47,7 @@ class WirelessLANFilter(
vlan_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter(models.WirelessLink, lookups=True)
@strawberry_django.filter_type(models.WirelessLink, lookups=True)
class WirelessLinkFilter(
WirelessAuthenticationBaseFilterMixin,
DistanceFilterMixin,

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wireless', '0014_wirelesslangroup_comments'),
]
operations = [
migrations.AlterField(
model_name='wirelesslink',
name='_abs_distance',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=13, null=True),
),
]

View File

@ -1,6 +1,40 @@
# See PEP 518 for the spec of this file
# https://www.python.org/dev/peps/pep-0518/
[project]
name = "netbox"
version = "4.3.1"
requires-python = ">=3.10"
authors = [
{ name = "NetBox Community" }
]
maintainers = [
{ name = "NetBox Community" }
]
description = "The premier source of truth powering network automation."
readme = "README.md"
license = "Apache-2.0"
license-files = ["LICENSE.txt"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Framework :: Django",
"Intended Audience :: Developers",
"Intended Audience :: System Administrators",
"Natural Language :: English",
"Programming Language :: Python",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
[project.urls]
Homepage = "https://netboxlabs.com/products/netbox/"
Documentation = "https://netboxlabs.com/docs/netbox/"
Source = "https://github.com/netbox-community/netbox"
Issues = "https://github.com/netbox-community/netbox/issues"
[tool.black]
line-length = 120
target_version = ['py310', 'py311', 'py312']