diff --git a/docs/configuration/security.md b/docs/configuration/security.md index 676c1a336..a647a65b8 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -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. --- diff --git a/docs/configuration/system.md b/docs/configuration/system.md index fe01e40b1..20143276c 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -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): diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index 342b2c3b3..75dd7f8a4 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -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. diff --git a/docs/index.md b/docs/index.md index a79ab03b4..1494de5f3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index f9a7a3189..21ffa9766 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -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 ``` diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md index 43cc0ce82..d5d376db8 100644 --- a/docs/plugins/development/views.md +++ b/docs/plugins/development/views.md @@ -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//', 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 diff --git a/netbox/account/views.py b/netbox/account/views.py index a2f21b44e..f5ef534ce 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -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, { diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index 6f8ab783d..ce09862ae 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -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(), diff --git a/netbox/circuits/graphql/filters.py b/netbox/circuits/graphql/filters.py index 966849fd0..d6ef2976d 100644 --- a/netbox/circuits/graphql/filters.py +++ b/netbox/circuits/graphql/filters.py @@ -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 ): diff --git a/netbox/circuits/migrations/0052_extend_circuit_abs_distance_upper_limit.py b/netbox/circuits/migrations/0052_extend_circuit_abs_distance_upper_limit.py new file mode 100644 index 000000000..abc54f627 --- /dev/null +++ b/netbox/circuits/migrations/0052_extend_circuit_abs_distance_upper_limit.py @@ -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), + ), + ] diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index 3643446bd..901893a77 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -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'), diff --git a/netbox/circuits/tables/virtual_circuits.py b/netbox/circuits/tables/virtual_circuits.py index 67ac03d59..ea3b6dc13 100644 --- a/netbox/circuits/tables/virtual_circuits.py +++ b/netbox/circuits/tables/virtual_circuits.py @@ -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( diff --git a/netbox/circuits/tests/test_tables.py b/netbox/circuits/tests/test_tables.py new file mode 100644 index 000000000..2ab001c9b --- /dev/null +++ b/netbox/circuits/tests/test_tables.py @@ -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) diff --git a/netbox/core/graphql/filters.py b/netbox/core/graphql/filters.py index e5d44674a..76ace2362 100644 --- a/netbox/core/graphql/filters.py +++ b/netbox/core/graphql/filters.py @@ -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() diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py index d8fb8fd83..e9e77f252 100644 --- a/netbox/core/tests/test_api.py +++ b/netbox/core/tests/test_api.py @@ -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) diff --git a/netbox/core/tests/test_views.py b/netbox/core/tests/test_views.py index 047b51ef6..96a4292df 100644 --- a/netbox/core/tests/test_views.py +++ b/netbox/core/tests/test_views.py @@ -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) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index a31cf136d..7f1493557 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -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, diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 77e7a53b9..a8a6c2a5e 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -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() diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index 127dfb9e5..e9484264c 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -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': _( diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index c3ac6053d..8af539b04 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -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 diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index ba8d4203d..2ae178653 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -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'} diff --git a/netbox/extras/graphql/filters.py b/netbox/extras/graphql/filters.py index 2798c4896..1712b7056 100644 --- a/netbox/extras/graphql/filters.py +++ b/netbox/extras/graphql/filters.py @@ -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() diff --git a/netbox/extras/models/mixins.py b/netbox/extras/models/mixins.py index 3a7273f93..eb017302a 100644 --- a/netbox/extras/models/mixins.py +++ b/netbox/extras/models/mixins.py @@ -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') diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 6e3fb37fc..29af3f96d 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -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): diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 089e47c02..6b718569c 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -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({})) diff --git a/netbox/extras/tests/test_scripts.py b/netbox/extras/tests/test_scripts.py index bed8f0fc5..17eb5a31a 100644 --- a/netbox/extras/tests/test_scripts.py +++ b/netbox/extras/tests/test_scripts.py @@ -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, diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 6d0ba94ea..795aa2b2b 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -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) diff --git a/netbox/ipam/graphql/filters.py b/netbox/ipam/graphql/filters.py index 9d168f3d9..53096af38 100644 --- a/netbox/ipam/graphql/filters.py +++ b/netbox/ipam/graphql/filters.py @@ -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() diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index 77ab8194a..89013aa31 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -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( diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 4b9b340c4..a7562a53b 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -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) diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index c1b4acf60..852fd3ea9 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -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 diff --git a/netbox/netbox/models/mixins.py b/netbox/netbox/models/mixins.py index dc706c7c2..13af8aaf5 100644 --- a/netbox/netbox/models/mixins.py +++ b/netbox/netbox/models/mixins.py @@ -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 diff --git a/netbox/templates/account/profile.html b/netbox/templates/account/profile.html index 20f8ad537..442cce9ba 100644 --- a/netbox/templates/account/profile.html +++ b/netbox/templates/account/profile.html @@ -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 %} -
+

{% trans "Account Details" %}

@@ -64,12 +62,7 @@ {% if perms.core.view_objectchange %}
-
-

{% trans "Recent Activity" %}

-
- {% render_table changelog_table 'inc/table.html' %} -
-
+ {% include 'users/inc/user_activity.html' with user=user table=changelog_table %}
{% endif %} diff --git a/netbox/templates/circuits/provideraccount.html b/netbox/templates/circuits/provideraccount.html index 8715dfe1e..41cf8c033 100644 --- a/netbox/templates/circuits/provideraccount.html +++ b/netbox/templates/circuits/provideraccount.html @@ -28,6 +28,10 @@ {% trans "Name" %} {{ object.name|placeholder }} + + {% trans "Description" %} + {{ object.description|placeholder }} +
{% include 'inc/panels/tags.html' %} diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index 72dd12a88..41d74e0eb 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -53,7 +53,6 @@

{% trans "Connection" %}

-
{% if object.mark_connected %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 8f27a5cc1..5bd731535 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -26,6 +26,12 @@ {% trans "Location" %} {% nested_tree object.location %} + {% if object.virtual_chassis %} + + {% trans "Virtual Chassis" %} + {{ object.virtual_chassis|linkify }} + + {% endif %} {% trans "Rack" %} diff --git a/netbox/templates/extras/object_render_config.html b/netbox/templates/extras/object_render_config.html index 42c6c1b86..10d6d4aef 100644 --- a/netbox/templates/extras/object_render_config.html +++ b/netbox/templates/extras/object_render_config.html @@ -63,11 +63,15 @@
{{ rendered_config }}
- {% else %} + {% elif error_message %}

{% trans "Error rendering template" %}

{% trans error_message %}
+ {% else %} +
+

{% trans "Template output is empty" %}

+
{% endif %} {% else %}
diff --git a/netbox/templates/users/inc/user_activity.html b/netbox/templates/users/inc/user_activity.html new file mode 100644 index 000000000..74d6500ab --- /dev/null +++ b/netbox/templates/users/inc/user_activity.html @@ -0,0 +1,16 @@ +{% load i18n %} +{% load render_table from django_tables2 %} + +
+

+ {% trans "Recent Activity" %} + +

+
+ {% render_table table 'inc/table.html' %} +
+
diff --git a/netbox/templates/users/user.html b/netbox/templates/users/user.html index 967cc0537..84e4cac68 100644 --- a/netbox/templates/users/user.html +++ b/netbox/templates/users/user.html @@ -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 %} -
+

{% trans "User" %}

@@ -74,12 +72,7 @@ {% if perms.core.view_objectchange %}
-
-

{% trans "Recent Activity" %}

-
- {% render_table changelog_table 'inc/table.html' %} -
-
+ {% include 'users/inc/user_activity.html' with user=object table=changelog_table %}
{% endif %} diff --git a/netbox/tenancy/graphql/filters.py b/netbox/tenancy/graphql/filters.py index f215fd8ab..fb37359ef 100644 --- a/netbox/tenancy/graphql/filters.py +++ b/netbox/tenancy/graphql/filters.py @@ -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() diff --git a/netbox/translations/en/LC_MESSAGES/django.po b/netbox/translations/en/LC_MESSAGES/django.po index cf8b89645..162a0ce10 100644 --- a/netbox/translations/en/LC_MESSAGES/django.po +++ b/netbox/translations/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-05-13 05:01+0000\n" +"POT-Creation-Date: 2025-06-04 05:01+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -165,8 +165,8 @@ msgstr "" #: netbox/dcim/filtersets.py:215 netbox/dcim/filtersets.py:336 #: netbox/dcim/filtersets.py:467 netbox/dcim/filtersets.py:1075 #: netbox/dcim/filtersets.py:1397 netbox/dcim/filtersets.py:1495 -#: netbox/dcim/filtersets.py:2160 netbox/dcim/filtersets.py:2403 -#: netbox/dcim/filtersets.py:2461 netbox/ipam/filtersets.py:954 +#: netbox/dcim/filtersets.py:2175 netbox/dcim/filtersets.py:2418 +#: netbox/dcim/filtersets.py:2476 netbox/ipam/filtersets.py:954 #: netbox/virtualization/filtersets.py:139 netbox/vpn/filtersets.py:361 msgid "Region (ID)" msgstr "" @@ -177,8 +177,8 @@ msgstr "" #: netbox/dcim/filtersets.py:222 netbox/dcim/filtersets.py:343 #: netbox/dcim/filtersets.py:474 netbox/dcim/filtersets.py:1082 #: netbox/dcim/filtersets.py:1404 netbox/dcim/filtersets.py:1502 -#: netbox/dcim/filtersets.py:2167 netbox/dcim/filtersets.py:2410 -#: netbox/dcim/filtersets.py:2468 netbox/extras/filtersets.py:602 +#: netbox/dcim/filtersets.py:2182 netbox/dcim/filtersets.py:2425 +#: netbox/dcim/filtersets.py:2483 netbox/extras/filtersets.py:602 #: netbox/ipam/filtersets.py:961 netbox/virtualization/filtersets.py:146 #: netbox/vpn/filtersets.py:356 msgid "Region (slug)" @@ -189,8 +189,8 @@ msgstr "" #: netbox/dcim/filtersets.py:131 netbox/dcim/filtersets.py:228 #: netbox/dcim/filtersets.py:349 netbox/dcim/filtersets.py:480 #: netbox/dcim/filtersets.py:1088 netbox/dcim/filtersets.py:1410 -#: netbox/dcim/filtersets.py:1508 netbox/dcim/filtersets.py:2173 -#: netbox/dcim/filtersets.py:2416 netbox/dcim/filtersets.py:2474 +#: netbox/dcim/filtersets.py:1508 netbox/dcim/filtersets.py:2188 +#: netbox/dcim/filtersets.py:2431 netbox/dcim/filtersets.py:2489 #: netbox/ipam/filtersets.py:239 netbox/ipam/filtersets.py:967 #: netbox/virtualization/filtersets.py:152 msgid "Site group (ID)" @@ -201,8 +201,8 @@ msgstr "" #: netbox/dcim/filtersets.py:138 netbox/dcim/filtersets.py:235 #: netbox/dcim/filtersets.py:356 netbox/dcim/filtersets.py:487 #: netbox/dcim/filtersets.py:1095 netbox/dcim/filtersets.py:1417 -#: netbox/dcim/filtersets.py:1515 netbox/dcim/filtersets.py:2180 -#: netbox/dcim/filtersets.py:2423 netbox/dcim/filtersets.py:2481 +#: netbox/dcim/filtersets.py:1515 netbox/dcim/filtersets.py:2195 +#: netbox/dcim/filtersets.py:2438 netbox/dcim/filtersets.py:2496 #: netbox/extras/filtersets.py:608 netbox/ipam/filtersets.py:246 #: netbox/ipam/filtersets.py:974 netbox/virtualization/filtersets.py:159 msgid "Site group (slug)" @@ -252,8 +252,8 @@ msgstr "" #: netbox/virtualization/forms/model_forms.py:178 #: netbox/virtualization/tables/virtualmachines.py:33 #: netbox/vpn/forms/filtersets.py:277 netbox/wireless/forms/filtersets.py:88 -#: netbox/wireless/forms/model_forms.py:80 -#: netbox/wireless/forms/model_forms.py:122 +#: netbox/wireless/forms/model_forms.py:81 +#: netbox/wireless/forms/model_forms.py:123 msgid "Site" msgstr "" @@ -318,8 +318,8 @@ msgstr "" #: netbox/dcim/base_filtersets.py:47 netbox/dcim/filtersets.py:239 #: netbox/dcim/filtersets.py:360 netbox/dcim/filtersets.py:455 #: netbox/dcim/filtersets.py:1099 netbox/dcim/filtersets.py:1422 -#: netbox/dcim/filtersets.py:1520 netbox/dcim/filtersets.py:2185 -#: netbox/dcim/filtersets.py:2427 netbox/dcim/filtersets.py:2486 +#: netbox/dcim/filtersets.py:1520 netbox/dcim/filtersets.py:2200 +#: netbox/dcim/filtersets.py:2442 netbox/dcim/filtersets.py:2501 #: netbox/ipam/filtersets.py:251 netbox/ipam/filtersets.py:978 #: netbox/virtualization/filtersets.py:163 netbox/vpn/filtersets.py:371 msgid "Site (ID)" @@ -329,7 +329,7 @@ msgstr "" #: netbox/dcim/base_filtersets.py:59 netbox/dcim/filtersets.py:261 #: netbox/dcim/filtersets.py:372 netbox/dcim/filtersets.py:493 #: netbox/dcim/filtersets.py:1111 netbox/dcim/filtersets.py:1433 -#: netbox/dcim/filtersets.py:1531 netbox/dcim/filtersets.py:2439 +#: netbox/dcim/filtersets.py:1531 netbox/dcim/filtersets.py:2454 msgid "Location (ID)" msgstr "" @@ -341,7 +341,7 @@ msgstr "" #: netbox/circuits/filtersets.py:537 netbox/core/filtersets.py:81 #: netbox/core/filtersets.py:140 netbox/core/filtersets.py:177 #: netbox/dcim/filtersets.py:780 netbox/dcim/filtersets.py:1489 -#: netbox/dcim/filtersets.py:2534 netbox/extras/filtersets.py:45 +#: netbox/dcim/filtersets.py:2549 netbox/extras/filtersets.py:45 #: netbox/extras/filtersets.py:67 netbox/extras/filtersets.py:96 #: netbox/extras/filtersets.py:136 netbox/extras/filtersets.py:185 #: netbox/extras/filtersets.py:213 netbox/extras/filtersets.py:243 @@ -369,9 +369,9 @@ msgstr "" #: netbox/circuits/forms/filtersets.py:224 #: netbox/circuits/forms/filtersets.py:251 #: netbox/circuits/forms/filtersets.py:297 -#: netbox/circuits/forms/model_forms.py:139 -#: netbox/circuits/forms/model_forms.py:162 -#: netbox/circuits/forms/model_forms.py:262 +#: netbox/circuits/forms/model_forms.py:140 +#: netbox/circuits/forms/model_forms.py:163 +#: netbox/circuits/forms/model_forms.py:263 #: netbox/circuits/tables/circuits.py:107 #: netbox/circuits/tables/circuits.py:202 netbox/dcim/forms/connections.py:73 #: netbox/templates/circuits/circuit.html:15 @@ -434,9 +434,9 @@ msgstr "" #: netbox/circuits/forms/bulk_import.py:249 #: netbox/circuits/forms/filtersets.py:373 #: netbox/circuits/forms/filtersets.py:379 -#: netbox/circuits/forms/model_forms.py:343 -#: netbox/circuits/forms/model_forms.py:358 -#: netbox/circuits/tables/virtual_circuits.py:88 +#: netbox/circuits/forms/model_forms.py:344 +#: netbox/circuits/forms/model_forms.py:359 +#: netbox/circuits/tables/virtual_circuits.py:87 #: netbox/templates/circuits/virtualcircuit.html:20 #: netbox/templates/circuits/virtualcircuittermination.html:38 msgid "Virtual circuit" @@ -449,7 +449,7 @@ msgid "Interface (ID)" msgstr "" #: netbox/circuits/forms/bulk_edit.py:42 netbox/circuits/forms/filtersets.py:64 -#: netbox/circuits/forms/model_forms.py:42 +#: netbox/circuits/forms/model_forms.py:43 #: netbox/circuits/tables/providers.py:32 netbox/dcim/forms/bulk_edit.py:137 #: netbox/dcim/forms/filtersets.py:197 netbox/dcim/forms/model_forms.py:132 #: netbox/dcim/tables/sites.py:100 netbox/ipam/models/asns.py:123 @@ -482,7 +482,7 @@ msgstr "" #: netbox/dcim/forms/bulk_edit.py:1194 netbox/dcim/forms/bulk_edit.py:1239 #: netbox/dcim/forms/bulk_edit.py:1266 netbox/dcim/forms/bulk_edit.py:1284 #: netbox/dcim/forms/bulk_edit.py:1302 netbox/dcim/forms/bulk_edit.py:1320 -#: netbox/dcim/forms/bulk_edit.py:1793 netbox/dcim/forms/bulk_edit.py:1834 +#: netbox/dcim/forms/bulk_edit.py:1800 netbox/dcim/forms/bulk_edit.py:1841 #: netbox/extras/forms/bulk_edit.py:40 netbox/extras/forms/bulk_edit.py:150 #: netbox/extras/forms/bulk_edit.py:183 netbox/extras/forms/bulk_edit.py:211 #: netbox/extras/forms/bulk_edit.py:241 netbox/extras/forms/bulk_edit.py:289 @@ -503,6 +503,7 @@ msgstr "" #: netbox/templates/circuits/circuittype.html:26 #: netbox/templates/circuits/inc/circuit_termination_fields.html:83 #: netbox/templates/circuits/provider.html:33 +#: netbox/templates/circuits/provideraccount.html:32 #: netbox/templates/circuits/providernetwork.html:32 #: netbox/templates/circuits/virtualcircuit.html:56 #: netbox/templates/circuits/virtualcircuittermination.html:68 @@ -511,7 +512,8 @@ msgstr "" #: netbox/templates/core/plugin.html:80 netbox/templates/dcim/cable.html:36 #: netbox/templates/dcim/consoleport.html:44 #: netbox/templates/dcim/consoleserverport.html:44 -#: netbox/templates/dcim/device.html:94 netbox/templates/dcim/devicebay.html:32 +#: netbox/templates/dcim/device.html:100 +#: netbox/templates/dcim/devicebay.html:32 #: netbox/templates/dcim/devicerole.html:30 #: netbox/templates/dcim/devicetype.html:33 #: netbox/templates/dcim/frontport.html:58 @@ -619,15 +621,15 @@ msgstr "" #: netbox/circuits/forms/filtersets.py:338 #: netbox/circuits/forms/filtersets.py:374 #: netbox/circuits/forms/filtersets.py:397 -#: netbox/circuits/forms/model_forms.py:60 -#: netbox/circuits/forms/model_forms.py:76 -#: netbox/circuits/forms/model_forms.py:110 +#: netbox/circuits/forms/model_forms.py:61 +#: netbox/circuits/forms/model_forms.py:77 +#: netbox/circuits/forms/model_forms.py:111 #: netbox/circuits/tables/circuits.py:57 netbox/circuits/tables/circuits.py:111 #: netbox/circuits/tables/circuits.py:195 #: netbox/circuits/tables/providers.py:70 #: netbox/circuits/tables/providers.py:101 #: netbox/circuits/tables/virtual_circuits.py:46 -#: netbox/circuits/tables/virtual_circuits.py:93 +#: netbox/circuits/tables/virtual_circuits.py:92 #: netbox/templates/circuits/circuit.html:18 #: netbox/templates/circuits/circuitgroupassignment.html:26 #: netbox/templates/circuits/circuittermination.html:25 @@ -653,7 +655,7 @@ msgstr "" #: netbox/circuits/forms/filtersets.py:321 netbox/dcim/forms/bulk_edit.py:216 #: netbox/dcim/forms/bulk_edit.py:656 netbox/dcim/forms/bulk_edit.py:866 #: netbox/dcim/forms/bulk_edit.py:1235 netbox/dcim/forms/bulk_edit.py:1262 -#: netbox/dcim/forms/bulk_edit.py:1789 netbox/dcim/forms/filtersets.py:1132 +#: netbox/dcim/forms/bulk_edit.py:1796 netbox/dcim/forms/filtersets.py:1132 #: netbox/dcim/forms/filtersets.py:1390 netbox/dcim/forms/filtersets.py:1543 #: netbox/dcim/forms/filtersets.py:1567 netbox/dcim/tables/devices.py:744 #: netbox/dcim/tables/devices.py:800 netbox/dcim/tables/devices.py:1041 @@ -739,8 +741,8 @@ msgstr "" #: netbox/circuits/forms/bulk_import.py:214 #: netbox/circuits/forms/filtersets.py:151 #: netbox/circuits/forms/filtersets.py:346 -#: netbox/circuits/forms/model_forms.py:116 -#: netbox/circuits/forms/model_forms.py:330 +#: netbox/circuits/forms/model_forms.py:117 +#: netbox/circuits/forms/model_forms.py:331 #: netbox/templates/circuits/virtualcircuit.html:31 #: netbox/templates/circuits/virtualcircuittermination.html:34 msgid "Provider account" @@ -758,7 +760,7 @@ msgstr "" #: netbox/dcim/forms/bulk_edit.py:366 netbox/dcim/forms/bulk_edit.py:753 #: netbox/dcim/forms/bulk_edit.py:818 netbox/dcim/forms/bulk_edit.py:850 #: netbox/dcim/forms/bulk_edit.py:977 netbox/dcim/forms/bulk_edit.py:1770 -#: netbox/dcim/forms/bulk_edit.py:1812 netbox/dcim/forms/bulk_import.py:91 +#: netbox/dcim/forms/bulk_edit.py:1819 netbox/dcim/forms/bulk_import.py:91 #: netbox/dcim/forms/bulk_import.py:150 netbox/dcim/forms/bulk_import.py:254 #: netbox/dcim/forms/bulk_import.py:563 netbox/dcim/forms/bulk_import.py:717 #: netbox/dcim/forms/bulk_import.py:1168 netbox/dcim/forms/bulk_import.py:1375 @@ -770,7 +772,7 @@ msgstr "" #: netbox/dcim/forms/filtersets.py:1394 netbox/dcim/forms/filtersets.py:1645 #: netbox/dcim/tables/devices.py:150 netbox/dcim/tables/devices.py:524 #: netbox/dcim/tables/devices.py:855 netbox/dcim/tables/devices.py:989 -#: netbox/dcim/tables/devices.py:1101 netbox/dcim/tables/modules.py:104 +#: netbox/dcim/tables/devices.py:1100 netbox/dcim/tables/modules.py:104 #: netbox/dcim/tables/power.py:74 netbox/dcim/tables/racks.py:129 #: netbox/dcim/tables/sites.py:88 netbox/dcim/tables/sites.py:143 #: netbox/ipam/forms/bulk_edit.py:240 netbox/ipam/forms/bulk_edit.py:290 @@ -787,7 +789,7 @@ msgstr "" #: netbox/templates/circuits/virtualcircuit.html:43 #: netbox/templates/core/datasource.html:46 netbox/templates/core/job.html:48 #: netbox/templates/core/rq_task.html:81 netbox/templates/core/system.html:18 -#: netbox/templates/dcim/cable.html:19 netbox/templates/dcim/device.html:178 +#: netbox/templates/dcim/cable.html:19 netbox/templates/dcim/device.html:184 #: netbox/templates/dcim/inventoryitem.html:36 #: netbox/templates/dcim/location.html:45 netbox/templates/dcim/module.html:69 #: netbox/templates/dcim/powerfeed.html:36 @@ -836,7 +838,7 @@ msgstr "" #: netbox/circuits/forms/filtersets.py:332 netbox/dcim/forms/bulk_edit.py:131 #: netbox/dcim/forms/bulk_edit.py:196 netbox/dcim/forms/bulk_edit.py:361 #: netbox/dcim/forms/bulk_edit.py:484 netbox/dcim/forms/bulk_edit.py:743 -#: netbox/dcim/forms/bulk_edit.py:856 netbox/dcim/forms/bulk_edit.py:1817 +#: netbox/dcim/forms/bulk_edit.py:856 netbox/dcim/forms/bulk_edit.py:1824 #: netbox/dcim/forms/bulk_import.py:110 netbox/dcim/forms/bulk_import.py:155 #: netbox/dcim/forms/bulk_import.py:247 netbox/dcim/forms/bulk_import.py:362 #: netbox/dcim/forms/bulk_import.py:537 netbox/dcim/forms/bulk_import.py:1387 @@ -866,7 +868,7 @@ msgstr "" #: netbox/ipam/tables/vlans.py:207 netbox/templates/circuits/circuit.html:48 #: netbox/templates/circuits/circuitgroup.html:36 #: netbox/templates/circuits/virtualcircuit.html:47 -#: netbox/templates/dcim/cable.html:23 netbox/templates/dcim/device.html:79 +#: netbox/templates/dcim/cable.html:23 netbox/templates/dcim/device.html:85 #: netbox/templates/dcim/location.html:49 #: netbox/templates/dcim/powerfeed.html:44 netbox/templates/dcim/rack.html:32 #: netbox/templates/dcim/rackreservation.html:49 @@ -920,12 +922,12 @@ msgstr "" #: netbox/circuits/forms/bulk_edit.py:176 #: netbox/circuits/forms/filtersets.py:209 -#: netbox/circuits/forms/model_forms.py:136 +#: netbox/circuits/forms/model_forms.py:137 #: netbox/templates/circuits/circuit.html:38 #: netbox/templates/wireless/wirelesslink.html:38 #: netbox/wireless/forms/bulk_edit.py:133 #: netbox/wireless/forms/filtersets.py:130 -#: netbox/wireless/forms/model_forms.py:169 +#: netbox/wireless/forms/model_forms.py:170 msgid "Distance" msgstr "" @@ -941,7 +943,7 @@ msgid "Distance unit" msgstr "" #: netbox/circuits/forms/bulk_edit.py:196 -#: netbox/circuits/forms/model_forms.py:141 +#: netbox/circuits/forms/model_forms.py:142 msgid "Service Parameters" msgstr "" @@ -983,9 +985,9 @@ msgstr "" #: netbox/circuits/forms/bulk_edit.py:198 #: netbox/circuits/forms/bulk_edit.py:356 -#: netbox/circuits/forms/model_forms.py:142 -#: netbox/circuits/forms/model_forms.py:240 -#: netbox/circuits/forms/model_forms.py:345 +#: netbox/circuits/forms/model_forms.py:143 +#: netbox/circuits/forms/model_forms.py:241 +#: netbox/circuits/forms/model_forms.py:346 #: netbox/dcim/forms/model_forms.py:148 netbox/dcim/forms/model_forms.py:191 #: netbox/dcim/forms/model_forms.py:281 netbox/dcim/forms/model_forms.py:339 #: netbox/dcim/forms/model_forms.py:874 netbox/dcim/forms/model_forms.py:1869 @@ -1003,13 +1005,13 @@ msgstr "" #: netbox/virtualization/forms/model_forms.py:229 #: netbox/vpn/forms/bulk_edit.py:78 netbox/vpn/forms/filtersets.py:48 #: netbox/vpn/forms/model_forms.py:63 netbox/vpn/forms/model_forms.py:148 -#: netbox/vpn/forms/model_forms.py:414 netbox/wireless/forms/model_forms.py:58 -#: netbox/wireless/forms/model_forms.py:174 +#: netbox/vpn/forms/model_forms.py:414 netbox/wireless/forms/model_forms.py:59 +#: netbox/wireless/forms/model_forms.py:175 msgid "Tenancy" msgstr "" #: netbox/circuits/forms/bulk_edit.py:215 -#: netbox/circuits/forms/model_forms.py:170 +#: netbox/circuits/forms/model_forms.py:171 #: netbox/dcim/forms/bulk_import.py:1348 netbox/dcim/forms/bulk_import.py:1366 msgid "Termination type" msgstr "" @@ -1017,7 +1019,7 @@ msgstr "" #: netbox/circuits/forms/bulk_edit.py:218 #: netbox/circuits/forms/bulk_import.py:133 #: netbox/circuits/forms/filtersets.py:226 -#: netbox/circuits/forms/model_forms.py:173 +#: netbox/circuits/forms/model_forms.py:174 #: netbox/templates/circuits/inc/circuit_termination.html:6 #: netbox/templates/dcim/cable.html:68 netbox/templates/dcim/cable.html:72 #: netbox/vpn/forms/bulk_import.py:100 netbox/vpn/forms/filtersets.py:82 @@ -1041,7 +1043,7 @@ msgid "Mark connected" msgstr "" #: netbox/circuits/forms/bulk_edit.py:243 -#: netbox/circuits/forms/model_forms.py:184 +#: netbox/circuits/forms/model_forms.py:185 #: netbox/templates/circuits/inc/circuit_termination_fields.html:55 #: netbox/templates/dcim/frontport.html:121 #: netbox/templates/dcim/interface.html:250 @@ -1050,7 +1052,7 @@ msgid "Circuit Termination" msgstr "" #: netbox/circuits/forms/bulk_edit.py:245 -#: netbox/circuits/forms/model_forms.py:186 +#: netbox/circuits/forms/model_forms.py:187 msgid "Termination Details" msgstr "" @@ -1059,7 +1061,7 @@ msgstr "" #: netbox/circuits/forms/filtersets.py:305 #: netbox/circuits/tables/circuits.py:206 netbox/dcim/forms/model_forms.py:656 #: netbox/templates/circuits/circuitgroupassignment.html:34 -#: netbox/templates/dcim/device.html:133 +#: netbox/templates/dcim/device.html:139 #: netbox/templates/dcim/virtualchassis.html:68 #: netbox/templates/dcim/virtualchassis_edit.html:60 #: netbox/templates/ipam/inc/panels/fhrp_groups.html:26 @@ -1073,16 +1075,16 @@ msgstr "" #: netbox/circuits/forms/filtersets.py:264 #: netbox/circuits/forms/filtersets.py:354 #: netbox/circuits/forms/filtersets.py:392 -#: netbox/circuits/forms/model_forms.py:325 +#: netbox/circuits/forms/model_forms.py:326 #: netbox/circuits/tables/virtual_circuits.py:51 -#: netbox/circuits/tables/virtual_circuits.py:99 +#: netbox/circuits/tables/virtual_circuits.py:98 msgid "Provider network" msgstr "" #: netbox/circuits/forms/bulk_edit.py:365 #: netbox/circuits/forms/bulk_import.py:254 #: netbox/circuits/forms/filtersets.py:382 -#: netbox/circuits/forms/model_forms.py:365 netbox/dcim/forms/bulk_edit.py:372 +#: netbox/circuits/forms/model_forms.py:366 netbox/dcim/forms/bulk_edit.py:372 #: netbox/dcim/forms/bulk_edit.py:1324 netbox/dcim/forms/bulk_edit.py:1760 #: netbox/dcim/forms/bulk_import.py:259 netbox/dcim/forms/bulk_import.py:1137 #: netbox/dcim/forms/filtersets.py:369 netbox/dcim/forms/filtersets.py:797 @@ -1103,7 +1105,7 @@ msgstr "" #: netbox/ipam/tables/ip.py:269 netbox/ipam/tables/ip.py:325 #: netbox/ipam/tables/vlans.py:101 netbox/ipam/tables/vlans.py:213 #: netbox/templates/circuits/virtualcircuittermination.html:42 -#: netbox/templates/dcim/device.html:182 +#: netbox/templates/dcim/device.html:188 #: netbox/templates/dcim/inc/panels/inventory_items.html:20 #: netbox/templates/dcim/interface.html:178 #: netbox/templates/dcim/interface.html:280 @@ -1206,12 +1208,12 @@ msgid "Operational role" msgstr "" #: netbox/circuits/forms/bulk_import.py:259 -#: netbox/circuits/forms/model_forms.py:368 -#: netbox/circuits/tables/virtual_circuits.py:112 +#: netbox/circuits/forms/model_forms.py:369 +#: netbox/circuits/tables/virtual_circuits.py:111 #: netbox/dcim/forms/bulk_import.py:1268 netbox/dcim/forms/model_forms.py:1289 #: netbox/dcim/forms/model_forms.py:1558 netbox/dcim/forms/model_forms.py:1725 #: netbox/dcim/forms/model_forms.py:1760 netbox/dcim/forms/model_forms.py:1890 -#: netbox/dcim/tables/connections.py:65 netbox/dcim/tables/devices.py:1147 +#: netbox/dcim/tables/connections.py:65 netbox/dcim/tables/devices.py:1146 #: netbox/ipam/forms/bulk_import.py:324 netbox/ipam/forms/model_forms.py:290 #: netbox/ipam/forms/model_forms.py:299 netbox/ipam/tables/fhrp.py:64 #: netbox/ipam/tables/ip.py:330 netbox/ipam/tables/vlans.py:147 @@ -1230,8 +1232,8 @@ msgstr "" #: netbox/templates/wireless/wirelesslink.html:55 #: netbox/virtualization/forms/model_forms.py:377 #: netbox/vpn/forms/bulk_import.py:302 netbox/vpn/forms/model_forms.py:439 -#: netbox/vpn/forms/model_forms.py:448 netbox/wireless/forms/model_forms.py:117 -#: netbox/wireless/forms/model_forms.py:159 +#: netbox/vpn/forms/model_forms.py:448 netbox/wireless/forms/model_forms.py:118 +#: netbox/wireless/forms/model_forms.py:160 msgid "Interface" msgstr "" @@ -1273,8 +1275,8 @@ msgstr "" #: netbox/virtualization/forms/filtersets.py:80 #: netbox/virtualization/forms/filtersets.py:106 #: netbox/wireless/forms/filtersets.py:93 -#: netbox/wireless/forms/model_forms.py:91 -#: netbox/wireless/forms/model_forms.py:133 +#: netbox/wireless/forms/model_forms.py:92 +#: netbox/wireless/forms/model_forms.py:134 msgid "Location" msgstr "" @@ -1350,7 +1352,7 @@ msgstr "" #: netbox/circuits/forms/filtersets.py:82 netbox/circuits/tables/circuits.py:62 #: netbox/circuits/tables/providers.py:64 #: netbox/circuits/tables/virtual_circuits.py:55 -#: netbox/circuits/tables/virtual_circuits.py:103 +#: netbox/circuits/tables/virtual_circuits.py:102 #: netbox/templates/circuits/circuit.html:22 #: netbox/templates/circuits/provideraccount.html:24 msgid "Account" @@ -1372,7 +1374,7 @@ msgid "Assignment" msgstr "" #: netbox/circuits/forms/filtersets.py:302 -#: netbox/circuits/forms/model_forms.py:252 +#: netbox/circuits/forms/model_forms.py:253 #: netbox/circuits/tables/circuits.py:190 netbox/dcim/forms/bulk_edit.py:126 #: netbox/dcim/forms/bulk_import.py:103 netbox/dcim/forms/model_forms.py:125 #: netbox/dcim/tables/sites.py:95 netbox/extras/forms/filtersets.py:544 @@ -1406,21 +1408,21 @@ msgstr "" #: netbox/vpn/tables/tunnels.py:44 netbox/wireless/forms/bulk_edit.py:51 #: netbox/wireless/forms/bulk_import.py:38 #: netbox/wireless/forms/filtersets.py:49 -#: netbox/wireless/forms/model_forms.py:42 +#: netbox/wireless/forms/model_forms.py:43 #: netbox/wireless/tables/wirelesslan.py:48 msgid "Group" msgstr "" -#: netbox/circuits/forms/model_forms.py:239 +#: netbox/circuits/forms/model_forms.py:240 #: netbox/templates/circuits/circuitgroup.html:25 msgid "Circuit Group" msgstr "" -#: netbox/circuits/forms/model_forms.py:259 +#: netbox/circuits/forms/model_forms.py:260 msgid "Circuit type" msgstr "" -#: netbox/circuits/forms/model_forms.py:270 +#: netbox/circuits/forms/model_forms.py:271 msgid "Group Assignment" msgstr "" @@ -1508,7 +1510,7 @@ msgid "member ID" msgstr "" #: netbox/circuits/models/circuits.py:201 netbox/ipam/models/fhrp.py:96 -#: netbox/tenancy/models/contacts.py:133 +#: netbox/tenancy/models/contacts.py:119 msgid "priority" msgstr "" @@ -1606,7 +1608,7 @@ msgstr "" #: netbox/ipam/models/vlans.py:206 netbox/ipam/models/vlans.py:352 #: netbox/ipam/models/vrfs.py:20 netbox/ipam/models/vrfs.py:75 #: netbox/netbox/models/__init__.py:142 netbox/netbox/models/__init__.py:190 -#: netbox/tenancy/models/contacts.py:59 netbox/tenancy/models/tenants.py:19 +#: netbox/tenancy/models/contacts.py:57 netbox/tenancy/models/tenants.py:19 #: netbox/tenancy/models/tenants.py:42 netbox/users/models/permissions.py:19 #: netbox/users/models/users.py:28 netbox/virtualization/models/clusters.py:52 #: netbox/virtualization/models/virtualmachines.py:71 @@ -1826,7 +1828,7 @@ msgstr "" #: netbox/netbox/navigation/menu.py:275 netbox/netbox/navigation/menu.py:279 #: netbox/netbox/navigation/menu.py:281 #: netbox/templates/circuits/provider.html:57 -#: netbox/templates/circuits/provideraccount.html:44 +#: netbox/templates/circuits/provideraccount.html:48 #: netbox/templates/circuits/providernetwork.html:50 msgid "Circuits" msgstr "" @@ -1840,7 +1842,7 @@ msgid "Circuit ID" msgstr "" #: netbox/circuits/tables/circuits.py:71 -#: netbox/wireless/forms/model_forms.py:164 +#: netbox/wireless/forms/model_forms.py:165 msgid "Side A" msgstr "" @@ -1856,7 +1858,7 @@ msgstr "" #: netbox/circuits/tables/circuits.py:83 netbox/circuits/tables/providers.py:46 #: netbox/circuits/tables/providers.py:80 #: netbox/circuits/tables/providers.py:105 -#: netbox/circuits/tables/virtual_circuits.py:68 +#: netbox/circuits/tables/virtual_circuits.py:67 #: netbox/dcim/tables/devices.py:1074 netbox/dcim/tables/devicetypes.py:97 #: netbox/dcim/tables/modules.py:27 netbox/dcim/tables/modules.py:68 #: netbox/dcim/tables/modules.py:107 netbox/dcim/tables/power.py:39 @@ -1929,7 +1931,7 @@ msgstr "" msgid "ASN Count" msgstr "" -#: netbox/circuits/tables/virtual_circuits.py:65 +#: netbox/circuits/tables/virtual_circuits.py:64 #: netbox/netbox/navigation/menu.py:235 #: netbox/templates/circuits/virtualcircuit.html:87 #: netbox/templates/vpn/l2vpn.html:60 netbox/templates/vpn/tunnel.html:72 @@ -1937,9 +1939,9 @@ msgstr "" msgid "Terminations" msgstr "" -#: netbox/circuits/tables/virtual_circuits.py:109 +#: netbox/circuits/tables/virtual_circuits.py:108 #: netbox/dcim/forms/bulk_edit.py:789 netbox/dcim/forms/bulk_edit.py:1343 -#: netbox/dcim/forms/bulk_edit.py:1755 netbox/dcim/forms/bulk_edit.py:1807 +#: netbox/dcim/forms/bulk_edit.py:1755 netbox/dcim/forms/bulk_edit.py:1814 #: netbox/dcim/forms/bulk_import.py:699 netbox/dcim/forms/bulk_import.py:761 #: netbox/dcim/forms/bulk_import.py:787 netbox/dcim/forms/bulk_import.py:813 #: netbox/dcim/forms/bulk_import.py:833 netbox/dcim/forms/bulk_import.py:889 @@ -1972,7 +1974,7 @@ msgstr "" #: netbox/templates/circuits/virtualcircuittermination.html:56 #: netbox/templates/dcim/consoleport.html:20 #: netbox/templates/dcim/consoleserverport.html:20 -#: netbox/templates/dcim/device.html:15 netbox/templates/dcim/device.html:130 +#: netbox/templates/dcim/device.html:15 netbox/templates/dcim/device.html:136 #: netbox/templates/dcim/device_edit.html:12 #: netbox/templates/dcim/devicebay.html:20 #: netbox/templates/dcim/devicebay.html:48 @@ -1999,8 +2001,8 @@ msgstr "" #: netbox/vpn/forms/bulk_import.py:86 netbox/vpn/forms/bulk_import.py:288 #: netbox/vpn/forms/filtersets.py:286 netbox/vpn/forms/model_forms.py:91 #: netbox/vpn/forms/model_forms.py:126 netbox/vpn/forms/model_forms.py:237 -#: netbox/vpn/forms/model_forms.py:456 netbox/wireless/forms/model_forms.py:103 -#: netbox/wireless/forms/model_forms.py:145 +#: netbox/vpn/forms/model_forms.py:456 netbox/wireless/forms/model_forms.py:104 +#: netbox/wireless/forms/model_forms.py:146 #: netbox/wireless/tables/wirelesslan.py:84 msgid "Device" msgstr "" @@ -3047,7 +3049,7 @@ msgstr "" msgid "Reserved" msgstr "" -#: netbox/dcim/choices.py:101 netbox/templates/dcim/device.html:259 +#: netbox/dcim/choices.py:101 netbox/templates/dcim/device.html:265 msgid "Available" msgstr "" @@ -3088,7 +3090,7 @@ msgstr "" #: netbox/dcim/forms/model_forms.py:517 netbox/dcim/forms/model_forms.py:1207 #: netbox/dcim/forms/model_forms.py:1676 netbox/dcim/forms/object_import.py:177 #: netbox/dcim/tables/devices.py:696 netbox/dcim/tables/devices.py:906 -#: netbox/dcim/tables/devices.py:993 netbox/dcim/tables/devices.py:1153 +#: netbox/dcim/tables/devices.py:993 netbox/dcim/tables/devices.py:1152 #: netbox/extras/tables/tables.py:237 netbox/ipam/forms/bulk_import.py:568 #: netbox/ipam/forms/model_forms.py:768 netbox/ipam/tables/fhrp.py:59 #: netbox/ipam/tables/ip.py:336 netbox/ipam/tables/services.py:44 @@ -3112,7 +3114,7 @@ msgstr "" #: netbox/virtualization/tables/virtualmachines.py:132 #: netbox/wireless/forms/bulk_edit.py:26 #: netbox/wireless/forms/bulk_import.py:23 -#: netbox/wireless/forms/model_forms.py:22 +#: netbox/wireless/forms/model_forms.py:23 msgid "Parent" msgstr "" @@ -3120,14 +3122,14 @@ msgstr "" msgid "Child" msgstr "" -#: netbox/dcim/choices.py:167 netbox/templates/dcim/device.html:349 +#: netbox/dcim/choices.py:167 netbox/templates/dcim/device.html:355 #: netbox/templates/dcim/rack.html:133 #: netbox/templates/dcim/rack_elevation_list.html:20 #: netbox/templates/dcim/rackreservation.html:76 msgid "Front" msgstr "" -#: netbox/dcim/choices.py:168 netbox/templates/dcim/device.html:355 +#: netbox/dcim/choices.py:168 netbox/templates/dcim/device.html:361 #: netbox/templates/dcim/rack.html:139 #: netbox/templates/dcim/rack_elevation_list.html:21 #: netbox/templates/dcim/rackreservation.html:82 @@ -3439,7 +3441,7 @@ msgstr "" #: netbox/dcim/filtersets.py:542 netbox/dcim/filtersets.py:707 #: netbox/dcim/filtersets.py:911 netbox/dcim/filtersets.py:985 #: netbox/dcim/filtersets.py:1025 netbox/dcim/filtersets.py:1368 -#: netbox/dcim/filtersets.py:2093 +#: netbox/dcim/filtersets.py:2108 msgid "Manufacturer (ID)" msgstr "" @@ -3447,7 +3449,7 @@ msgstr "" #: netbox/dcim/filtersets.py:548 netbox/dcim/filtersets.py:713 #: netbox/dcim/filtersets.py:917 netbox/dcim/filtersets.py:991 #: netbox/dcim/filtersets.py:1031 netbox/dcim/filtersets.py:1374 -#: netbox/dcim/filtersets.py:2099 +#: netbox/dcim/filtersets.py:2114 msgid "Manufacturer (slug)" msgstr "" @@ -3460,14 +3462,14 @@ msgid "Rack type (ID)" msgstr "" #: netbox/dcim/filtersets.py:414 netbox/dcim/filtersets.py:921 -#: netbox/dcim/filtersets.py:1047 netbox/dcim/filtersets.py:2103 +#: netbox/dcim/filtersets.py:1047 netbox/dcim/filtersets.py:2118 #: netbox/ipam/filtersets.py:376 netbox/ipam/filtersets.py:488 #: netbox/ipam/filtersets.py:998 netbox/virtualization/filtersets.py:177 msgid "Role (ID)" msgstr "" #: netbox/dcim/filtersets.py:420 netbox/dcim/filtersets.py:927 -#: netbox/dcim/filtersets.py:1054 netbox/dcim/filtersets.py:2109 +#: netbox/dcim/filtersets.py:1054 netbox/dcim/filtersets.py:2124 #: netbox/extras/filtersets.py:651 netbox/ipam/filtersets.py:382 #: netbox/ipam/filtersets.py:494 netbox/ipam/filtersets.py:1004 #: netbox/virtualization/filtersets.py:184 @@ -3476,7 +3478,7 @@ msgstr "" #: netbox/dcim/filtersets.py:450 netbox/dcim/filtersets.py:1123 #: netbox/dcim/filtersets.py:1444 netbox/dcim/filtersets.py:1542 -#: netbox/dcim/filtersets.py:2501 +#: netbox/dcim/filtersets.py:2516 msgid "Rack (ID)" msgstr "" @@ -3574,7 +3576,7 @@ msgstr "" msgid "Power port (ID)" msgstr "" -#: netbox/dcim/filtersets.py:907 netbox/dcim/filtersets.py:2089 +#: netbox/dcim/filtersets.py:907 netbox/dcim/filtersets.py:2104 msgid "Parent inventory item (ID)" msgstr "" @@ -3609,8 +3611,8 @@ msgid "Platform (slug)" msgstr "" #: netbox/dcim/filtersets.py:1105 netbox/dcim/filtersets.py:1428 -#: netbox/dcim/filtersets.py:1526 netbox/dcim/filtersets.py:2191 -#: netbox/dcim/filtersets.py:2433 netbox/dcim/filtersets.py:2492 +#: netbox/dcim/filtersets.py:1526 netbox/dcim/filtersets.py:2206 +#: netbox/dcim/filtersets.py:2448 netbox/dcim/filtersets.py:2507 msgid "Site name (slug)" msgstr "" @@ -3729,7 +3731,7 @@ msgstr "" #: netbox/dcim/filtersets.py:1591 netbox/dcim/forms/filtersets.py:111 #: netbox/dcim/tables/devices.py:216 netbox/netbox/navigation/menu.py:79 -#: netbox/templates/dcim/device.html:120 +#: netbox/templates/dcim/device.html:31 netbox/templates/dcim/device.html:126 #: netbox/templates/dcim/device_edit.html:95 #: netbox/templates/dcim/virtualchassis.html:20 #: netbox/templates/dcim/virtualchassis_add.html:12 @@ -3884,7 +3886,7 @@ msgid "LAG interface (ID)" msgstr "" #: netbox/dcim/filtersets.py:1923 netbox/dcim/tables/devices.py:612 -#: netbox/dcim/tables/devices.py:1142 netbox/templates/dcim/interface.html:131 +#: netbox/dcim/tables/devices.py:1141 netbox/templates/dcim/interface.html:131 #: netbox/templates/dcim/macaddress.html:11 #: netbox/templates/dcim/macaddress.html:14 #: netbox/templates/virtualization/vminterface.html:79 @@ -3912,7 +3914,7 @@ msgid "Virtual Device Context (Identifier)" msgstr "" #: netbox/dcim/filtersets.py:1973 netbox/templates/wireless/wirelesslan.html:11 -#: netbox/wireless/forms/model_forms.py:56 +#: netbox/wireless/forms/model_forms.py:57 msgid "Wireless LAN" msgstr "" @@ -3924,44 +3926,44 @@ msgstr "" msgid "Virtual circuit termination (ID)" msgstr "" -#: netbox/dcim/filtersets.py:2056 +#: netbox/dcim/filtersets.py:2071 msgid "Parent module bay (ID)" msgstr "" -#: netbox/dcim/filtersets.py:2061 +#: netbox/dcim/filtersets.py:2076 msgid "Installed module (ID)" msgstr "" -#: netbox/dcim/filtersets.py:2072 +#: netbox/dcim/filtersets.py:2087 msgid "Installed device (ID)" msgstr "" -#: netbox/dcim/filtersets.py:2078 +#: netbox/dcim/filtersets.py:2093 msgid "Installed device (name)" msgstr "" -#: netbox/dcim/filtersets.py:2148 +#: netbox/dcim/filtersets.py:2163 msgid "Master (ID)" msgstr "" -#: netbox/dcim/filtersets.py:2154 +#: netbox/dcim/filtersets.py:2169 msgid "Master (name)" msgstr "" -#: netbox/dcim/filtersets.py:2196 netbox/tenancy/filtersets.py:250 +#: netbox/dcim/filtersets.py:2211 netbox/tenancy/filtersets.py:250 msgid "Tenant (ID)" msgstr "" -#: netbox/dcim/filtersets.py:2202 netbox/extras/filtersets.py:711 +#: netbox/dcim/filtersets.py:2217 netbox/extras/filtersets.py:711 #: netbox/tenancy/filtersets.py:256 msgid "Tenant (slug)" msgstr "" -#: netbox/dcim/filtersets.py:2238 netbox/dcim/forms/filtersets.py:1145 +#: netbox/dcim/filtersets.py:2253 netbox/dcim/forms/filtersets.py:1145 msgid "Unterminated" msgstr "" -#: netbox/dcim/filtersets.py:2496 +#: netbox/dcim/filtersets.py:2511 msgid "Power panel (ID)" msgstr "" @@ -3981,7 +3983,7 @@ msgstr "" #: netbox/dcim/forms/object_create.py:208 #: netbox/dcim/forms/object_create.py:357 netbox/dcim/tables/devices.py:175 #: netbox/dcim/tables/devices.py:747 netbox/dcim/tables/devicetypes.py:253 -#: netbox/templates/dcim/device.html:43 netbox/templates/dcim/device.html:131 +#: netbox/templates/dcim/device.html:49 netbox/templates/dcim/device.html:137 #: netbox/templates/dcim/modulebay.html:38 #: netbox/templates/dcim/virtualchassis.html:66 #: netbox/templates/dcim/virtualchassis_edit.html:59 @@ -4104,7 +4106,7 @@ msgstr "" #: netbox/extras/forms/bulk_import.py:238 netbox/extras/forms/filtersets.py:66 #: netbox/extras/forms/filtersets.py:160 netbox/extras/forms/filtersets.py:254 #: netbox/extras/forms/filtersets.py:284 netbox/extras/forms/model_forms.py:572 -#: netbox/ipam/forms/bulk_edit.py:193 netbox/templates/dcim/device.html:324 +#: netbox/ipam/forms/bulk_edit.py:193 netbox/templates/dcim/device.html:330 #: netbox/templates/dcim/devicetype.html:49 #: netbox/templates/dcim/moduletype.html:51 netbox/templates/dcim/rack.html:81 #: netbox/templates/dcim/racktype.html:41 @@ -4142,7 +4144,7 @@ msgid "Outer Dimensions" msgstr "" #: netbox/dcim/forms/bulk_edit.py:316 netbox/dcim/forms/model_forms.py:234 -#: netbox/dcim/forms/model_forms.py:315 netbox/templates/dcim/device.html:315 +#: netbox/dcim/forms/model_forms.py:315 netbox/templates/dcim/device.html:321 #: netbox/templates/dcim/inc/panels/racktype_dimensions.html:3 msgid "Dimensions" msgstr "" @@ -4159,7 +4161,7 @@ msgid "Rack type" msgstr "" #: netbox/dcim/forms/bulk_edit.py:384 netbox/dcim/forms/bulk_edit.py:765 -#: netbox/dcim/forms/bulk_edit.py:826 netbox/templates/dcim/device.html:104 +#: netbox/dcim/forms/bulk_edit.py:826 netbox/templates/dcim/device.html:110 #: netbox/templates/dcim/module.html:77 netbox/templates/dcim/modulebay.html:70 #: netbox/templates/dcim/rack.html:57 #: netbox/templates/virtualization/virtualmachine.html:35 @@ -4177,7 +4179,7 @@ msgstr "" #: netbox/dcim/forms/bulk_import.py:295 netbox/dcim/forms/bulk_import.py:453 #: netbox/dcim/forms/bulk_import.py:638 netbox/dcim/forms/filtersets.py:282 #: netbox/dcim/forms/filtersets.py:513 netbox/dcim/forms/filtersets.py:684 -#: netbox/dcim/forms/filtersets.py:824 netbox/templates/dcim/device.html:98 +#: netbox/dcim/forms/filtersets.py:824 netbox/templates/dcim/device.html:104 #: netbox/templates/dcim/devicetype.html:65 #: netbox/templates/dcim/moduletype.html:47 netbox/templates/dcim/rack.html:65 #: netbox/templates/dcim/racktype.html:28 @@ -4196,7 +4198,7 @@ msgstr "" #: netbox/dcim/forms/model_forms.py:583 netbox/dcim/forms/model_forms.py:861 #: netbox/dcim/forms/object_create.py:404 netbox/dcim/tables/devices.py:171 #: netbox/dcim/tables/power.py:70 netbox/dcim/tables/racks.py:225 -#: netbox/ipam/forms/filtersets.py:467 netbox/templates/dcim/device.html:30 +#: netbox/ipam/forms/filtersets.py:467 netbox/templates/dcim/device.html:36 #: netbox/templates/dcim/inc/cable_termination.html:16 #: netbox/templates/dcim/powerfeed.html:28 netbox/templates/dcim/rack.html:13 #: netbox/templates/dcim/rack/base.html:4 @@ -4239,7 +4241,7 @@ msgstr "" #: netbox/dcim/forms/model_forms.py:1083 netbox/dcim/forms/model_forms.py:1111 #: netbox/dcim/forms/model_forms.py:1142 netbox/dcim/forms/model_forms.py:1161 #: netbox/dcim/forms/model_forms.py:1179 netbox/dcim/forms/object_create.py:123 -#: netbox/dcim/tables/devicetypes.py:82 netbox/templates/dcim/device.html:88 +#: netbox/dcim/tables/devicetypes.py:82 netbox/templates/dcim/device.html:94 #: netbox/templates/dcim/devicebay.html:52 netbox/templates/dcim/module.html:61 msgid "Device Type" msgstr "" @@ -4310,7 +4312,7 @@ msgstr "" #: netbox/dcim/forms/bulk_edit.py:748 netbox/dcim/forms/bulk_import.py:556 #: netbox/dcim/forms/filtersets.py:816 netbox/dcim/forms/model_forms.py:555 #: netbox/dcim/forms/model_forms.py:618 netbox/dcim/tables/devices.py:192 -#: netbox/extras/filtersets.py:656 netbox/templates/dcim/device.html:186 +#: netbox/extras/filtersets.py:656 netbox/templates/dcim/device.html:192 #: netbox/templates/dcim/platform.html:26 #: netbox/templates/virtualization/virtualmachine.html:27 #: netbox/virtualization/forms/bulk_edit.py:142 @@ -4326,7 +4328,7 @@ msgstr "" #: netbox/dcim/forms/model_forms.py:627 netbox/dcim/tables/devices.py:212 #: netbox/extras/filtersets.py:689 netbox/extras/forms/filtersets.py:364 #: netbox/ipam/forms/filtersets.py:439 netbox/ipam/forms/filtersets.py:472 -#: netbox/templates/dcim/device.html:239 +#: netbox/templates/dcim/device.html:245 #: netbox/templates/virtualization/cluster.html:10 #: netbox/templates/virtualization/virtualmachine.html:92 #: netbox/templates/virtualization/virtualmachine.html:101 @@ -5085,7 +5087,7 @@ msgid "{side_upper} side termination not found: {device} {name}" msgstr "" #: netbox/dcim/forms/bulk_import.py:1461 netbox/dcim/forms/model_forms.py:891 -#: netbox/dcim/tables/devices.py:1065 netbox/templates/dcim/device.html:132 +#: netbox/dcim/tables/devices.py:1065 netbox/templates/dcim/device.html:138 #: netbox/templates/dcim/virtualchassis.html:27 #: netbox/templates/dcim/virtualchassis.html:67 msgid "Master" @@ -5116,7 +5118,7 @@ msgid "Single or three-phase" msgstr "" #: netbox/dcim/forms/bulk_import.py:1607 netbox/dcim/forms/model_forms.py:1847 -#: netbox/templates/dcim/device.html:190 +#: netbox/templates/dcim/device.html:196 #: netbox/templates/dcim/virtualdevicecontext.html:30 #: netbox/templates/virtualization/virtualmachine.html:52 msgid "Primary IPv4" @@ -5127,7 +5129,7 @@ msgid "IPv4 address with mask, e.g. 1.2.3.4/24" msgstr "" #: netbox/dcim/forms/bulk_import.py:1614 netbox/dcim/forms/model_forms.py:1856 -#: netbox/templates/dcim/device.html:206 +#: netbox/templates/dcim/device.html:212 #: netbox/templates/dcim/virtualdevicecontext.html:41 #: netbox/templates/virtualization/virtualmachine.html:68 msgid "Primary IPv6" @@ -5341,12 +5343,12 @@ msgstr "" msgid "A virtual chassis member already exists in position {vc_position}." msgstr "" -#: netbox/dcim/forms/mixins.py:27 netbox/dcim/forms/mixins.py:75 +#: netbox/dcim/forms/mixins.py:27 netbox/dcim/forms/mixins.py:79 #: netbox/ipam/forms/bulk_edit.py:425 netbox/ipam/forms/model_forms.py:617 msgid "Scope type" msgstr "" -#: netbox/dcim/forms/mixins.py:30 netbox/dcim/forms/mixins.py:78 +#: netbox/dcim/forms/mixins.py:30 netbox/dcim/forms/mixins.py:82 #: netbox/ipam/forms/bulk_edit.py:270 netbox/ipam/forms/bulk_edit.py:428 #: netbox/ipam/forms/bulk_edit.py:447 netbox/ipam/forms/filtersets.py:181 #: netbox/ipam/forms/model_forms.py:231 netbox/ipam/forms/model_forms.py:620 @@ -5360,12 +5362,12 @@ msgstr "" #: netbox/virtualization/forms/model_forms.py:79 #: netbox/virtualization/tables/clusters.py:80 #: netbox/wireless/forms/bulk_edit.py:94 netbox/wireless/forms/filtersets.py:37 -#: netbox/wireless/forms/model_forms.py:57 +#: netbox/wireless/forms/model_forms.py:58 #: netbox/wireless/tables/wirelesslan.py:58 msgid "Scope" msgstr "" -#: netbox/dcim/forms/mixins.py:104 netbox/ipam/forms/bulk_import.py:452 +#: netbox/dcim/forms/mixins.py:108 netbox/ipam/forms/bulk_import.py:452 msgid "Scope type (app & model)" msgstr "" @@ -5474,7 +5476,7 @@ msgstr "" #: netbox/dcim/forms/model_forms.py:1290 netbox/dcim/forms/model_forms.py:1761 #: netbox/dcim/tables/connections.py:27 #: netbox/templates/dcim/consoleport.html:17 -#: netbox/templates/dcim/consoleserverport.html:74 +#: netbox/templates/dcim/consoleserverport.html:73 #: netbox/templates/dcim/frontport.html:112 msgid "Console Port" msgstr "" @@ -5489,7 +5491,7 @@ msgstr "" #: netbox/dcim/forms/model_forms.py:1292 netbox/dcim/forms/model_forms.py:1763 #: netbox/templates/circuits/inc/circuit_termination_fields.html:53 #: netbox/templates/dcim/consoleport.html:76 -#: netbox/templates/dcim/consoleserverport.html:77 +#: netbox/templates/dcim/consoleserverport.html:76 #: netbox/templates/dcim/frontport.html:17 #: netbox/templates/dcim/frontport.html:115 #: netbox/templates/dcim/interface.html:244 @@ -5501,7 +5503,7 @@ msgstr "" #: netbox/dcim/tables/devices.py:750 #: netbox/templates/circuits/inc/circuit_termination_fields.html:54 #: netbox/templates/dcim/consoleport.html:79 -#: netbox/templates/dcim/consoleserverport.html:80 +#: netbox/templates/dcim/consoleserverport.html:79 #: netbox/templates/dcim/frontport.html:50 #: netbox/templates/dcim/frontport.html:118 #: netbox/templates/dcim/interface.html:247 @@ -7233,7 +7235,7 @@ msgid "VMs" msgstr "" #: netbox/dcim/tables/devices.py:111 netbox/dcim/tables/devices.py:226 -#: netbox/extras/forms/model_forms.py:712 netbox/templates/dcim/device.html:112 +#: netbox/extras/forms/model_forms.py:712 netbox/templates/dcim/device.html:118 #: netbox/templates/dcim/devicerole.html:48 #: netbox/templates/dcim/platform.html:41 #: netbox/templates/extras/configtemplate.html:10 @@ -7244,7 +7246,7 @@ msgstr "" msgid "Config Template" msgstr "" -#: netbox/dcim/tables/devices.py:197 netbox/dcim/tables/devices.py:1106 +#: netbox/dcim/tables/devices.py:197 netbox/dcim/tables/devices.py:1105 #: netbox/ipam/forms/bulk_import.py:587 netbox/ipam/forms/model_forms.py:316 #: netbox/ipam/forms/model_forms.py:329 netbox/ipam/tables/ip.py:314 #: netbox/ipam/tables/ip.py:381 netbox/ipam/tables/ip.py:391 @@ -7253,12 +7255,12 @@ msgstr "" msgid "IP Address" msgstr "" -#: netbox/dcim/tables/devices.py:201 netbox/dcim/tables/devices.py:1110 +#: netbox/dcim/tables/devices.py:201 netbox/dcim/tables/devices.py:1109 #: netbox/virtualization/tables/virtualmachines.py:56 msgid "IPv4 Address" msgstr "" -#: netbox/dcim/tables/devices.py:205 netbox/dcim/tables/devices.py:1114 +#: netbox/dcim/tables/devices.py:205 netbox/dcim/tables/devices.py:1113 #: netbox/virtualization/tables/virtualmachines.py:60 msgid "IPv6 Address" msgstr "" @@ -7296,7 +7298,7 @@ msgstr "" msgid "Power outlets" msgstr "" -#: netbox/dcim/tables/devices.py:256 netbox/dcim/tables/devices.py:1119 +#: netbox/dcim/tables/devices.py:256 netbox/dcim/tables/devices.py:1118 #: netbox/dcim/tables/devicetypes.py:133 netbox/dcim/views.py:1173 #: netbox/dcim/views.py:1473 netbox/dcim/views.py:2226 #: netbox/netbox/navigation/menu.py:95 netbox/netbox/navigation/menu.py:259 @@ -7576,7 +7578,7 @@ msgid "Racks" msgstr "" #: netbox/dcim/tables/racks.py:63 netbox/dcim/tables/racks.py:145 -#: netbox/templates/dcim/device.html:318 +#: netbox/templates/dcim/device.html:324 #: netbox/templates/dcim/inc/panels/racktype_dimensions.html:14 msgid "Height" msgstr "" @@ -7768,7 +7770,7 @@ msgstr "" #: netbox/extras/choices.py:108 netbox/templates/tenancy/contact.html:67 #: netbox/tenancy/forms/bulk_edit.py:125 -#: netbox/wireless/forms/model_forms.py:172 +#: netbox/wireless/forms/model_forms.py:173 msgid "Link" msgstr "" @@ -8649,7 +8651,7 @@ msgstr "" #: netbox/extras/models/configs.py:38 netbox/extras/models/models.py:315 #: netbox/extras/models/models.py:480 netbox/extras/models/models.py:559 #: netbox/extras/models/search.py:48 netbox/extras/models/tags.py:44 -#: netbox/ipam/models/ip.py:188 netbox/netbox/models/mixins.py:15 +#: netbox/ipam/models/ip.py:188 netbox/netbox/models/mixins.py:16 msgid "weight" msgstr "" @@ -9990,7 +9992,7 @@ msgstr "" #: netbox/vpn/forms/model_forms.py:436 netbox/vpn/forms/model_forms.py:455 #: netbox/wireless/forms/bulk_edit.py:58 #: netbox/wireless/forms/bulk_import.py:50 -#: netbox/wireless/forms/model_forms.py:51 netbox/wireless/models.py:102 +#: netbox/wireless/forms/model_forms.py:52 netbox/wireless/models.py:102 msgid "VLAN" msgstr "" @@ -10059,8 +10061,8 @@ msgstr "" #: netbox/wireless/forms/bulk_edit.py:95 netbox/wireless/forms/bulk_edit.py:153 #: netbox/wireless/forms/filtersets.py:39 #: netbox/wireless/forms/filtersets.py:104 -#: netbox/wireless/forms/model_forms.py:59 -#: netbox/wireless/forms/model_forms.py:175 +#: netbox/wireless/forms/model_forms.py:60 +#: netbox/wireless/forms/model_forms.py:176 msgid "Authentication" msgstr "" @@ -10645,7 +10647,7 @@ msgstr "" msgid "Defined range exceeds maximum supported size ({max_size})" msgstr "" -#: netbox/ipam/models/ip.py:721 netbox/tenancy/models/contacts.py:78 +#: netbox/ipam/models/ip.py:721 netbox/tenancy/models/contacts.py:76 msgid "address" msgstr "" @@ -10887,7 +10889,7 @@ msgstr "" #: netbox/ipam/tables/ip.py:78 netbox/ipam/tables/ip.py:222 #: netbox/ipam/tables/ip.py:281 netbox/ipam/tables/vlans.py:55 -#: netbox/templates/dcim/device.html:260 +#: netbox/templates/dcim/device.html:266 #: netbox/templates/ipam/aggregate.html:24 #: netbox/templates/ipam/iprange.html:37 netbox/templates/ipam/prefix.html:102 msgid "Utilization" @@ -11170,7 +11172,7 @@ msgstr "" msgid "Tab" msgstr "" -#: netbox/netbox/choices.py:193 netbox/templates/dcim/device.html:327 +#: netbox/netbox/choices.py:193 netbox/templates/dcim/device.html:333 #: netbox/templates/dcim/rack.html:107 msgid "Kilograms" msgstr "" @@ -11179,7 +11181,7 @@ msgstr "" msgid "Grams" msgstr "" -#: netbox/netbox/choices.py:195 netbox/templates/dcim/device.html:328 +#: netbox/netbox/choices.py:195 netbox/templates/dcim/device.html:334 #: netbox/templates/dcim/rack.html:108 msgid "Pounds" msgstr "" @@ -11470,23 +11472,23 @@ msgstr "" msgid "{class_name} must implement a sync_data() method." msgstr "" -#: netbox/netbox/models/mixins.py:22 +#: netbox/netbox/models/mixins.py:23 msgid "weight unit" msgstr "" -#: netbox/netbox/models/mixins.py:52 +#: netbox/netbox/models/mixins.py:53 msgid "Must specify a unit when setting a weight" msgstr "" -#: netbox/netbox/models/mixins.py:57 +#: netbox/netbox/models/mixins.py:58 msgid "distance" msgstr "" -#: netbox/netbox/models/mixins.py:64 +#: netbox/netbox/models/mixins.py:65 msgid "distance unit" msgstr "" -#: netbox/netbox/models/mixins.py:99 +#: netbox/netbox/models/mixins.py:100 msgid "Must specify a unit when setting a distance" msgstr "" @@ -11527,7 +11529,7 @@ msgstr "" msgid "Modules" msgstr "" -#: netbox/netbox/navigation/menu.py:80 netbox/templates/dcim/device.html:160 +#: netbox/netbox/navigation/menu.py:80 netbox/templates/dcim/device.html:166 #: netbox/templates/dcim/virtualdevicecontext.html:8 msgid "Virtual Device Contexts" msgstr "" @@ -11605,7 +11607,7 @@ msgstr "" msgid "Service Templates" msgstr "" -#: netbox/netbox/navigation/menu.py:213 netbox/templates/dcim/device.html:302 +#: netbox/netbox/navigation/menu.py:213 netbox/templates/dcim/device.html:308 #: netbox/templates/ipam/ipaddress.html:118 #: netbox/templates/virtualization/virtualmachine.html:154 msgid "Services" @@ -12043,6 +12045,7 @@ msgid "Toggle all" msgstr "" #: netbox/netbox/tables/columns.py:307 +#: netbox/templates/inc/table_controls_htmx.html:35 msgid "Toggle Dropdown" msgstr "" @@ -12507,7 +12510,7 @@ msgstr "" #: netbox/templates/circuits/inc/circuit_termination_fields.html:20 #: netbox/templates/dcim/consoleport.html:59 -#: netbox/templates/dcim/consoleserverport.html:60 +#: netbox/templates/dcim/consoleserverport.html:59 #: netbox/templates/dcim/powerfeed.html:114 msgid "Marked as connected" msgstr "" @@ -12551,7 +12554,7 @@ msgstr "" #: netbox/templates/circuits/inc/circuit_termination_fields.html:49 #: netbox/templates/dcim/consoleport.html:69 -#: netbox/templates/dcim/consoleserverport.html:70 +#: netbox/templates/dcim/consoleserverport.html:69 #: netbox/templates/dcim/frontport.html:102 #: netbox/templates/dcim/interface.html:237 #: netbox/templates/dcim/interface.html:257 @@ -13070,7 +13073,7 @@ msgid "Rename Selected" msgstr "" #: netbox/templates/dcim/consoleport.html:65 -#: netbox/templates/dcim/consoleserverport.html:66 +#: netbox/templates/dcim/consoleserverport.html:65 #: netbox/templates/dcim/frontport.html:98 #: netbox/templates/dcim/interface.html:233 #: netbox/templates/dcim/poweroutlet.html:83 @@ -13078,86 +13081,86 @@ msgstr "" msgid "Not Connected" msgstr "" -#: netbox/templates/dcim/device.html:34 +#: netbox/templates/dcim/device.html:40 msgid "Highlight device in rack" msgstr "" -#: netbox/templates/dcim/device.html:55 +#: netbox/templates/dcim/device.html:61 msgid "Not racked" msgstr "" -#: netbox/templates/dcim/device.html:62 netbox/templates/dcim/site.html:94 +#: netbox/templates/dcim/device.html:68 netbox/templates/dcim/site.html:94 msgid "GPS Coordinates" msgstr "" -#: netbox/templates/dcim/device.html:68 netbox/templates/dcim/site.html:81 +#: netbox/templates/dcim/device.html:74 netbox/templates/dcim/site.html:81 #: netbox/templates/dcim/site.html:100 msgid "Map" msgstr "" -#: netbox/templates/dcim/device.html:108 +#: netbox/templates/dcim/device.html:114 #: netbox/templates/dcim/inventoryitem.html:60 #: netbox/templates/dcim/module.html:81 netbox/templates/dcim/modulebay.html:74 #: netbox/templates/dcim/rack.html:61 msgid "Asset Tag" msgstr "" -#: netbox/templates/dcim/device.html:123 +#: netbox/templates/dcim/device.html:129 msgid "View Virtual Chassis" msgstr "" -#: netbox/templates/dcim/device.html:164 +#: netbox/templates/dcim/device.html:170 msgid "Create VDC" msgstr "" -#: netbox/templates/dcim/device.html:175 +#: netbox/templates/dcim/device.html:181 #: netbox/templates/dcim/device_edit.html:66 #: netbox/virtualization/forms/model_forms.py:230 msgid "Management" msgstr "" -#: netbox/templates/dcim/device.html:195 netbox/templates/dcim/device.html:211 -#: netbox/templates/dcim/device.html:227 +#: netbox/templates/dcim/device.html:201 netbox/templates/dcim/device.html:217 +#: netbox/templates/dcim/device.html:233 #: netbox/templates/virtualization/virtualmachine.html:57 #: netbox/templates/virtualization/virtualmachine.html:73 msgid "NAT for" msgstr "" -#: netbox/templates/dcim/device.html:197 netbox/templates/dcim/device.html:213 -#: netbox/templates/dcim/device.html:229 +#: netbox/templates/dcim/device.html:203 netbox/templates/dcim/device.html:219 +#: netbox/templates/dcim/device.html:235 #: netbox/templates/virtualization/virtualmachine.html:59 #: netbox/templates/virtualization/virtualmachine.html:75 msgid "NAT" msgstr "" -#: netbox/templates/dcim/device.html:252 netbox/templates/dcim/rack.html:73 +#: netbox/templates/dcim/device.html:258 netbox/templates/dcim/rack.html:73 msgid "Power Utilization" msgstr "" -#: netbox/templates/dcim/device.html:256 +#: netbox/templates/dcim/device.html:262 msgid "Input" msgstr "" -#: netbox/templates/dcim/device.html:257 +#: netbox/templates/dcim/device.html:263 msgid "Outlets" msgstr "" -#: netbox/templates/dcim/device.html:258 +#: netbox/templates/dcim/device.html:264 msgid "Allocated" msgstr "" -#: netbox/templates/dcim/device.html:268 netbox/templates/dcim/device.html:270 -#: netbox/templates/dcim/device.html:286 +#: netbox/templates/dcim/device.html:274 netbox/templates/dcim/device.html:276 +#: netbox/templates/dcim/device.html:292 #: netbox/templates/dcim/powerfeed.html:67 msgid "VA" msgstr "" -#: netbox/templates/dcim/device.html:280 +#: netbox/templates/dcim/device.html:286 msgctxt "Leg of a power feed" msgid "Leg" msgstr "" -#: netbox/templates/dcim/device.html:306 +#: netbox/templates/dcim/device.html:312 #: netbox/templates/virtualization/virtualmachine.html:158 msgid "Add a service" msgstr "" @@ -14063,7 +14066,11 @@ msgstr "" msgid "Error rendering template" msgstr "" -#: netbox/templates/extras/object_render_config.html:74 +#: netbox/templates/extras/object_render_config.html:73 +msgid "Template output is empty" +msgstr "" + +#: netbox/templates/extras/object_render_config.html:78 msgid "No configuration template has been assigned." msgstr "" @@ -15101,7 +15108,7 @@ msgid "Add Wireless LAN" msgstr "" #: netbox/templates/wireless/wirelesslangroup.html:26 -#: netbox/wireless/forms/model_forms.py:30 +#: netbox/wireless/forms/model_forms.py:31 msgid "Wireless LAN Group" msgstr "" @@ -15189,63 +15196,55 @@ msgstr "" msgid "Assigned contact" msgstr "" -#: netbox/tenancy/models/contacts.py:33 +#: netbox/tenancy/models/contacts.py:32 msgid "contact group" msgstr "" -#: netbox/tenancy/models/contacts.py:34 +#: netbox/tenancy/models/contacts.py:33 msgid "contact groups" msgstr "" -#: netbox/tenancy/models/contacts.py:43 +#: netbox/tenancy/models/contacts.py:42 msgid "contact role" msgstr "" -#: netbox/tenancy/models/contacts.py:44 +#: netbox/tenancy/models/contacts.py:43 msgid "contact roles" msgstr "" -#: netbox/tenancy/models/contacts.py:64 +#: netbox/tenancy/models/contacts.py:62 msgid "title" msgstr "" -#: netbox/tenancy/models/contacts.py:69 +#: netbox/tenancy/models/contacts.py:67 msgid "phone" msgstr "" -#: netbox/tenancy/models/contacts.py:74 +#: netbox/tenancy/models/contacts.py:72 msgid "email" msgstr "" -#: netbox/tenancy/models/contacts.py:83 +#: netbox/tenancy/models/contacts.py:81 msgid "link" msgstr "" -#: netbox/tenancy/models/contacts.py:93 +#: netbox/tenancy/models/contacts.py:91 msgid "contact" msgstr "" -#: netbox/tenancy/models/contacts.py:94 +#: netbox/tenancy/models/contacts.py:92 msgid "contacts" msgstr "" -#: netbox/tenancy/models/contacts.py:108 -msgid "contact group membership" -msgstr "" - -#: netbox/tenancy/models/contacts.py:109 -msgid "contact group memberships" -msgstr "" - -#: netbox/tenancy/models/contacts.py:153 +#: netbox/tenancy/models/contacts.py:139 msgid "contact assignment" msgstr "" -#: netbox/tenancy/models/contacts.py:154 +#: netbox/tenancy/models/contacts.py:140 msgid "contact assignments" msgstr "" -#: netbox/tenancy/models/contacts.py:170 +#: netbox/tenancy/models/contacts.py:156 #, python-brace-format msgid "Contacts cannot be assigned to this object type ({type})." msgstr "" @@ -15705,7 +15704,7 @@ msgstr "" msgid "Unrecognized header: {name}" msgstr "" -#: netbox/utilities/forms/mixins.py:44 +#: netbox/utilities/forms/mixins.py:47 msgid "" "This object has been modified since the form was rendered. Please consult " "the object's change log for details." @@ -16650,7 +16649,7 @@ msgstr "" msgid "Interface B" msgstr "" -#: netbox/wireless/forms/model_forms.py:165 +#: netbox/wireless/forms/model_forms.py:166 msgid "Side B" msgstr "" diff --git a/netbox/users/graphql/filters.py b/netbox/users/graphql/filters.py index 8f8a8f946..60a80181b 100644 --- a/netbox/users/graphql/filters.py +++ b/netbox/users/graphql/filters.py @@ -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() diff --git a/netbox/users/views.py b/netbox/users/views.py index 16dacaa46..099bbcf87 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -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 { diff --git a/netbox/utilities/forms/mixins.py b/netbox/utilities/forms/mixins.py index e89fbb520..ca0f64e54 100644 --- a/netbox/utilities/forms/mixins.py +++ b/netbox/utilities/forms/mixins.py @@ -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)), + ] + ) diff --git a/netbox/utilities/jinja2.py b/netbox/utilities/jinja2.py index 37b3b2dfb..362bc2393 100644 --- a/netbox/utilities/jinja2.py +++ b/netbox/utilities/jinja2.py @@ -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) diff --git a/netbox/utilities/templates/form_helpers/render_fieldset.html b/netbox/utilities/templates/form_helpers/render_fieldset.html index ae8252b97..1821a3cb7 100644 --- a/netbox/utilities/templates/form_helpers/render_fieldset.html +++ b/netbox/utilities/templates/form_helpers/render_fieldset.html @@ -31,6 +31,11 @@
{{ field }}
{% trans field.label %}
+ {% if field.errors %} +
+ {% for error in field.errors %}{{ error }}{% if not forloop.last %}
{% endif %}{% endfor %} +
+ {% endif %}
{% endfor %}
diff --git a/netbox/utilities/tests/test_api.py b/netbox/utilities/tests/test_api.py index 2c3ba0566..b6e5e1360 100644 --- a/netbox/utilities/tests/test_api.py +++ b/netbox/utilities/tests/test_api.py @@ -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) diff --git a/netbox/virtualization/graphql/filters.py b/netbox/virtualization/graphql/filters.py index 2a09e86d1..a10ade5a2 100644 --- a/netbox/virtualization/graphql/filters.py +++ b/netbox/virtualization/graphql/filters.py @@ -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() diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index dfa8309a0..e07f4dc06 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -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 diff --git a/netbox/vpn/graphql/filters.py b/netbox/vpn/graphql/filters.py index f3ee290fe..21adcd100 100644 --- a/netbox/vpn/graphql/filters.py +++ b/netbox/vpn/graphql/filters.py @@ -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() diff --git a/netbox/vpn/tables/tunnels.py b/netbox/vpn/tables/tunnels.py index 94f65e573..fc8dec5e4 100644 --- a/netbox/vpn/tables/tunnels.py +++ b/netbox/vpn/tables/tunnels.py @@ -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'), diff --git a/netbox/vpn/tests/test_tables.py b/netbox/vpn/tests/test_tables.py new file mode 100644 index 000000000..0c7a4ae80 --- /dev/null +++ b/netbox/vpn/tests/test_tables.py @@ -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) diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py index 56422ab57..08f418e3c 100644 --- a/netbox/wireless/forms/model_forms.py +++ b/netbox/wireless/forms/model_forms.py @@ -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, diff --git a/netbox/wireless/graphql/filters.py b/netbox/wireless/graphql/filters.py index d71af7ae2..5bd22afab 100644 --- a/netbox/wireless/graphql/filters.py +++ b/netbox/wireless/graphql/filters.py @@ -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, diff --git a/netbox/wireless/migrations/0015_extend_wireless_link_abs_distance_upper_limit.py b/netbox/wireless/migrations/0015_extend_wireless_link_abs_distance_upper_limit.py new file mode 100644 index 000000000..86e8c6af5 --- /dev/null +++ b/netbox/wireless/migrations/0015_extend_wireless_link_abs_distance_upper_limit.py @@ -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), + ), + ] diff --git a/pyproject.toml b/pyproject.toml index 003465053..3664e4805 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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']