From 43841939a0b7c6140dae18c48d1aed3c770d4eb5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Feb 2025 13:10:40 -0500 Subject: [PATCH 001/103] Closes #18540: Track installed plugins in registry --- netbox/core/plugins.py | 3 ++- netbox/core/views.py | 3 ++- netbox/netbox/plugins/__init__.py | 1 + netbox/netbox/plugins/urls.py | 4 ++-- netbox/netbox/plugins/utils.py | 4 +++- netbox/netbox/plugins/views.py | 10 +++++++--- netbox/netbox/settings.py | 4 ++++ 7 files changed, 21 insertions(+), 8 deletions(-) diff --git a/netbox/core/plugins.py b/netbox/core/plugins.py index 1fcb37f2b..7da197a70 100644 --- a/netbox/core/plugins.py +++ b/netbox/core/plugins.py @@ -9,6 +9,7 @@ from django.conf import settings from django.core.cache import cache from netbox.plugins import PluginConfig +from netbox.registry import registry from utilities.datetime import datetime_from_timestamp USER_AGENT_STRING = f'NetBox/{settings.RELEASE.version} {settings.RELEASE.edition}' @@ -76,7 +77,7 @@ def get_local_plugins(plugins=None): local_plugins = {} # Gather all locally-installed plugins - for plugin_name in settings.PLUGINS: + for plugin_name in registry['plugins']['installed']: plugin = importlib.import_module(plugin_name) plugin_config: PluginConfig = plugin.config diff --git a/netbox/core/views.py b/netbox/core/views.py index 713807a82..ea1fd08d3 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -22,6 +22,7 @@ from rq.worker_registration import clean_worker_registry from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job from netbox.config import get_config, PARAMS +from netbox.registry import registry from netbox.views import generic from netbox.views.generic.base import BaseObjectView from netbox.views.generic.mixins import TableMixin @@ -560,7 +561,7 @@ class SystemView(UserPassesTestMixin, View): params = [param.name for param in PARAMS] data = { **stats, - 'plugins': settings.PLUGINS, + 'plugins': registry['plugins']['installed'], 'config': { k: getattr(config, k) for k in sorted(params) }, diff --git a/netbox/netbox/plugins/__init__.py b/netbox/netbox/plugins/__init__.py index 69881a251..f339f9d51 100644 --- a/netbox/netbox/plugins/__init__.py +++ b/netbox/netbox/plugins/__init__.py @@ -16,6 +16,7 @@ from .utils import * # Initialize plugin registry registry['plugins'].update({ + 'installed': [], 'graphql_schemas': [], 'menus': [], 'menu_items': {}, diff --git a/netbox/netbox/plugins/urls.py b/netbox/netbox/plugins/urls.py index 7a9f30c7e..791c1d7b5 100644 --- a/netbox/netbox/plugins/urls.py +++ b/netbox/netbox/plugins/urls.py @@ -1,11 +1,11 @@ from importlib import import_module from django.apps import apps -from django.conf import settings from django.conf.urls import include from django.urls import path from django.utils.module_loading import import_string, module_has_submodule +from netbox.registry import registry from . import views plugin_patterns = [] @@ -15,7 +15,7 @@ plugin_api_patterns = [ ] # Register base/API URL patterns for each plugin -for plugin_path in settings.PLUGINS: +for plugin_path in registry['plugins']['installed']: plugin = import_module(plugin_path) plugin_name = plugin_path.split('.')[-1] app = apps.get_app_config(plugin_name) diff --git a/netbox/netbox/plugins/utils.py b/netbox/netbox/plugins/utils.py index c260f156d..435afff6f 100644 --- a/netbox/netbox/plugins/utils.py +++ b/netbox/netbox/plugins/utils.py @@ -2,6 +2,8 @@ from django.apps import apps from django.conf import settings from django.core.exceptions import ImproperlyConfigured +from netbox.registry import registry + __all__ = ( 'get_installed_plugins', 'get_plugin_config', @@ -13,7 +15,7 @@ def get_installed_plugins(): Return a dictionary mapping the names of installed plugins to their versions. """ plugins = {} - for plugin_name in settings.PLUGINS: + for plugin_name in registry['plugins']['installed']: plugin_name = plugin_name.rsplit('.', 1)[-1] plugin_config = apps.get_app_config(plugin_name) plugins[plugin_name] = getattr(plugin_config, 'version', None) diff --git a/netbox/netbox/plugins/views.py b/netbox/netbox/plugins/views.py index 6a10f2e2c..7044b6ea0 100644 --- a/netbox/netbox/plugins/views.py +++ b/netbox/netbox/plugins/views.py @@ -1,7 +1,6 @@ from collections import OrderedDict from django.apps import apps -from django.conf import settings from django.urls.exceptions import NoReverseMatch from drf_spectacular.utils import extend_schema from rest_framework import permissions @@ -9,6 +8,8 @@ from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.views import APIView +from netbox.registry import registry + @extend_schema(exclude=True) class InstalledPluginsAPIView(APIView): @@ -34,7 +35,10 @@ class InstalledPluginsAPIView(APIView): } def get(self, request, format=None): - return Response([self._get_plugin_data(apps.get_app_config(plugin)) for plugin in settings.PLUGINS]) + return Response([ + self._get_plugin_data(apps.get_app_config(plugin)) + for plugin in registry['plugins']['installed'] + ]) @extend_schema(exclude=True) @@ -64,7 +68,7 @@ class PluginsAPIRootView(APIView): def get(self, request, format=None): entries = [] - for plugin in settings.PLUGINS: + for plugin in registry['plugins']['installed']: app_config = apps.get_app_config(plugin) entry = self._get_plugin_entry(plugin, app_config, request, format) if entry is not None: diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 0682e713d..ceb20d226 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,6 +16,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.config import PARAMS as CONFIG_PARAMS from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW from netbox.plugins import PluginConfig +from netbox.registry import registry from utilities.release import load_release_data from utilities.string import trailing_slash @@ -813,6 +814,9 @@ for plugin_name in PLUGINS: f"__init__.py file and point to the PluginConfig subclass." ) + # Register the plugin as installed successfully + registry['plugins']['installed'].append(plugin_name) + plugin_module = "{}.{}".format(plugin_config.__module__, plugin_config.__name__) # type: ignore # Gather additional apps to load alongside this plugin From 75417c9cd5a9775b072855827baca43786d5b53e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Feb 2025 13:37:58 -0500 Subject: [PATCH 002/103] Closes #17587: Add release_track attribute to PluginConfig --- docs/plugins/development/index.md | 1 + netbox/core/plugins.py | 5 ++++- netbox/netbox/plugins/__init__.py | 1 + netbox/netbox/plugins/utils.py | 5 ++++- netbox/netbox/plugins/views.py | 3 ++- 5 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index 246816349..ee8ba1118 100644 --- a/docs/plugins/development/index.md +++ b/docs/plugins/development/index.md @@ -103,6 +103,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i | `name` | Raw plugin name; same as the plugin's source directory | | `verbose_name` | Human-friendly name for the plugin | | `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) | +| `release_track` | An alternate release track (e.g. `dev` or `beta`) to which a release belongs | | `description` | Brief description of the plugin's purpose | | `author` | Name of plugin's author | | `author_email` | Author's public email address | diff --git a/netbox/core/plugins.py b/netbox/core/plugins.py index 7da197a70..e6d09711f 100644 --- a/netbox/core/plugins.py +++ b/netbox/core/plugins.py @@ -80,6 +80,9 @@ def get_local_plugins(plugins=None): for plugin_name in registry['plugins']['installed']: plugin = importlib.import_module(plugin_name) plugin_config: PluginConfig = plugin.config + installed_version = plugin_config.version + if plugin_config.release_track: + installed_version = f'{installed_version}-{plugin_config.release_track}' local_plugins[plugin_config.name] = Plugin( config_name=plugin_config.name, @@ -89,7 +92,7 @@ def get_local_plugins(plugins=None): description_short=plugin_config.description, is_local=True, is_installed=True, - installed_version=plugin_config.version, + installed_version=installed_version, ) # Update catalog entries for local plugins, or add them to the list if not listed diff --git a/netbox/netbox/plugins/__init__.py b/netbox/netbox/plugins/__init__.py index f339f9d51..bb3280ac4 100644 --- a/netbox/netbox/plugins/__init__.py +++ b/netbox/netbox/plugins/__init__.py @@ -48,6 +48,7 @@ class PluginConfig(AppConfig): author_email = '' description = '' version = '' + release_track = '' # Root URL path under /plugins. If not set, the plugin's label will be used. base_url = None diff --git a/netbox/netbox/plugins/utils.py b/netbox/netbox/plugins/utils.py index 435afff6f..886292274 100644 --- a/netbox/netbox/plugins/utils.py +++ b/netbox/netbox/plugins/utils.py @@ -18,7 +18,10 @@ def get_installed_plugins(): for plugin_name in registry['plugins']['installed']: plugin_name = plugin_name.rsplit('.', 1)[-1] plugin_config = apps.get_app_config(plugin_name) - plugins[plugin_name] = getattr(plugin_config, 'version', None) + if plugin_config.release_track: + plugins[plugin_name] = f'{plugin_config.version}-{plugin_config.release_track}' + else: + plugins[plugin_name] = plugin_config.version or None return dict(sorted(plugins.items())) diff --git a/netbox/netbox/plugins/views.py b/netbox/netbox/plugins/views.py index 7044b6ea0..feee78e82 100644 --- a/netbox/netbox/plugins/views.py +++ b/netbox/netbox/plugins/views.py @@ -31,7 +31,8 @@ class InstalledPluginsAPIView(APIView): 'author': plugin_app_config.author, 'author_email': plugin_app_config.author_email, 'description': plugin_app_config.description, - 'version': plugin_app_config.version + 'version': plugin_app_config.version, + 'release_track': plugin_app_config.release_track, } def get(self, request, format=None): From 697610db94b6b5b54362edeb463dc6e962902d08 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 6 Feb 2025 12:52:29 -0500 Subject: [PATCH 003/103] Closes #18541: Document support for auth_required attribute on PluginMenuItem --- docs/plugins/development/navigation.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/plugins/development/navigation.md b/docs/plugins/development/navigation.md index 90b523473..b5e2694b4 100644 --- a/docs/plugins/development/navigation.md +++ b/docs/plugins/development/navigation.md @@ -64,13 +64,14 @@ item1 = PluginMenuItem( A `PluginMenuItem` has the following attributes: -| Attribute | Required | Description | -|---------------|----------|----------------------------------------------------------------------------------------------------------| -| `link` | Yes | Name of the URL path to which this menu item links | -| `link_text` | Yes | The text presented to the user | -| `permissions` | - | A list of permissions required to display this link | -| `staff_only` | - | Display only for users who have `is_staff` set to true (any specified permissions will also be required) | -| `buttons` | - | An iterable of PluginMenuButton instances to include | +| Attribute | Required | Description | +|-----------------|----------|----------------------------------------------------------------------------------------------------------| +| `link` | Yes | Name of the URL path to which this menu item links | +| `link_text` | Yes | The text presented to the user | +| `permissions` | - | A list of permissions required to display this link | +| `auth_required` | - | Display only for authenticated users | +| `staff_only` | - | Display only for users who have `is_staff` set to true (any specified permissions will also be required) | +| `buttons` | - | An iterable of PluginMenuButton instances to include | ## Menu Buttons From 701f40e2a8e46ebcf8bf11707f1c418bbc06c9c8 Mon Sep 17 00:00:00 2001 From: Alexander Haase Date: Sun, 16 Feb 2025 20:04:12 +0100 Subject: [PATCH 004/103] Show parent contacts for nested models When contacts of a nested model are displayed, the contacts of the parents are also displayed. --- netbox/tenancy/views.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 0988d2e65..9bb542f82 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -2,6 +2,7 @@ from django.contrib.contenttypes.models import ContentType from django.shortcuts import get_object_or_404 from django.utils.translation import gettext_lazy as _ +from netbox.models import NestedGroupModel from netbox.views import generic from utilities.query import count_related from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view @@ -23,19 +24,18 @@ class ObjectContactsView(generic.ObjectChildrenView): ) def get_children(self, request, parent): - return ContactAssignment.objects.restrict(request.user, 'view').filter( - object_type=ContentType.objects.get_for_model(parent), - object_id=parent.pk - ).order_by('priority', 'contact', 'role') + qs = ContactAssignment.objects.restrict(request.user, 'view') + for obj in [parent]: + qs = qs.filter( + object_type=ContentType.objects.get_for_model(obj), + object_id__in=( + obj.get_ancestors(include_self=True).values_list('pk', flat=True) + if isinstance(obj, NestedGroupModel) + else [obj.pk] + ), + ) - def get_table(self, *args, **kwargs): - table = super().get_table(*args, **kwargs) - - # Hide object columns - table.columns.hide('object_type') - table.columns.hide('object') - - return table + return qs.order_by('priority', 'contact', 'role') # From d5316de9c84daf258b43af002e1f156ac41c18cb Mon Sep 17 00:00:00 2001 From: Alexander Haase Date: Tue, 18 Feb 2025 23:02:57 +0100 Subject: [PATCH 005/103] Move contact queryset into model --- netbox/netbox/models/features.py | 21 +++++++++++++++++++++ netbox/tenancy/views.py | 16 ++-------------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index a97227770..70027a9fc 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -5,6 +5,7 @@ from functools import cached_property from django.contrib.contenttypes.fields import GenericRelation from django.core.validators import ValidationError from django.db import models +from django.db.models import Q from django.utils import timezone from django.utils.translation import gettext_lazy as _ from taggit.managers import TaggableManager @@ -363,6 +364,26 @@ class ContactsMixin(models.Model): class Meta: abstract = True + def get_contacts(self): + """ + Return a `QuerySet` matching all contacts assigned to this object. + """ + from tenancy.models import ContactAssignment + from . import NestedGroupModel + + filter = Q() + for obj in [self]: + filter |= Q( + object_type=ObjectType.objects.get_for_model(obj), + object_id__in=( + obj.get_ancestors(include_self=True).values_list('pk', flat=True) + if isinstance(obj, NestedGroupModel) + else [obj.pk] + ), + ) + + return ContactAssignment.objects.filter(filter) + class BookmarksMixin(models.Model): """ diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 9bb542f82..3b5029bd7 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -2,7 +2,6 @@ from django.contrib.contenttypes.models import ContentType from django.shortcuts import get_object_or_404 from django.utils.translation import gettext_lazy as _ -from netbox.models import NestedGroupModel from netbox.views import generic from utilities.query import count_related from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view @@ -18,24 +17,13 @@ class ObjectContactsView(generic.ObjectChildrenView): template_name = 'tenancy/object_contacts.html' tab = ViewTab( label=_('Contacts'), - badge=lambda obj: obj.contacts.count(), + badge=lambda obj: obj.get_contacts().count(), permission='tenancy.view_contactassignment', weight=5000 ) def get_children(self, request, parent): - qs = ContactAssignment.objects.restrict(request.user, 'view') - for obj in [parent]: - qs = qs.filter( - object_type=ContentType.objects.get_for_model(obj), - object_id__in=( - obj.get_ancestors(include_self=True).values_list('pk', flat=True) - if isinstance(obj, NestedGroupModel) - else [obj.pk] - ), - ) - - return qs.order_by('priority', 'contact', 'role') + return parent.get_contacts().restrict(request.user, 'view').order_by('priority', 'contact', 'role') # From 72adda11974f81fbc0cc9edfc74654009c74b1b9 Mon Sep 17 00:00:00 2001 From: Alexander Haase Date: Tue, 18 Feb 2025 23:08:47 +0100 Subject: [PATCH 006/103] Allow exclusion of inherited contacts --- netbox/netbox/models/features.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 70027a9fc..ba895d5ed 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -364,9 +364,11 @@ class ContactsMixin(models.Model): class Meta: abstract = True - def get_contacts(self): + def get_contacts(self, inherited=True): """ Return a `QuerySet` matching all contacts assigned to this object. + + :param inherited: If `True`, inherited contacts from parent objects are included. """ from tenancy.models import ContactAssignment from . import NestedGroupModel @@ -377,7 +379,7 @@ class ContactsMixin(models.Model): object_type=ObjectType.objects.get_for_model(obj), object_id__in=( obj.get_ancestors(include_self=True).values_list('pk', flat=True) - if isinstance(obj, NestedGroupModel) + if (isinstance(obj, NestedGroupModel) and inherited) else [obj.pk] ), ) From ef89fc1264534539895dd2066b0230226b18dd87 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 20 Feb 2025 14:53:53 -0500 Subject: [PATCH 007/103] Closes #18071: Remvoe legacy staged changes functionality --- docs/models/extras/branch.md | 16 -- docs/models/extras/stagedchange.md | 29 --- docs/plugins/development/staged-changes.md | 39 ---- mkdocs.yml | 3 - netbox/extras/choices.py | 17 -- .../extras/migrations/0123_remove_staging.py | 27 +++ netbox/extras/models/__init__.py | 1 - netbox/extras/models/staging.py | 150 ------------ netbox/netbox/staging.py | 148 ------------ netbox/netbox/tests/test_staging.py | 216 ------------------ 10 files changed, 27 insertions(+), 619 deletions(-) delete mode 100644 docs/models/extras/branch.md delete mode 100644 docs/models/extras/stagedchange.md delete mode 100644 docs/plugins/development/staged-changes.md create mode 100644 netbox/extras/migrations/0123_remove_staging.py delete mode 100644 netbox/extras/models/staging.py delete mode 100644 netbox/netbox/staging.py delete mode 100644 netbox/netbox/tests/test_staging.py diff --git a/docs/models/extras/branch.md b/docs/models/extras/branch.md deleted file mode 100644 index 4599fed85..000000000 --- a/docs/models/extras/branch.md +++ /dev/null @@ -1,16 +0,0 @@ -# Branches - -!!! danger "Deprecated Feature" - This feature has been deprecated in NetBox v4.2 and will be removed in a future release. Please consider using the [netbox-branching plugin](https://github.com/netboxlabs/netbox-branching), which provides much more robust functionality. - -A branch is a collection of related [staged changes](./stagedchange.md) that have been prepared for merging into the active database. A branch can be merged by executing its `commit()` method. Deleting a branch will delete all its related changes. - -## Fields - -### Name - -The branch's name. - -### User - -The user to which the branch belongs (optional). diff --git a/docs/models/extras/stagedchange.md b/docs/models/extras/stagedchange.md deleted file mode 100644 index 0693a32d3..000000000 --- a/docs/models/extras/stagedchange.md +++ /dev/null @@ -1,29 +0,0 @@ -# Staged Changes - -!!! danger "Deprecated Feature" - This feature has been deprecated in NetBox v4.2 and will be removed in a future release. Please consider using the [netbox-branching plugin](https://github.com/netboxlabs/netbox-branching), which provides much more robust functionality. - -A staged change represents the creation of a new object or the modification or deletion of an existing object to be performed at some future point. Each change must be assigned to a [branch](./branch.md). - -Changes can be applied individually via the `apply()` method, however it is recommended to apply changes in bulk using the parent branch's `commit()` method. - -## Fields - -!!! warning - Staged changes are not typically created or manipulated directly, but rather effected through the use of the [`checkout()`](../../plugins/development/staged-changes.md) context manager. - -### Branch - -The [branch](./branch.md) to which this change belongs. - -### Action - -The type of action this change represents: `create`, `update`, or `delete`. - -### Object - -A generic foreign key referencing the existing object to which this change applies. - -### Data - -JSON representation of the changes being made to the object (not applicable for deletions). diff --git a/docs/plugins/development/staged-changes.md b/docs/plugins/development/staged-changes.md deleted file mode 100644 index a8fd1d232..000000000 --- a/docs/plugins/development/staged-changes.md +++ /dev/null @@ -1,39 +0,0 @@ -# Staged Changes - -!!! danger "Deprecated Feature" - This feature has been deprecated in NetBox v4.2 and will be removed in a future release. Please consider using the [netbox-branching plugin](https://github.com/netboxlabs/netbox-branching), which provides much more robust functionality. - -NetBox provides a programmatic API to stage the creation, modification, and deletion of objects without actually committing those changes to the active database. This can be useful for performing a "dry run" of bulk operations, or preparing a set of changes for administrative approval, for example. - -To begin staging changes, first create a [branch](../../models/extras/branch.md): - -```python -from extras.models import Branch - -branch1 = Branch.objects.create(name='branch1') -``` - -Then, activate the branch using the `checkout()` context manager and begin making your changes. This initiates a new database transaction. - -```python -from extras.models import Branch -from netbox.staging import checkout - -branch1 = Branch.objects.get(name='branch1') -with checkout(branch1): - Site.objects.create(name='New Site', slug='new-site') - # ... -``` - -Upon exiting the context, the database transaction is automatically rolled back and your changes recorded as [staged changes](../../models/extras/stagedchange.md). Re-entering a branch will trigger a new database transaction and automatically apply any staged changes associated with the branch. - -To apply the changes within a branch, call the branch's `commit()` method: - -```python -from extras.models import Branch - -branch1 = Branch.objects.get(name='branch1') -branch1.commit() -``` - -Committing a branch is an all-or-none operation: Any exceptions will revert the entire set of changes. After successfully committing a branch, all its associated StagedChange objects are automatically deleted (however the branch itself will remain and can be reused). diff --git a/mkdocs.yml b/mkdocs.yml index db6798eae..a5b2d5355 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -150,7 +150,6 @@ nav: - GraphQL API: 'plugins/development/graphql-api.md' - Background Jobs: 'plugins/development/background-jobs.md' - Dashboard Widgets: 'plugins/development/dashboard-widgets.md' - - Staged Changes: 'plugins/development/staged-changes.md' - Exceptions: 'plugins/development/exceptions.md' - Migrating to v4.0: 'plugins/development/migration-v4.md' - Administration: @@ -226,7 +225,6 @@ nav: - VirtualDeviceContext: 'models/dcim/virtualdevicecontext.md' - Extras: - Bookmark: 'models/extras/bookmark.md' - - Branch: 'models/extras/branch.md' - ConfigContext: 'models/extras/configcontext.md' - ConfigTemplate: 'models/extras/configtemplate.md' - CustomField: 'models/extras/customfield.md' @@ -239,7 +237,6 @@ nav: - Notification: 'models/extras/notification.md' - NotificationGroup: 'models/extras/notificationgroup.md' - SavedFilter: 'models/extras/savedfilter.md' - - StagedChange: 'models/extras/stagedchange.md' - Subscription: 'models/extras/subscription.md' - Tag: 'models/extras/tag.md' - Webhook: 'models/extras/webhook.md' diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 4525d8689..3ecc7e57f 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -212,23 +212,6 @@ class WebhookHttpMethodChoices(ChoiceSet): ) -# -# Staging -# - -class ChangeActionChoices(ChoiceSet): - - ACTION_CREATE = 'create' - ACTION_UPDATE = 'update' - ACTION_DELETE = 'delete' - - CHOICES = ( - (ACTION_CREATE, _('Create'), 'green'), - (ACTION_UPDATE, _('Update'), 'blue'), - (ACTION_DELETE, _('Delete'), 'red'), - ) - - # # Dashboard widgets # diff --git a/netbox/extras/migrations/0123_remove_staging.py b/netbox/extras/migrations/0123_remove_staging.py new file mode 100644 index 000000000..643cd912d --- /dev/null +++ b/netbox/extras/migrations/0123_remove_staging.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.5 on 2025-02-20 19:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0122_charfield_null_choices'), + ] + + operations = [ + migrations.RemoveField( + model_name='stagedchange', + name='branch', + ), + migrations.RemoveField( + model_name='stagedchange', + name='object_type', + ), + migrations.DeleteModel( + name='Branch', + ), + migrations.DeleteModel( + name='StagedChange', + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index e85721034..f214b1268 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -5,5 +5,4 @@ from .models import * from .notifications import * from .scripts import * from .search import * -from .staging import * from .tags import * diff --git a/netbox/extras/models/staging.py b/netbox/extras/models/staging.py deleted file mode 100644 index 68d37de7f..000000000 --- a/netbox/extras/models/staging.py +++ /dev/null @@ -1,150 +0,0 @@ -import logging -import warnings - -from django.contrib.contenttypes.fields import GenericForeignKey -from django.db import models, transaction -from django.utils.translation import gettext_lazy as _ -from mptt.models import MPTTModel - -from extras.choices import ChangeActionChoices -from netbox.models import ChangeLoggedModel -from netbox.models.features import * -from utilities.serialization import deserialize_object - -__all__ = ( - 'Branch', - 'StagedChange', -) - -logger = logging.getLogger('netbox.staging') - - -class Branch(ChangeLoggedModel): - """ - A collection of related StagedChanges. - """ - name = models.CharField( - verbose_name=_('name'), - max_length=100, - unique=True - ) - description = models.CharField( - verbose_name=_('description'), - max_length=200, - blank=True - ) - user = models.ForeignKey( - to='users.User', - on_delete=models.SET_NULL, - blank=True, - null=True - ) - - class Meta: - ordering = ('name',) - verbose_name = _('branch') - verbose_name_plural = _('branches') - - def __init__(self, *args, **kwargs): - warnings.warn( - 'The staged changes functionality has been deprecated and will be removed in a future release.', - DeprecationWarning - ) - super().__init__(*args, **kwargs) - - def __str__(self): - return f'{self.name} ({self.pk})' - - def merge(self): - logger.info(f'Merging changes in branch {self}') - with transaction.atomic(): - for change in self.staged_changes.all(): - change.apply() - self.staged_changes.all().delete() - - -class StagedChange(CustomValidationMixin, EventRulesMixin, models.Model): - """ - The prepared creation, modification, or deletion of an object to be applied to the active database at a - future point. - """ - branch = models.ForeignKey( - to=Branch, - on_delete=models.CASCADE, - related_name='staged_changes' - ) - action = models.CharField( - verbose_name=_('action'), - max_length=20, - choices=ChangeActionChoices - ) - object_type = models.ForeignKey( - to='contenttypes.ContentType', - on_delete=models.CASCADE, - related_name='+' - ) - object_id = models.PositiveBigIntegerField( - blank=True, - null=True - ) - object = GenericForeignKey( - ct_field='object_type', - fk_field='object_id' - ) - data = models.JSONField( - verbose_name=_('data'), - blank=True, - null=True - ) - - class Meta: - ordering = ('pk',) - indexes = ( - models.Index(fields=('object_type', 'object_id')), - ) - verbose_name = _('staged change') - verbose_name_plural = _('staged changes') - - def __init__(self, *args, **kwargs): - warnings.warn( - 'The staged changes functionality has been deprecated and will be removed in a future release.', - DeprecationWarning - ) - super().__init__(*args, **kwargs) - - def __str__(self): - action = self.get_action_display() - app_label, model_name = self.object_type.natural_key() - return f"{action} {app_label}.{model_name} ({self.object_id})" - - @property - def model(self): - return self.object_type.model_class() - - def apply(self): - """ - Apply the staged create/update/delete action to the database. - """ - if self.action == ChangeActionChoices.ACTION_CREATE: - instance = deserialize_object(self.model, self.data, pk=self.object_id) - logger.info(f'Creating {self.model._meta.verbose_name} {instance}') - instance.save() - - if self.action == ChangeActionChoices.ACTION_UPDATE: - instance = deserialize_object(self.model, self.data, pk=self.object_id) - logger.info(f'Updating {self.model._meta.verbose_name} {instance}') - instance.save() - - if self.action == ChangeActionChoices.ACTION_DELETE: - instance = self.model.objects.get(pk=self.object_id) - logger.info(f'Deleting {self.model._meta.verbose_name} {instance}') - instance.delete() - - # Rebuild the MPTT tree where applicable - if issubclass(self.model, MPTTModel): - self.model.objects.rebuild() - - apply.alters_data = True - - def get_action_color(self): - return ChangeActionChoices.colors.get(self.action) diff --git a/netbox/netbox/staging.py b/netbox/netbox/staging.py deleted file mode 100644 index e6b946403..000000000 --- a/netbox/netbox/staging.py +++ /dev/null @@ -1,148 +0,0 @@ -import logging - -from django.contrib.contenttypes.models import ContentType -from django.db import transaction -from django.db.models.signals import m2m_changed, pre_delete, post_save - -from extras.choices import ChangeActionChoices -from extras.models import StagedChange -from utilities.serialization import serialize_object - -logger = logging.getLogger('netbox.staging') - - -class checkout: - """ - Context manager for staging changes to NetBox objects. Staged changes are saved out-of-band - (as Change instances) for application at a later time, without modifying the production - database. - - branch = Branch.objects.create(name='my-branch') - with checkout(branch): - # All changes made herein will be rolled back and stored for later - - Note that invoking the context disabled transaction autocommit to facilitate manual rollbacks, - and restores its original value upon exit. - """ - def __init__(self, branch): - self.branch = branch - self.queue = {} - - def __enter__(self): - - # Disable autocommit to effect a new transaction - logger.debug(f"Entering transaction for {self.branch}") - self._autocommit = transaction.get_autocommit() - transaction.set_autocommit(False) - - # Apply any existing Changes assigned to this Branch - staged_changes = self.branch.staged_changes.all() - if change_count := staged_changes.count(): - logger.debug(f"Applying {change_count} pre-staged changes...") - for change in staged_changes: - change.apply() - else: - logger.debug("No pre-staged changes found") - - # Connect signal handlers - logger.debug("Connecting signal handlers") - post_save.connect(self.post_save_handler) - m2m_changed.connect(self.post_save_handler) - pre_delete.connect(self.pre_delete_handler) - - def __exit__(self, exc_type, exc_val, exc_tb): - - # Disconnect signal handlers - logger.debug("Disconnecting signal handlers") - post_save.disconnect(self.post_save_handler) - m2m_changed.disconnect(self.post_save_handler) - pre_delete.disconnect(self.pre_delete_handler) - - # Roll back the transaction to return the database to its original state - logger.debug("Rolling back database transaction") - transaction.rollback() - logger.debug(f"Restoring autocommit state ({self._autocommit})") - transaction.set_autocommit(self._autocommit) - - # Process queued changes - self.process_queue() - - # - # Queuing - # - - @staticmethod - def get_key_for_instance(instance): - return ContentType.objects.get_for_model(instance), instance.pk - - def process_queue(self): - """ - Create Change instances for all actions stored in the queue. - """ - if not self.queue: - logger.debug("No queued changes; aborting") - return - logger.debug(f"Processing {len(self.queue)} queued changes") - - # Iterate through the in-memory queue, creating Change instances - changes = [] - for key, change in self.queue.items(): - logger.debug(f' {key}: {change}') - object_type, pk = key - action, data = change - - changes.append(StagedChange( - branch=self.branch, - action=action, - object_type=object_type, - object_id=pk, - data=data - )) - - # Save all Change instances to the database - StagedChange.objects.bulk_create(changes) - - # - # Signal handlers - # - - def post_save_handler(self, sender, instance, **kwargs): - """ - Hooks to the post_save signal when a branch is active to queue create and update actions. - """ - key = self.get_key_for_instance(instance) - object_type = instance._meta.verbose_name - - # Creating a new object - if kwargs.get('created'): - logger.debug(f"[{self.branch}] Staging creation of {object_type} {instance} (PK: {instance.pk})") - data = serialize_object(instance, resolve_tags=False) - self.queue[key] = (ChangeActionChoices.ACTION_CREATE, data) - return - - # Ignore pre_* many-to-many actions - if 'action' in kwargs and kwargs['action'] not in ('post_add', 'post_remove', 'post_clear'): - return - - # Object has already been created/updated in the queue; update its queued representation - if key in self.queue: - logger.debug(f"[{self.branch}] Updating staged value for {object_type} {instance} (PK: {instance.pk})") - data = serialize_object(instance, resolve_tags=False) - self.queue[key] = (self.queue[key][0], data) - return - - # Modifying an existing object for the first time - logger.debug(f"[{self.branch}] Staging changes to {object_type} {instance} (PK: {instance.pk})") - data = serialize_object(instance, resolve_tags=False) - self.queue[key] = (ChangeActionChoices.ACTION_UPDATE, data) - - def pre_delete_handler(self, sender, instance, **kwargs): - """ - Hooks to the pre_delete signal when a branch is active to queue delete actions. - """ - key = self.get_key_for_instance(instance) - object_type = instance._meta.verbose_name - - # Delete an existing object - logger.debug(f"[{self.branch}] Staging deletion of {object_type} {instance} (PK: {instance.pk})") - self.queue[key] = (ChangeActionChoices.ACTION_DELETE, None) diff --git a/netbox/netbox/tests/test_staging.py b/netbox/netbox/tests/test_staging.py deleted file mode 100644 index 0a73b2987..000000000 --- a/netbox/netbox/tests/test_staging.py +++ /dev/null @@ -1,216 +0,0 @@ -from django.db.models.signals import post_save -from django.test import TransactionTestCase - -from circuits.models import Provider, Circuit, CircuitType -from extras.choices import ChangeActionChoices -from extras.models import Branch, StagedChange, Tag -from ipam.models import ASN, RIR -from netbox.search.backends import search_backend -from netbox.staging import checkout -from utilities.testing import create_tags - - -class StagingTestCase(TransactionTestCase): - - def setUp(self): - # Disconnect search backend to avoid issues with cached ObjectTypes being deleted - # from the database upon transaction rollback - post_save.disconnect(search_backend.caching_handler) - - create_tags('Alpha', 'Bravo', 'Charlie') - - rir = RIR.objects.create(name='RIR 1', slug='rir-1') - asns = ( - ASN(asn=65001, rir=rir), - ASN(asn=65002, rir=rir), - ASN(asn=65003, rir=rir), - ) - ASN.objects.bulk_create(asns) - - providers = ( - Provider(name='Provider A', slug='provider-a'), - Provider(name='Provider B', slug='provider-b'), - Provider(name='Provider C', slug='provider-c'), - ) - Provider.objects.bulk_create(providers) - - circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') - - Circuit.objects.bulk_create(( - Circuit(provider=providers[0], cid='Circuit A1', type=circuit_type), - Circuit(provider=providers[0], cid='Circuit A2', type=circuit_type), - Circuit(provider=providers[0], cid='Circuit A3', type=circuit_type), - Circuit(provider=providers[1], cid='Circuit B1', type=circuit_type), - Circuit(provider=providers[1], cid='Circuit B2', type=circuit_type), - Circuit(provider=providers[1], cid='Circuit B3', type=circuit_type), - Circuit(provider=providers[2], cid='Circuit C1', type=circuit_type), - Circuit(provider=providers[2], cid='Circuit C2', type=circuit_type), - Circuit(provider=providers[2], cid='Circuit C3', type=circuit_type), - )) - - def test_object_creation(self): - branch = Branch.objects.create(name='Branch 1') - tags = Tag.objects.all() - asns = ASN.objects.all() - - with checkout(branch): - provider = Provider.objects.create(name='Provider D', slug='provider-d') - provider.asns.set(asns) - circuit = Circuit.objects.create(provider=provider, cid='Circuit D1', type=CircuitType.objects.first()) - circuit.tags.set(tags) - - # Sanity-checking - self.assertEqual(Provider.objects.count(), 4) - self.assertListEqual(list(provider.asns.all()), list(asns)) - self.assertEqual(Circuit.objects.count(), 10) - self.assertListEqual(list(circuit.tags.all()), list(tags)) - - # Verify that changes have been rolled back after exiting the context - self.assertEqual(Provider.objects.count(), 3) - self.assertEqual(Circuit.objects.count(), 9) - self.assertEqual(StagedChange.objects.count(), 5) - - # Verify that changes are replayed upon entering the context - with checkout(branch): - self.assertEqual(Provider.objects.count(), 4) - self.assertEqual(Circuit.objects.count(), 10) - provider = Provider.objects.get(name='Provider D') - self.assertListEqual(list(provider.asns.all()), list(asns)) - circuit = Circuit.objects.get(cid='Circuit D1') - self.assertListEqual(list(circuit.tags.all()), list(tags)) - - # Verify that changes are applied and deleted upon branch merge - branch.merge() - self.assertEqual(Provider.objects.count(), 4) - self.assertEqual(Circuit.objects.count(), 10) - provider = Provider.objects.get(name='Provider D') - self.assertListEqual(list(provider.asns.all()), list(asns)) - circuit = Circuit.objects.get(cid='Circuit D1') - self.assertListEqual(list(circuit.tags.all()), list(tags)) - self.assertEqual(StagedChange.objects.count(), 0) - - def test_object_modification(self): - branch = Branch.objects.create(name='Branch 1') - tags = Tag.objects.all() - asns = ASN.objects.all() - - with checkout(branch): - provider = Provider.objects.get(name='Provider A') - provider.name = 'Provider X' - provider.save() - provider.asns.set(asns) - circuit = Circuit.objects.get(cid='Circuit A1') - circuit.cid = 'Circuit X' - circuit.save() - circuit.tags.set(tags) - - # Sanity-checking - self.assertEqual(Provider.objects.count(), 3) - self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider X') - self.assertListEqual(list(provider.asns.all()), list(asns)) - self.assertEqual(Circuit.objects.count(), 9) - self.assertEqual(Circuit.objects.get(pk=circuit.pk).cid, 'Circuit X') - self.assertListEqual(list(circuit.tags.all()), list(tags)) - - # Verify that changes have been rolled back after exiting the context - self.assertEqual(Provider.objects.count(), 3) - self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider A') - provider = Provider.objects.get(pk=provider.pk) - self.assertListEqual(list(provider.asns.all()), []) - self.assertEqual(Circuit.objects.count(), 9) - circuit = Circuit.objects.get(pk=circuit.pk) - self.assertEqual(circuit.cid, 'Circuit A1') - self.assertListEqual(list(circuit.tags.all()), []) - self.assertEqual(StagedChange.objects.count(), 5) - - # Verify that changes are replayed upon entering the context - with checkout(branch): - self.assertEqual(Provider.objects.count(), 3) - self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider X') - provider = Provider.objects.get(pk=provider.pk) - self.assertListEqual(list(provider.asns.all()), list(asns)) - self.assertEqual(Circuit.objects.count(), 9) - circuit = Circuit.objects.get(pk=circuit.pk) - self.assertEqual(circuit.cid, 'Circuit X') - self.assertListEqual(list(circuit.tags.all()), list(tags)) - - # Verify that changes are applied and deleted upon branch merge - branch.merge() - self.assertEqual(Provider.objects.count(), 3) - self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider X') - provider = Provider.objects.get(pk=provider.pk) - self.assertListEqual(list(provider.asns.all()), list(asns)) - self.assertEqual(Circuit.objects.count(), 9) - circuit = Circuit.objects.get(pk=circuit.pk) - self.assertEqual(circuit.cid, 'Circuit X') - self.assertListEqual(list(circuit.tags.all()), list(tags)) - self.assertEqual(StagedChange.objects.count(), 0) - - def test_object_deletion(self): - branch = Branch.objects.create(name='Branch 1') - - with checkout(branch): - provider = Provider.objects.get(name='Provider A') - provider.circuits.all().delete() - provider.delete() - - # Sanity-checking - self.assertEqual(Provider.objects.count(), 2) - self.assertEqual(Circuit.objects.count(), 6) - - # Verify that changes have been rolled back after exiting the context - self.assertEqual(Provider.objects.count(), 3) - self.assertEqual(Circuit.objects.count(), 9) - self.assertEqual(StagedChange.objects.count(), 4) - - # Verify that changes are replayed upon entering the context - with checkout(branch): - self.assertEqual(Provider.objects.count(), 2) - self.assertEqual(Circuit.objects.count(), 6) - - # Verify that changes are applied and deleted upon branch merge - branch.merge() - self.assertEqual(Provider.objects.count(), 2) - self.assertEqual(Circuit.objects.count(), 6) - self.assertEqual(StagedChange.objects.count(), 0) - - def test_exit_enter_context(self): - branch = Branch.objects.create(name='Branch 1') - - with checkout(branch): - - # Create a new object - provider = Provider.objects.create(name='Provider D', slug='provider-d') - provider.save() - - # Check that a create Change was recorded - self.assertEqual(StagedChange.objects.count(), 1) - change = StagedChange.objects.first() - self.assertEqual(change.action, ChangeActionChoices.ACTION_CREATE) - self.assertEqual(change.data['name'], provider.name) - - with checkout(branch): - - # Update the staged object - provider = Provider.objects.get(name='Provider D') - provider.comments = 'New comments' - provider.save() - - # Check that a second Change object has been created for the object - self.assertEqual(StagedChange.objects.count(), 2) - change = StagedChange.objects.last() - self.assertEqual(change.action, ChangeActionChoices.ACTION_UPDATE) - self.assertEqual(change.data['name'], provider.name) - self.assertEqual(change.data['comments'], provider.comments) - - with checkout(branch): - - # Delete the staged object - provider = Provider.objects.get(name='Provider D') - provider.delete() - - # Check that a third Change has recorded the object's deletion - self.assertEqual(StagedChange.objects.count(), 3) - change = StagedChange.objects.last() - self.assertEqual(change.action, ChangeActionChoices.ACTION_DELETE) - self.assertIsNone(change.data) From ca6b686b88ac68550712adf74b4f68cbcac3f8ee Mon Sep 17 00:00:00 2001 From: Alexander Haase Date: Sat, 22 Feb 2025 00:06:44 +0100 Subject: [PATCH 008/103] Limit inherited contacts to model --- netbox/netbox/models/features.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index ba895d5ed..60084c361 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -373,16 +373,14 @@ class ContactsMixin(models.Model): from tenancy.models import ContactAssignment from . import NestedGroupModel - filter = Q() - for obj in [self]: - filter |= Q( - object_type=ObjectType.objects.get_for_model(obj), - object_id__in=( - obj.get_ancestors(include_self=True).values_list('pk', flat=True) - if (isinstance(obj, NestedGroupModel) and inherited) - else [obj.pk] - ), - ) + filter = Q( + object_type=ObjectType.objects.get_for_model(self), + object_id__in=( + self.get_ancestors(include_self=True).values_list('pk', flat=True) + if (isinstance(self, NestedGroupModel) and inherited) + else [self.pk] + ), + ) return ContactAssignment.objects.filter(filter) From 2eaee8bf45d4620ad5b624331a5d37993acbd0f6 Mon Sep 17 00:00:00 2001 From: Tobias Genannt Date: Sun, 16 Feb 2025 09:47:10 +0100 Subject: [PATCH 009/103] Close #18635: Show only the semantic version This modifies the 'netbox-version' to only show the semantic version of Netbox and adds 'netbox-full-version' to show the full version. Related issues: - https://github.com/netbox-community/netbox/issues/15908 - https://github.com/netbox-community/ansible_modules/issues/1381 --- netbox/netbox/api/views.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index d58d1affe..1befda371 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -4,15 +4,15 @@ from django import __version__ as DJANGO_VERSION from django.apps import apps from django.conf import settings from django_rq.queues import get_connection -from drf_spectacular.utils import extend_schema from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.views import APIView from rq.worker import Worker -from netbox.plugins.utils import get_installed_plugins from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired +from netbox.plugins.utils import get_installed_plugins class APIRootView(APIView): @@ -66,7 +66,8 @@ class StatusView(APIView): return Response({ 'django-version': DJANGO_VERSION, 'installed-apps': installed_apps, - 'netbox-version': settings.RELEASE.full_version, + 'netbox-version': settings.RELEASE.version, + 'netbox-full-version': settings.RELEASE.full_version, 'plugins': get_installed_plugins(), 'python-version': platform.python_version(), 'rq-workers-running': Worker.count(get_connection('default')), From 08b2fc424a2d657aa75113805627a26cc09f390c Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Tue, 25 Feb 2025 06:13:30 -0800 Subject: [PATCH 010/103] 18296 Add Tenancy to VLAN Groups (#18690) * 18296 add tenant to vlan groups * 18296 add tenant to vlan groups * 18296 add tenant to vlan groups * 18296 add tenant to vlan groups * 18296 review changes --- netbox/ipam/api/serializers_/vlans.py | 3 +- netbox/ipam/filtersets.py | 2 +- netbox/ipam/forms/bulk_edit.py | 6 +++ netbox/ipam/forms/bulk_import.py | 9 ++++- netbox/ipam/forms/filtersets.py | 3 +- netbox/ipam/forms/model_forms.py | 5 ++- netbox/ipam/graphql/types.py | 1 + .../ipam/migrations/0077_vlangroup_tenant.py | 26 +++++++++++++ netbox/ipam/models/vlans.py | 7 ++++ netbox/ipam/tables/vlans.py | 8 ++-- netbox/ipam/tests/test_filtersets.py | 38 +++++++++++++++++-- netbox/templates/ipam/vlangroup.html | 9 +++++ 12 files changed, 105 insertions(+), 12 deletions(-) create mode 100644 netbox/ipam/migrations/0077_vlangroup_tenant.py diff --git a/netbox/ipam/api/serializers_/vlans.py b/netbox/ipam/api/serializers_/vlans.py index 9b5501dc5..a6f428343 100644 --- a/netbox/ipam/api/serializers_/vlans.py +++ b/netbox/ipam/api/serializers_/vlans.py @@ -37,6 +37,7 @@ class VLANGroupSerializer(NetBoxModelSerializer): scope = serializers.SerializerMethodField(read_only=True) vid_ranges = IntegerRangeSerializer(many=True, required=False) utilization = serializers.CharField(read_only=True) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) # Related object counts vlan_count = RelatedObjectCountField('vlans') @@ -45,7 +46,7 @@ class VLANGroupSerializer(NetBoxModelSerializer): model = VLANGroup fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'vid_ranges', - 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization' + 'tenant', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization' ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count') validators = [] diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 81cbd2ef8..d9507ec2e 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -857,7 +857,7 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet): ) -class VLANGroupFilterSet(OrganizationalModelFilterSet): +class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): scope_type = ContentTypeFilter() region = django_filters.NumberFilter( method='filter_scope' diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 7f3216cfd..f1aa6d845 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -430,11 +430,17 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): label=_('VLAN ID ranges'), required=False ) + tenant = DynamicModelChoiceField( + label=_('Tenant'), + queryset=Tenant.objects.all(), + required=False + ) model = VLANGroup fieldsets = ( FieldSet('site', 'vid_ranges', 'description'), FieldSet('scope_type', 'scope', name=_('Scope')), + FieldSet('tenant', name=_('Tenancy')), ) nullable_fields = ('description', 'scope') diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index c1f2dedd7..85583ca18 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -438,10 +438,17 @@ class VLANGroupImportForm(NetBoxModelImportForm): vid_ranges = NumericRangeArrayField( required=False ) + tenant = CSVModelChoiceField( + label=_('Tenant'), + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text=_('Assigned tenant') + ) class Meta: model = VLANGroup - fields = ('name', 'slug', 'scope_type', 'scope_id', 'vid_ranges', 'description', 'tags') + fields = ('name', 'slug', 'scope_type', 'scope_id', 'vid_ranges', 'tenant', 'description', 'tags') labels = { 'scope_id': 'Scope ID', } diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 3f951512b..f60003c56 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -411,12 +411,13 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) -class VLANGroupFilterForm(NetBoxModelFilterSetForm): +class VLANGroupFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): fieldsets = ( FieldSet('q', 'filter_id', 'tag'), FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')), FieldSet('cluster_group', 'cluster', name=_('Cluster')), FieldSet('contains_vid', name=_('VLANs')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) model = VLANGroup region = DynamicModelMultipleChoiceField( diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index c381f99c9..22f98f6f0 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -598,7 +598,7 @@ class FHRPGroupAssignmentForm(forms.ModelForm): return group -class VLANGroupForm(NetBoxModelForm): +class VLANGroupForm(TenancyForm, NetBoxModelForm): slug = SlugField() vid_ranges = NumericRangeArrayField( label=_('VLAN IDs') @@ -621,12 +621,13 @@ class VLANGroupForm(NetBoxModelForm): FieldSet('name', 'slug', 'description', 'tags', name=_('VLAN Group')), FieldSet('vid_ranges', name=_('Child VLANs')), FieldSet('scope_type', 'scope', name=_('Scope')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: model = VLANGroup fields = [ - 'name', 'slug', 'description', 'vid_ranges', 'scope_type', 'tags', + 'name', 'slug', 'description', 'vid_ranges', 'scope_type', 'tenant_group', 'tenant', 'tags', ] def __init__(self, *args, **kwargs): diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index e6ecca984..b16cf29fe 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -266,6 +266,7 @@ class VLANGroupType(OrganizationalObjectType): vlans: List[VLANType] vid_ranges: List[str] + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None @strawberry_django.field def scope(self) -> Annotated[Union[ diff --git a/netbox/ipam/migrations/0077_vlangroup_tenant.py b/netbox/ipam/migrations/0077_vlangroup_tenant.py new file mode 100644 index 000000000..9fb67cf53 --- /dev/null +++ b/netbox/ipam/migrations/0077_vlangroup_tenant.py @@ -0,0 +1,26 @@ +# Generated by Django 5.1.3 on 2025-02-20 17:49 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0076_natural_ordering'), + ('tenancy', '0017_natural_ordering'), + ] + + operations = [ + migrations.AddField( + model_name='vlangroup', + name='tenant', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='vlan_groups', + to='tenancy.tenant', + ), + ), + ] diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 91e39c6d3..b639fd185 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -62,6 +62,13 @@ class VLANGroup(OrganizationalModel): verbose_name=_('VLAN ID ranges'), default=default_vid_ranges ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='vlan_groups', + blank=True, + null=True + ) _total_vlan_ids = models.PositiveBigIntegerField( default=VLAN_VID_MAX - VLAN_VID_MIN + 1 ) diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index aa1900e41..c22975be0 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -28,7 +28,7 @@ AVAILABLE_LABEL = mark_safe('AvailableUtilization {% utilization_graph object.utilization %} + + {% trans "Tenant" %} + + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} + + {% include 'inc/panels/tags.html' %} From 7e669d1a14e3133d11ca826d3d3158a6c08065c3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 20 Feb 2025 17:02:05 -0500 Subject: [PATCH 011/103] Closes #18072: Remove support for single model registration from PluginTemplateExtension --- netbox/netbox/plugins/registration.py | 12 ++---------- netbox/netbox/plugins/templates.py | 11 ++++++++--- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/netbox/netbox/plugins/registration.py b/netbox/netbox/plugins/registration.py index 515405f1b..0001d50c9 100644 --- a/netbox/netbox/plugins/registration.py +++ b/netbox/netbox/plugins/registration.py @@ -1,7 +1,7 @@ import inspect -import warnings from django.utils.translation import gettext_lazy as _ + from netbox.registry import registry from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem from .templates import PluginTemplateExtension @@ -35,16 +35,8 @@ def register_template_extensions(class_list): ) if template_extension.models: - # Registration for multiple models + # Registration for specific models models = template_extension.models - elif template_extension.model: - # Registration for a single model (deprecated) - warnings.warn( - "PluginTemplateExtension.model is deprecated and will be removed in a future release. Use " - "'models' instead.", - DeprecationWarning - ) - models = [template_extension.model] else: # Global registration (no specific models) models = [None] diff --git a/netbox/netbox/plugins/templates.py b/netbox/netbox/plugins/templates.py index 4ea90b4db..58f9ad80e 100644 --- a/netbox/netbox/plugins/templates.py +++ b/netbox/netbox/plugins/templates.py @@ -11,8 +11,14 @@ class PluginTemplateExtension: This class is used to register plugin content to be injected into core NetBox templates. It contains methods that are overridden by plugin authors to return template content. - The `model` attribute on the class defines the which model detail page this class renders content for. It - should be set as a string in the form '.'. render() provides the following context data: + The `models` attribute on the class defines the which specific model detail pages this class renders content + for. It should be defined as a list of strings in the following form: + + models = ['.', '.'] + + If `models` is left as None, the extension will render for _all_ models. + + The `render()` method provides the following context data: * object - The object being viewed (object views only) * model - The type of object being viewed (list views only) @@ -21,7 +27,6 @@ class PluginTemplateExtension: * config - Plugin-specific configuration parameters """ models = None - model = None # Deprecated; use `models` instead def __init__(self, context): self.context = context From d1712c45bb83f1506d8eef5ec46bb54ebbefc320 Mon Sep 17 00:00:00 2001 From: Mathias Guillemot <84408567+Mathias-gt@users.noreply.github.com> Date: Tue, 25 Feb 2025 23:06:07 +0800 Subject: [PATCH 012/103] Closes: #18434 - Add SPB in L2VPN (#18523) * Add SPB in L2VPN * Change category as Other Co-authored-by: Daniel Sheppard --------- Co-authored-by: Daniel Sheppard --- netbox/vpn/choices.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netbox/vpn/choices.py b/netbox/vpn/choices.py index 9847e1b97..7aea90232 100644 --- a/netbox/vpn/choices.py +++ b/netbox/vpn/choices.py @@ -228,6 +228,7 @@ class L2VPNTypeChoices(ChoiceSet): TYPE_MPLS_EVPN = 'mpls-evpn' TYPE_PBB_EVPN = 'pbb-evpn' TYPE_EVPN_VPWS = 'evpn-vpws' + TYPE_SPB = 'spb' CHOICES = ( ('VPLS', ( @@ -255,6 +256,9 @@ class L2VPNTypeChoices(ChoiceSet): (TYPE_EPTREE, _('Ethernet Private Tree')), (TYPE_EVPTREE, _('Ethernet Virtual Private Tree')), )), + ('Other', ( + (TYPE_SPB, _('SPB')), + )), ) P2P = ( From f7fdf079493d7b6a89caec0b7eb5b95f1c13bc28 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 25 Feb 2025 12:06:44 -0500 Subject: [PATCH 013/103] Closes #17793: Introduce a REST API endpoint for tagged objects (#18679) * Closes #17793: Introduce a REST API endpoint for tagged objects * Add missing object_id filter to TaggedItemFilterSet --- netbox/extras/api/serializers_/tags.py | 44 +++++++++++++++++++- netbox/extras/api/urls.py | 1 + netbox/extras/api/views.py | 9 ++++- netbox/extras/filtersets.py | 36 +++++++++++++++++ netbox/extras/models/tags.py | 2 + netbox/extras/tests/test_api.py | 28 +++++++++++++ netbox/extras/tests/test_filtersets.py | 56 ++++++++++++++++++++++++++ 7 files changed, 173 insertions(+), 3 deletions(-) diff --git a/netbox/extras/api/serializers_/tags.py b/netbox/extras/api/serializers_/tags.py index e4e62845a..ea964ff52 100644 --- a/netbox/extras/api/serializers_/tags.py +++ b/netbox/extras/api/serializers_/tags.py @@ -1,10 +1,16 @@ +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + from core.models import ObjectType -from extras.models import Tag +from extras.models import Tag, TaggedItem +from netbox.api.exceptions import SerializerNotFound from netbox.api.fields import ContentTypeField, RelatedObjectCountField -from netbox.api.serializers import ValidatedModelSerializer +from netbox.api.serializers import BaseModelSerializer, ValidatedModelSerializer +from utilities.api import get_serializer_for_model __all__ = ( 'TagSerializer', + 'TaggedItemSerializer', ) @@ -25,3 +31,37 @@ class TagSerializer(ValidatedModelSerializer): 'tagged_items', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description') + + +class TaggedItemSerializer(BaseModelSerializer): + object_type = ContentTypeField( + source='content_type', + read_only=True + ) + object = serializers.SerializerMethodField( + read_only=True + ) + tag = TagSerializer( + nested=True, + read_only=True + ) + + class Meta: + model = TaggedItem + fields = [ + 'id', 'url', 'display', 'object_type', 'object_id', 'object', 'tag', + ] + brief_fields = ('id', 'url', 'display', 'object_type', 'object_id', 'object', 'tag') + + @extend_schema_field(serializers.JSONField()) + def get_object(self, obj): + """ + Serialize a nested representation of the tagged object. + """ + try: + serializer = get_serializer_for_model(obj.content_object) + except SerializerNotFound: + return obj.object_repr + data = serializer(obj.content_object, nested=True, context={'request': self.context['request']}).data + + return data diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index bbcb8f0ef..88121b640 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -19,6 +19,7 @@ router.register('notifications', views.NotificationViewSet) router.register('notification-groups', views.NotificationGroupViewSet) router.register('subscriptions', views.SubscriptionViewSet) router.register('tags', views.TagViewSet) +router.register('tagged-objects', views.TaggedItemViewSet) router.register('image-attachments', views.ImageAttachmentViewSet) router.register('journal-entries', views.JournalEntryViewSet) router.register('config-contexts', views.ConfigContextViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index e4c3c7f3e..49a44f5f1 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -6,6 +6,7 @@ from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied from rest_framework.generics import RetrieveUpdateDestroyAPIView +from rest_framework.mixins import ListModelMixin, RetrieveModelMixin from rest_framework.renderers import JSONRenderer from rest_framework.response import Response from rest_framework.routers import APIRootView @@ -20,7 +21,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.features import SyncedDataMixin from netbox.api.metadata import ContentTypeMetadata from netbox.api.renderers import TextRenderer -from netbox.api.viewsets import NetBoxModelViewSet +from netbox.api.viewsets import BaseViewSet, NetBoxModelViewSet from utilities.exceptions import RQWorkerNotRunningException from utilities.request import copy_safe_request from . import serializers @@ -172,6 +173,12 @@ class TagViewSet(NetBoxModelViewSet): filterset_class = filtersets.TagFilterSet +class TaggedItemViewSet(RetrieveModelMixin, ListModelMixin, BaseViewSet): + queryset = TaggedItem.objects.prefetch_related('content_type', 'content_object', 'tag') + serializer_class = serializers.TaggedItemSerializer + filterset_class = filtersets.TaggedItemFilterSet + + # # Image attachments # diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 4f40ce500..98302d0f4 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -31,6 +31,7 @@ __all__ = ( 'SavedFilterFilterSet', 'ScriptFilterSet', 'TagFilterSet', + 'TaggedItemFilterSet', 'WebhookFilterSet', ) @@ -492,6 +493,41 @@ class TagFilterSet(ChangeLoggedModelFilterSet): ) +class TaggedItemFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label=_('Search'), + ) + object_type = ContentTypeFilter( + field_name='content_type' + ) + object_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=ContentType.objects.all(), + field_name='content_type_id' + ) + tag_id = django_filters.ModelMultipleChoiceFilter( + queryset=Tag.objects.all() + ) + tag = django_filters.ModelMultipleChoiceFilter( + field_name='tag__slug', + queryset=Tag.objects.all(), + to_field_name='slug', + ) + + class Meta: + model = TaggedItem + fields = ('id', 'object_id') + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(tag__name__icontains=value) | + Q(tag__slug__icontains=value) | + Q(tag__description__icontains=value) + ) + + class ConfigContextFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index d1e329f03..baf72baa1 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -9,6 +9,7 @@ from netbox.choices import ColorChoices from netbox.models import ChangeLoggedModel from netbox.models.features import CloningMixin, ExportTemplatesMixin from utilities.fields import ColorField +from utilities.querysets import RestrictedQuerySet __all__ = ( 'Tag', @@ -72,6 +73,7 @@ class TaggedItem(GenericTaggedItemBase): ) _netbox_private = True + objects = RestrictedQuerySet.as_manager() class Meta: indexes = [models.Index(fields=["content_type", "object_id"])] diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 63baf44d3..17f03350d 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -538,6 +538,34 @@ class TagTest(APIViewTestCases.APIViewTestCase): Tag.objects.bulk_create(tags) +class TaggedItemTest( + APIViewTestCases.GetObjectViewTestCase, + APIViewTestCases.ListObjectsViewTestCase +): + model = TaggedItem + brief_fields = ['display', 'id', 'object', 'object_id', 'object_type', 'tag', 'url'] + + @classmethod + def setUpTestData(cls): + + tags = ( + Tag(name='Tag 1', slug='tag-1'), + Tag(name='Tag 2', slug='tag-2'), + Tag(name='Tag 3', slug='tag-3'), + ) + Tag.objects.bulk_create(tags) + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + sites[0].tags.set([tags[0], tags[1]]) + sites[1].tags.set([tags[1], tags[2]]) + sites[2].tags.set([tags[2], tags[0]]) + + # TODO: Standardize to APIViewTestCase (needs create & update tests) class ImageAttachmentTest( APIViewTestCases.GetObjectViewTestCase, diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index cf914e665..9684b3dbe 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -1250,6 +1250,62 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): ) +class TaggedItemFilterSetTestCase(TestCase): + queryset = TaggedItem.objects.all() + filterset = TaggedItemFilterSet + + @classmethod + def setUpTestData(cls): + tags = ( + Tag(name='Tag 1', slug='tag-1'), + Tag(name='Tag 2', slug='tag-2'), + Tag(name='Tag 3', slug='tag-3'), + ) + Tag.objects.bulk_create(tags) + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + sites[0].tags.add(tags[0]) + sites[1].tags.add(tags[1]) + sites[2].tags.add(tags[2]) + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), + ) + Tenant.objects.bulk_create(tenants) + tenants[0].tags.add(tags[0]) + tenants[1].tags.add(tags[1]) + tenants[2].tags.add(tags[2]) + + def test_tag(self): + tags = Tag.objects.all()[:2] + params = {'tag': [tags[0].slug, tags[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'tag_id': [tags[0].pk, tags[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_object_type(self): + object_type = ObjectType.objects.get_for_model(Site) + params = {'object_type': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'object_type_id': [object_type.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_object_id(self): + site_ids = Site.objects.values_list('pk', flat=True) + params = { + 'object_type': 'dcim.site', + 'object_id': site_ids[:2], + } + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class ChangeLoggedFilterSetTestCase(TestCase): """ Evaluate base ChangeLoggedFilterSet filters using the Site model. From 26c7c8f08deb398202b56163f6d7d2bcf5c5293e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 25 Feb 2025 12:13:53 -0500 Subject: [PATCH 014/103] Closes #18623: Upgrade Tabler to v1.0 (#18624) * Upgrade Tabler to v1.0 * Fix navigation menu colors * Reduce table column heading font size --- netbox/project-static/dist/netbox.css | Bin 554815 -> 554086 bytes netbox/project-static/package.json | 5 +-- netbox/project-static/styles/_variables.scss | 4 --- .../styles/transitional/_navigation.scss | 34 +++++------------- .../styles/transitional/_tables.scss | 5 +++ netbox/project-static/yarn.lock | 18 +++++----- 6 files changed, 26 insertions(+), 40 deletions(-) diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css index 2cb549a0d2013315c376bcc38be6f4856c7d6fec..5dbcf97cf07fb1a97d39e1f74221a09148b10a03 100644 GIT binary patch delta 15150 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 Date: Tue, 25 Feb 2025 18:36:16 +0100 Subject: [PATCH 015/103] Optimize contact lookup query --- netbox/netbox/models/features.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 60084c361..e58037b85 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -376,7 +376,7 @@ class ContactsMixin(models.Model): filter = Q( object_type=ObjectType.objects.get_for_model(self), object_id__in=( - self.get_ancestors(include_self=True).values_list('pk', flat=True) + self.get_ancestors(include_self=True) if (isinstance(self, NestedGroupModel) and inherited) else [self.pk] ), From b9b42cd3b44a6857b2c86f8f566100288abe2573 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 26 Feb 2025 11:28:02 -0600 Subject: [PATCH 016/103] Fixes: #15924 - Prevent API payload from allowing tagged_vlans while interface mode is set to tagged-all (#17211) --- .../api/serializers_/device_components.py | 51 +++++- netbox/dcim/forms/common.py | 18 +- netbox/dcim/models/device_components.py | 2 + netbox/dcim/tests/test_api.py | 70 ++++++++ netbox/dcim/tests/test_forms.py | 166 +++++++++++++++++- 5 files changed, 292 insertions(+), 15 deletions(-) diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index a6767bb6f..b591030aa 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -1,3 +1,4 @@ +from django.utils.translation import gettext as _ from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from rest_framework import serializers @@ -232,8 +233,56 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect def validate(self, data): - # Validate many-to-many VLAN assignments if not self.nested: + + # Validate 802.1q mode and vlan(s) + mode = None + tagged_vlans = [] + + # Gather Information + if self.instance: + mode = data.get('mode') if 'mode' in data.keys() else self.instance.mode + untagged_vlan = data.get('untagged_vlan') if 'untagged_vlan' in data.keys() else \ + self.instance.untagged_vlan + qinq_svlan = data.get('qinq_svlan') if 'qinq_svlan' in data.keys() else \ + self.instance.qinq_svlan + tagged_vlans = data.get('tagged_vlans') if 'tagged_vlans' in data.keys() else \ + self.instance.tagged_vlans.all() + else: + mode = data.get('mode', None) + untagged_vlan = data.get('untagged_vlan') if 'untagged_vlan' in data.keys() else None + qinq_svlan = data.get('qinq_svlan') if 'qinq_svlan' in data.keys() else None + tagged_vlans = data.get('tagged_vlans') if 'tagged_vlans' in data.keys() else None + + errors = {} + + # Non Q-in-Q mode with service vlan set + if mode != InterfaceModeChoices.MODE_Q_IN_Q and qinq_svlan: + errors.update({ + 'qinq_svlan': _("Interface mode does not support q-in-q service vlan") + }) + # Routed mode + if not mode: + # Untagged vlan + if untagged_vlan: + errors.update({ + 'untagged_vlan': _("Interface mode does not support untagged vlan") + }) + # Tagged vlan + if tagged_vlans: + errors.update({ + 'tagged_vlans': _("Interface mode does not support tagged vlans") + }) + # Non-tagged mode + elif mode in (InterfaceModeChoices.MODE_TAGGED_ALL, InterfaceModeChoices.MODE_ACCESS) and tagged_vlans: + errors.update({ + 'tagged_vlans': _("Interface mode does not support tagged vlans") + }) + + if errors: + raise serializers.ValidationError(errors) + + # Validate many-to-many VLAN assignments device = self.instance.device if self.instance else data.get('device') for vlan in data.get('tagged_vlans', []): if vlan.site not in [device.site, None]: diff --git a/netbox/dcim/forms/common.py b/netbox/dcim/forms/common.py index 8ca258f34..23109f66b 100644 --- a/netbox/dcim/forms/common.py +++ b/netbox/dcim/forms/common.py @@ -43,20 +43,14 @@ class InterfaceCommonForm(forms.Form): super().clean() parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine' - tagged_vlans = self.cleaned_data.get('tagged_vlans') - - # Untagged interfaces cannot be assigned tagged VLANs - if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans: - raise forms.ValidationError({ - 'mode': _("An access interface cannot have tagged VLANs assigned.") - }) - - # Remove all tagged VLAN assignments from "tagged all" interfaces - elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL: - self.cleaned_data['tagged_vlans'] = [] + if 'tagged_vlans' in self.fields.keys(): + tagged_vlans = self.cleaned_data.get('tagged_vlans') if self.is_bound else \ + self.get_initial_for_field(self.fields['tagged_vlans'], 'tagged_vlans') + else: + tagged_vlans = [] # Validate tagged VLANs; must be a global VLAN or in the same site - elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans: + if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans: valid_sites = [None, self.cleaned_data[parent_field].site] invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index ce9e5607f..8a8e8f4cc 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -934,6 +934,8 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd raise ValidationError({'rf_channel_width': _("Cannot specify custom width with channel selected.")}) # VLAN validation + if not self.mode and self.untagged_vlan: + raise ValidationError({'untagged_vlan': _("Interface mode does not support an untagged vlan.")}) # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]: diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 99a446aef..08f93f6ea 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1,3 +1,5 @@ +import json + from django.test import override_settings from django.urls import reverse from django.utils.translation import gettext as _ @@ -1748,6 +1750,23 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase }, ] + def _perform_interface_test_with_invalid_data(self, mode: str = None, invalid_data: dict = {}): + device = Device.objects.first() + data = { + 'device': device.pk, + 'name': 'Interface 1', + 'type': InterfaceTypeChoices.TYPE_1GE_FIXED, + } + data.update({'mode': mode}) + data.update(invalid_data) + + response = self.client.post(self._get_list_url(), data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + content = json.loads(response.content) + for key in invalid_data.keys(): + self.assertIn(key, content) + self.assertIsNone(content.get('data')) + def test_bulk_delete_child_interfaces(self): interface1 = Interface.objects.get(name='Interface 1') device = interface1.device @@ -1775,6 +1794,57 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase self.client.delete(self._get_list_url(), data, format='json', **self.header) self.assertEqual(device.interfaces.count(), 2) # Child & parent were both deleted + def test_create_child_interfaces_mode_invalid_data(self): + """ + POST data to test interface mode check and invalid tagged/untagged VLANS. + """ + self.add_permissions('dcim.add_interface') + + vlans = VLAN.objects.all()[0:3] + + # Routed mode, untagged, tagged and qinq service vlan + invalid_data = { + 'untagged_vlan': vlans[0].pk, + 'tagged_vlans': [vlans[1].pk, vlans[2].pk], + 'qinq_svlan': vlans[2].pk + } + self._perform_interface_test_with_invalid_data(None, invalid_data) + + # Routed mode, untagged and tagged vlan + invalid_data = { + 'untagged_vlan': vlans[0].pk, + 'tagged_vlans': [vlans[1].pk, vlans[2].pk], + } + self._perform_interface_test_with_invalid_data(None, invalid_data) + + # Routed mode, untagged vlan + invalid_data = { + 'untagged_vlan': vlans[0].pk, + } + self._perform_interface_test_with_invalid_data(None, invalid_data) + + invalid_data = { + 'tagged_vlans': [vlans[1].pk, vlans[2].pk], + } + # Routed mode, qinq service vlan + self._perform_interface_test_with_invalid_data(None, invalid_data) + # Access mode, tagged vlans + self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_ACCESS, invalid_data) + # All tagged mode, tagged vlans + self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data) + + invalid_data = { + 'qinq_svlan': vlans[0].pk, + } + # Routed mode, qinq service vlan + self._perform_interface_test_with_invalid_data(None, invalid_data) + # Access mode, qinq service vlan + self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_ACCESS, invalid_data) + # Tagged mode, qinq service vlan + self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED, invalid_data) + # Tagged-all mode, qinq service vlan + self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data) + class FrontPortTest(APIViewTestCases.APIViewTestCase): model = FrontPort diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index 7a57bf3f0..89b7508f3 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -1,8 +1,9 @@ from django.test import TestCase -from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices +from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices, InterfaceModeChoices from dcim.forms import * from dcim.models import * +from ipam.models import VLAN from utilities.testing import create_test_device from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -117,11 +118,23 @@ class DeviceTestCase(TestCase): self.assertIn('position', form.errors) -class LabelTestCase(TestCase): +class InterfaceTestCase(TestCase): @classmethod def setUpTestData(cls): cls.device = create_test_device('Device 1') + cls.vlans = ( + VLAN(name='VLAN 1', vid=1), + VLAN(name='VLAN 2', vid=2), + VLAN(name='VLAN 3', vid=3), + ) + VLAN.objects.bulk_create(cls.vlans) + cls.interface = Interface.objects.create( + device=cls.device, + name='Interface 1', + type=InterfaceTypeChoices.TYPE_1GE_GBIC, + mode=InterfaceModeChoices.MODE_TAGGED, + ) def test_interface_label_count_valid(self): """ @@ -151,3 +164,152 @@ class LabelTestCase(TestCase): self.assertFalse(form.is_valid()) self.assertIn('label', form.errors) + + def test_create_interface_mode_valid_data(self): + """ + Test that saving valid interface mode and tagged/untagged vlans works properly + """ + + # Validate access mode + data = { + 'device': self.device.pk, + 'name': 'ethernet1/1', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mode': InterfaceModeChoices.MODE_ACCESS, + 'untagged_vlan': self.vlans[0].pk + } + form = InterfaceCreateForm(data) + + self.assertTrue(form.is_valid()) + + # Validate tagged vlans + data = { + 'device': self.device.pk, + 'name': 'ethernet1/2', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mode': InterfaceModeChoices.MODE_TAGGED, + 'untagged_vlan': self.vlans[0].pk, + 'tagged_vlans': [self.vlans[1].pk, self.vlans[2].pk] + } + form = InterfaceCreateForm(data) + self.assertTrue(form.is_valid()) + + # Validate tagged vlans + data = { + 'device': self.device.pk, + 'name': 'ethernet1/3', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mode': InterfaceModeChoices.MODE_TAGGED_ALL, + 'untagged_vlan': self.vlans[0].pk, + } + form = InterfaceCreateForm(data) + self.assertTrue(form.is_valid()) + + def test_create_interface_mode_access_invalid_data(self): + """ + Test that saving invalid interface mode and tagged/untagged vlans works properly + """ + data = { + 'device': self.device.pk, + 'name': 'ethernet1/4', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mode': InterfaceModeChoices.MODE_ACCESS, + 'untagged_vlan': self.vlans[0].pk, + 'tagged_vlans': [self.vlans[1].pk, self.vlans[2].pk] + } + form = InterfaceCreateForm(data) + + self.assertTrue(form.is_valid()) + self.assertIn('untagged_vlan', form.cleaned_data.keys()) + self.assertNotIn('tagged_vlans', form.cleaned_data.keys()) + self.assertNotIn('qinq_svlan', form.cleaned_data.keys()) + + def test_edit_interface_mode_access_invalid_data(self): + """ + Test that saving invalid interface mode and tagged/untagged vlans works properly + """ + data = { + 'device': self.device.pk, + 'name': 'Ethernet 1/5', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mode': InterfaceModeChoices.MODE_ACCESS, + 'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk] + } + form = InterfaceForm(data, instance=self.interface) + + self.assertTrue(form.is_valid()) + self.assertIn('untagged_vlan', form.cleaned_data.keys()) + self.assertNotIn('tagged_vlans', form.cleaned_data.keys()) + self.assertNotIn('qinq_svlan', form.cleaned_data.keys()) + + def test_create_interface_mode_tagged_all_invalid_data(self): + """ + Test that saving invalid interface mode and tagged/untagged vlans works properly + """ + data = { + 'device': self.device.pk, + 'name': 'ethernet1/6', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mode': InterfaceModeChoices.MODE_TAGGED_ALL, + 'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk] + } + form = InterfaceCreateForm(data) + + self.assertTrue(form.is_valid()) + self.assertIn('untagged_vlan', form.cleaned_data.keys()) + self.assertNotIn('tagged_vlans', form.cleaned_data.keys()) + self.assertNotIn('qinq_svlan', form.cleaned_data.keys()) + + def test_edit_interface_mode_tagged_all_invalid_data(self): + """ + Test that saving invalid interface mode and tagged/untagged vlans works properly + """ + data = { + 'device': self.device.pk, + 'name': 'Ethernet 1/7', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mode': InterfaceModeChoices.MODE_TAGGED_ALL, + 'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk] + } + form = InterfaceForm(data) + self.assertTrue(form.is_valid()) + self.assertIn('untagged_vlan', form.cleaned_data.keys()) + self.assertNotIn('tagged_vlans', form.cleaned_data.keys()) + self.assertNotIn('qinq_svlan', form.cleaned_data.keys()) + + def test_create_interface_mode_routed_invalid_data(self): + """ + Test that saving invalid interface mode (routed) and tagged/untagged vlans works properly + """ + data = { + 'device': self.device.pk, + 'name': 'ethernet1/6', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mode': None, + 'untagged_vlan': self.vlans[0].pk, + 'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk] + } + form = InterfaceCreateForm(data) + + self.assertTrue(form.is_valid()) + self.assertNotIn('untagged_vlan', form.cleaned_data.keys()) + self.assertNotIn('tagged_vlans', form.cleaned_data.keys()) + self.assertNotIn('qinq_svlan', form.cleaned_data.keys()) + + def test_edit_interface_mode_routed_invalid_data(self): + """ + Test that saving invalid interface mode (routed) and tagged/untagged vlans works properly + """ + data = { + 'device': self.device.pk, + 'name': 'Ethernet 1/7', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mode': None, + 'untagged_vlan': self.vlans[0].pk, + 'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk] + } + form = InterfaceForm(data) + self.assertTrue(form.is_valid()) + self.assertNotIn('untagged_vlan', form.cleaned_data.keys()) + self.assertNotIn('tagged_vlans', form.cleaned_data.keys()) + self.assertNotIn('qinq_svlan', form.cleaned_data.keys()) From dbac09349bc752a214ab92e3500a0a32fff68a8d Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Wed, 26 Feb 2025 15:58:46 -0600 Subject: [PATCH 017/103] For #18352, adds choices, model field, migration Adds: - dcim.choices.PowerOutletStatusChoices - dcim.models.device_components.PowerOutlet.status field with `choices` set to PowerOutletStatusChoices - adds migration for PowerOutlet.status field - updates breaking view tests --- netbox/dcim/choices.py | 17 +++++++++++++++++ .../migrations/0201_add_power_outlet_status.py | 16 ++++++++++++++++ netbox/dcim/models/device_components.py | 6 ++++++ netbox/dcim/tests/test_views.py | 3 +++ 4 files changed, 42 insertions(+) create mode 100644 netbox/dcim/migrations/0201_add_power_outlet_status.py diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index c5b6cbcad..8bd41b3d2 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1627,6 +1627,23 @@ class PowerFeedPhaseChoices(ChoiceSet): ) +# +# PowerOutlets +# +class PowerOutletStatusChoices(ChoiceSet): + key = 'PowerOutlet.status' + + STATUS_ENABLED = 'enabled' + STATUS_DISABLED = 'disabled' + STATUS_FAULTY = 'faulty' + + CHOICES = [ + (STATUS_ENABLED, _('Enabled'), 'green'), + (STATUS_DISABLED, _('Disabled'), 'red'), + (STATUS_FAULTY, _('Faulty'), 'gray'), + ] + + # # VDC # diff --git a/netbox/dcim/migrations/0201_add_power_outlet_status.py b/netbox/dcim/migrations/0201_add_power_outlet_status.py new file mode 100644 index 000000000..21fd32186 --- /dev/null +++ b/netbox/dcim/migrations/0201_add_power_outlet_status.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0200_populate_mac_addresses'), + ] + + operations = [ + migrations.AddField( + model_name='poweroutlet', + name='status', + field=models.CharField(default='enabled', max_length=50), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 8a8e8f4cc..49b709944 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -449,6 +449,12 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ + status = models.CharField( + verbose_name=_('status'), + max_length=50, + choices=PowerOutletStatusChoices, + default=PowerOutletStatusChoices.STATUS_ENABLED + ) type = models.CharField( verbose_name=_('type'), max_length=50, diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index b84217882..4dea94c7d 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -2513,6 +2513,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): 'device': device.pk, 'name': 'Power Outlet X', 'type': PowerOutletTypeChoices.TYPE_IEC_C13, + 'status': PowerOutletStatusChoices.STATUS_ENABLED, 'power_port': powerports[1].pk, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, 'description': 'A power outlet', @@ -2523,6 +2524,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): 'device': device.pk, 'name': 'Power Outlet [4-6]', 'type': PowerOutletTypeChoices.TYPE_IEC_C13, + 'status': PowerOutletStatusChoices.STATUS_ENABLED, 'power_port': powerports[1].pk, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, 'description': 'A power outlet', @@ -2531,6 +2533,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_edit_data = { 'type': PowerOutletTypeChoices.TYPE_IEC_C15, + 'status': PowerOutletStatusChoices.STATUS_ENABLED, 'power_port': powerports[1].pk, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, 'description': 'New description', From d9d7955c19ea8b40c2f7121b5ccb23ad3cd8420c Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Wed, 26 Feb 2025 16:33:04 -0600 Subject: [PATCH 018/103] For #18352, adds PowerOutlet.status field to forms and filtersets --- netbox/dcim/filtersets.py | 6 +++++- netbox/dcim/forms/bulk_edit.py | 7 +++++-- netbox/dcim/forms/filtersets.py | 7 ++++++- netbox/dcim/forms/model_forms.py | 4 ++-- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 60c3c4d38..e46730da8 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1591,11 +1591,15 @@ class PowerOutletFilterSet( queryset=PowerPort.objects.all(), label=_('Power port (ID)'), ) + status = django_filters.MultipleChoiceFilter( + choices=PowerOutletStatusChoices, + null_value=None + ) class Meta: model = PowerOutlet fields = ( - 'id', 'name', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end', + 'id', 'name', 'status', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end', ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index da5a45f15..3b9a183cd 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1379,7 +1379,10 @@ class PowerPortBulkEditForm( class PowerOutletBulkEditForm( ComponentBulkEditForm, - form_from_model(PowerOutlet, ['label', 'type', 'color', 'feed_leg', 'power_port', 'mark_connected', 'description']) + form_from_model( + PowerOutlet, + ['label', 'type', 'status', 'color', 'feed_leg', 'power_port', 'mark_connected', 'description'] + ) ): mark_connected = forms.NullBooleanField( label=_('Mark connected'), @@ -1389,7 +1392,7 @@ class PowerOutletBulkEditForm( model = PowerOutlet fieldsets = ( - FieldSet('module', 'type', 'label', 'description', 'mark_connected', 'color'), + FieldSet('module', 'type', 'label', 'status', 'description', 'mark_connected', 'color'), FieldSet('feed_leg', 'power_port', name=_('Power')), ) nullable_fields = ('module', 'label', 'type', 'feed_leg', 'power_port', 'description') diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 37b8afd17..d794c6893 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1305,7 +1305,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerOutlet fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('name', 'label', 'type', 'color', name=_('Attributes')), + FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet( 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', @@ -1323,6 +1323,11 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): label=_('Color'), required=False ) + status = forms.MultipleChoiceField( + label=_('Status'), + choices=PowerOutletStatusChoices, + required=False + ) class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 5a3a27d25..91e23e8b1 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1308,7 +1308,7 @@ class PowerOutletForm(ModularDeviceComponentForm): fieldsets = ( FieldSet( - 'device', 'module', 'name', 'label', 'type', 'color', 'power_port', 'feed_leg', 'mark_connected', + 'device', 'module', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'mark_connected', 'description', 'tags', ), ) @@ -1316,7 +1316,7 @@ class PowerOutletForm(ModularDeviceComponentForm): class Meta: model = PowerOutlet fields = [ - 'device', 'module', 'name', 'label', 'type', 'color', 'power_port', 'feed_leg', 'mark_connected', + 'device', 'module', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'mark_connected', 'description', 'tags', ] From 9556b0c480a55631000ac87c83bbd0cb2bb289f8 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Wed, 26 Feb 2025 16:42:44 -0600 Subject: [PATCH 019/103] Adds status field to PowerOutletSerializer --- netbox/dcim/api/serializers_/device_components.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index b591030aa..8b9cd42df 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -156,10 +156,10 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne class Meta: model = PowerOutlet fields = [ - 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'power_port', - 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', - 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', - 'created', 'last_updated', '_occupied', + 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'status', 'color', + 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', + 'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', + 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') From 1d5c67a0a8f947bcb49b290ae75a3906b7949050 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Wed, 26 Feb 2025 16:44:03 -0600 Subject: [PATCH 020/103] Adds PowerOutlet.status field to PowerOutlet model tables --- netbox/dcim/tables/devices.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index d4f2f74b3..f69c58994 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -530,9 +530,11 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port', 'color', 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items', - 'tags', 'created', 'last_updated', + 'tags', 'created', 'last_updated', 'status', + ) + default_columns = ( + 'pk', 'name', 'device', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'description', ) - default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'power_port', 'feed_leg', 'description') class DevicePowerOutletTable(PowerOutletTable): @@ -550,9 +552,11 @@ class DevicePowerOutletTable(PowerOutletTable): fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'color', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', + 'status', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'color', 'power_port', 'feed_leg', 'description', 'cable', 'connection', + 'pk', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'description', 'cable', + 'connection', ) From f2a09333d7cc72406cffa314ef2ff02b0626f97a Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Wed, 26 Feb 2025 16:44:54 -0600 Subject: [PATCH 021/103] Updates PowerOutletIndex to display status field in results This seemed inline with status fields on other model search indexes --- netbox/dcim/search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index b964421de..5dea2a09b 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -224,7 +224,7 @@ class PowerOutletIndex(SearchIndex): ('label', 200), ('description', 500), ) - display_attrs = ('device', 'label', 'type', 'description') + display_attrs = ('device', 'label', 'type', 'status', 'description') @register_search From 8efcbddb374d4fdc5510555f8b2f9db497d3c7b4 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Wed, 26 Feb 2025 16:51:07 -0600 Subject: [PATCH 022/103] Updates PowetOutler docs to include new status field --- docs/models/dcim/poweroutlet.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/models/dcim/poweroutlet.md b/docs/models/dcim/poweroutlet.md index a99f60b23..22a7ec63e 100644 --- a/docs/models/dcim/poweroutlet.md +++ b/docs/models/dcim/poweroutlet.md @@ -29,6 +29,19 @@ An alternative physical label identifying the power outlet. The type of power outlet. +### Status + +The operational status of the power outlet. By default, the following statuses are available: + +* Enabled +* Disabled +* Faulty + +!!! tip "Custom power outlet statuses" + Additional power outlet statuses may be defined by setting `PowerOutlet.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +!!! info "This field was introduced in NetBox v4.3." + ### Color !!! info "This field was introduced in NetBox v4.2." From 2dcf2d203ca7c0d89b54e4093d7ff2189fdc6c96 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Wed, 26 Feb 2025 17:09:56 -0600 Subject: [PATCH 023/103] Extend filterset/model tests to cover PowerOutlet.status --- netbox/dcim/tests/test_filtersets.py | 20 ++++++++++++++++++++ netbox/dcim/tests/test_models.py | 3 ++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index ede1e2a09..7c9b8adc6 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -3684,6 +3684,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A, description='First', color='ff0000', + status=PowerOutletStatusChoices.STATUS_ENABLED, ), PowerOutlet( device=devices[1], @@ -3693,6 +3694,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF feed_leg=PowerOutletFeedLegChoices.FEED_LEG_B, description='Second', color='00ff00', + status=PowerOutletStatusChoices.STATUS_DISABLED, ), PowerOutlet( device=devices[2], @@ -3702,6 +3704,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C, description='Third', color='0000ff', + status=PowerOutletStatusChoices.STATUS_FAULTY, ), ) PowerOutlet.objects.bulk_create(power_outlets) @@ -3796,6 +3799,23 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_status(self): + params = {'status': [PowerOutletStatusChoices.STATUS_ENABLED]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'status': [PowerOutletStatusChoices.STATUS_DISABLED]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'status': [PowerOutletStatusChoices.STATUS_FAULTY]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'status': [ + PowerOutletStatusChoices.STATUS_ENABLED, + PowerOutletStatusChoices.STATUS_DISABLED, + PowerOutletStatusChoices.STATUS_FAULTY, + ]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = Interface.objects.all() diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index ff1eddd56..bdb07d6d1 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -465,7 +465,8 @@ class DeviceTestCase(TestCase): device=device, name='Power Outlet 1', power_port=powerport, - feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A + feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A, + status=PowerOutletStatusChoices.STATUS_ENABLED, ) self.assertEqual(poweroutlet.cf['cf1'], 'foo') From cf7e2c8dc9b14cf94668eef122b9f78b145fcccd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 27 Feb 2025 09:30:52 -0500 Subject: [PATCH 024/103] Closes #17424: Add custom visibility toggle to ViewTab --- netbox/utilities/views.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index b3334ca87..5a9830918 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -206,22 +206,30 @@ class ViewTab: Args: label: Human-friendly text + visible: A callable which determines whether the tab should be displayed. This callable must accept exactly one + argument: the object instance. If a callable is not specified, the tab's visibility will be determined by + its badge (if any) and the value of `hide_if_empty`. badge: A static value or callable to display alongside the label (optional). If a callable is used, it must accept a single argument representing the object being viewed. weight: Numeric weight to influence ordering among other tabs (default: 1000) permission: The permission required to display the tab (optional). - hide_if_empty: If true, the tab will be displayed only if its badge has a meaningful value. (Tabs without a - badge are always displayed.) + hide_if_empty: If true, the tab will be displayed only if its badge has a meaningful value. (This parameter is + evaluated only if the tab is permitted to be displayed according to the `visible` parameter.) """ - def __init__(self, label, badge=None, weight=1000, permission=None, hide_if_empty=False): + def __init__(self, label, visible=None, badge=None, weight=1000, permission=None, hide_if_empty=False): self.label = label + self.visible = visible self.badge = badge self.weight = weight self.permission = permission self.hide_if_empty = hide_if_empty def render(self, instance): - """Return the attributes needed to render a tab in HTML.""" + """ + Return the attributes needed to render a tab in HTML if the tab should be displayed. Otherwise, return None. + """ + if self.visible is not None and not self.visible(instance): + return None badge_value = self._get_badge_value(instance) if self.badge and self.hide_if_empty and not badge_value: return None From 2ae84ce9fb40ec94cd4b88269417a57bfa513099 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Thu, 27 Feb 2025 15:02:14 -0600 Subject: [PATCH 025/103] Adds initial PowerOutletForm tests --- netbox/dcim/tests/test_forms.py | 54 ++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index 89b7508f3..0067acaaf 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -1,6 +1,8 @@ from django.test import TestCase -from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices, InterfaceModeChoices +from dcim.choices import ( + DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices, InterfaceModeChoices, PowerOutletStatusChoices +) from dcim.forms import * from dcim.models import * from ipam.models import VLAN @@ -12,6 +14,56 @@ def get_id(model, slug): return model.objects.get(slug=slug).id +class PowerOutletFormTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.site = site = Site.objects.create(name='Site 1', slug='site-1') + cls.manufacturer = manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + cls.role = role = DeviceRole.objects.create( + name='Device Role 1', slug='device-role-1', color='ff0000' + ) + cls.device_type = device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1 + ) + cls.rack = rack = Rack.objects.create(name='Rack 1', site=site) + cls.device = Device.objects.create( + name='Device 1', device_type=device_type, role=role, site=site, rack=rack, position=1 + ) + + def test_status_is_required(self): + form = PowerOutletForm(data={ + 'device': self.device, + 'module': None, + 'name': 'New Enabled Outlet', + }) + self.assertFalse(form.is_valid()) + self.assertIn('status', form.errors) + + def test_status_must_be_defined_choice(self): + form = PowerOutletForm(data={ + 'device': self.device, + 'module': None, + 'name': 'New Enabled Outlet', + 'status': 'this isn\'t a defined choice', + }) + self.assertFalse(form.is_valid()) + self.assertIn('status', form.errors) + self.assertTrue(form.errors['status'][-1].startswith('Select a valid choice.')) + + def test_status_recognizes_choices(self): + for index, choice in enumerate(PowerOutletStatusChoices.CHOICES): + form = PowerOutletForm(data={ + 'device': self.device, + 'module': None, + 'name': f'New Enabled Outlet {index + 1}', + 'status': choice[0], + }) + self.assertEqual({}, form.errors) + self.assertTrue(form.is_valid()) + instance = form.save() + self.assertEqual(instance.status, choice[0]) + + class DeviceTestCase(TestCase): @classmethod From 77b98205776efd01ea86846d4882e1415e85841a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Mar 2025 09:29:40 -0500 Subject: [PATCH 026/103] Closes #18287: Enable periodic synchronization for data sources (#18747) * Add sync_interval to DataSource * Enqueue a SyncDataSourceJob when needed after saving a DataSource * Fix logic for clearing pending jobs on interval change * Fix lingering background tasks after modifying DataSource --- docs/models/core/datasource.md | 6 +++++ netbox/core/api/serializers_/data.py | 4 ++-- netbox/core/filtersets.py | 4 ++++ netbox/core/forms/bulk_edit.py | 10 ++++++-- netbox/core/forms/bulk_import.py | 3 ++- netbox/core/forms/filtersets.py | 7 +++++- netbox/core/forms/model_forms.py | 7 ++++-- .../0013_datasource_sync_interval.py | 18 ++++++++++++++ netbox/core/models/data.py | 6 +++++ netbox/core/signals.py | 24 ++++++++++++++++--- netbox/core/tables/data.py | 9 ++++--- netbox/core/tests/test_filtersets.py | 13 +++++++--- netbox/templates/core/datasource.html | 4 ++++ 13 files changed, 98 insertions(+), 17 deletions(-) create mode 100644 netbox/core/migrations/0013_datasource_sync_interval.py diff --git a/docs/models/core/datasource.md b/docs/models/core/datasource.md index 0e18a2aae..527d93939 100644 --- a/docs/models/core/datasource.md +++ b/docs/models/core/datasource.md @@ -44,6 +44,12 @@ A set of rules (one per line) identifying filenames to ignore during synchroniza | `*.txt` | Ignore any files with a `.txt` extension | | `data???.json` | Ignore e.g. `data123.json` | +### Sync Interval + +!!! info "This field was introduced in NetBox v4.3." + +The interval at which the data source should automatically synchronize. If not set, the data source must be synchronized manually. + ### Last Synced The date and time at which the source was most recently synchronized successfully. diff --git a/netbox/core/api/serializers_/data.py b/netbox/core/api/serializers_/data.py index 2c155ba6b..3f2ddb2a0 100644 --- a/netbox/core/api/serializers_/data.py +++ b/netbox/core/api/serializers_/data.py @@ -26,8 +26,8 @@ class DataSourceSerializer(NetBoxModelSerializer): model = DataSource fields = [ 'id', 'url', 'display_url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', - 'parameters', 'ignore_rules', 'comments', 'custom_fields', 'created', 'last_updated', 'last_synced', - 'file_count', + 'sync_interval', 'parameters', 'ignore_rules', 'comments', 'custom_fields', 'created', 'last_updated', + 'last_synced', 'file_count', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py index 21fdaa4ab..42ec22350 100644 --- a/netbox/core/filtersets.py +++ b/netbox/core/filtersets.py @@ -29,6 +29,10 @@ class DataSourceFilterSet(NetBoxModelFilterSet): choices=DataSourceStatusChoices, null_value=None ) + sync_interval = django_filters.MultipleChoiceFilter( + choices=JobIntervalChoices, + null_value=None + ) class Meta: model = DataSource diff --git a/netbox/core/forms/bulk_edit.py b/netbox/core/forms/bulk_edit.py index c1f1fca4d..73618826d 100644 --- a/netbox/core/forms/bulk_edit.py +++ b/netbox/core/forms/bulk_edit.py @@ -1,6 +1,7 @@ from django import forms from django.utils.translation import gettext_lazy as _ +from core.choices import JobIntervalChoices from core.models import * from netbox.forms import NetBoxModelBulkEditForm from netbox.utils import get_data_backend_choices @@ -29,6 +30,11 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + sync_interval = forms.ChoiceField( + choices=JobIntervalChoices, + required=False, + label=_('Sync interval') + ) comments = CommentField() parameters = forms.JSONField( label=_('Parameters'), @@ -42,8 +48,8 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm): model = DataSource fieldsets = ( - FieldSet('type', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules'), + FieldSet('type', 'enabled', 'description', 'sync_interval', 'parameters', 'ignore_rules', 'comments'), ) nullable_fields = ( - 'description', 'description', 'parameters', 'comments', 'parameters', 'ignore_rules', + 'description', 'description', 'sync_interval', 'parameters', 'parameters', 'ignore_rules' 'comments', ) diff --git a/netbox/core/forms/bulk_import.py b/netbox/core/forms/bulk_import.py index 78a859dcb..a5791c945 100644 --- a/netbox/core/forms/bulk_import.py +++ b/netbox/core/forms/bulk_import.py @@ -11,5 +11,6 @@ class DataSourceImportForm(NetBoxModelImportForm): class Meta: model = DataSource fields = ( - 'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules', + 'name', 'type', 'source_url', 'enabled', 'description', 'sync_interval', 'parameters', 'ignore_rules', + 'comments', ) diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index ab4b869b7..4e7286737 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -27,7 +27,7 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm): model = DataSource fieldsets = ( FieldSet('q', 'filter_id'), - FieldSet('type', 'status', name=_('Data Source')), + FieldSet('type', 'status', 'enabled', 'sync_interval', name=_('Data Source')), ) type = forms.MultipleChoiceField( label=_('Type'), @@ -46,6 +46,11 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + sync_interval = forms.ChoiceField( + label=_('Sync interval'), + choices=JobIntervalChoices, + required=False + ) class DataFileFilterForm(NetBoxModelFilterSetForm): diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py index a05377597..0a683a381 100644 --- a/netbox/core/forms/model_forms.py +++ b/netbox/core/forms/model_forms.py @@ -36,7 +36,7 @@ class DataSourceForm(NetBoxModelForm): class Meta: model = DataSource fields = [ - 'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'ignore_rules', 'tags', + 'name', 'type', 'source_url', 'enabled', 'description', 'sync_interval', 'ignore_rules', 'comments', 'tags', ] widgets = { 'ignore_rules': forms.Textarea( @@ -51,7 +51,10 @@ class DataSourceForm(NetBoxModelForm): @property def fieldsets(self): fieldsets = [ - FieldSet('name', 'type', 'source_url', 'enabled', 'description', 'tags', 'ignore_rules', name=_('Source')), + FieldSet( + 'name', 'type', 'source_url', 'description', 'tags', 'ignore_rules', name=_('Source') + ), + FieldSet('enabled', 'sync_interval', name=_('Sync')), ] if self.backend_fields: fieldsets.append( diff --git a/netbox/core/migrations/0013_datasource_sync_interval.py b/netbox/core/migrations/0013_datasource_sync_interval.py new file mode 100644 index 000000000..ec3d2a5d6 --- /dev/null +++ b/netbox/core/migrations/0013_datasource_sync_interval.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-02-26 19:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0012_job_object_type_optional'), + ] + + operations = [ + migrations.AddField( + model_name='datasource', + name='sync_interval', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 39ee8fa57..3f97fb003 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -59,6 +59,12 @@ class DataSource(JobsMixin, PrimaryModel): verbose_name=_('enabled'), default=True ) + sync_interval = models.PositiveSmallIntegerField( + verbose_name=_('sync interval'), + choices=JobIntervalChoices, + blank=True, + null=True + ) ignore_rules = models.TextField( verbose_name=_('ignore rules'), blank=True, diff --git a/netbox/core/signals.py b/netbox/core/signals.py index 06432bf4c..bdaa60f97 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -8,16 +8,15 @@ from django.dispatch import receiver, Signal from django.utils.translation import gettext_lazy as _ from django_prometheus.models import model_deletes, model_inserts, model_updates -from core.choices import ObjectChangeActionChoices +from core.choices import JobStatusChoices, ObjectChangeActionChoices from core.events import * -from core.models import ObjectChange from extras.events import enqueue_event from extras.utils import run_validators from netbox.config import get_config from netbox.context import current_request, events_queue from netbox.models.features import ChangeLoggingMixin from utilities.exceptions import AbortRequest -from .models import ConfigRevision +from .models import ConfigRevision, DataSource, ObjectChange __all__ = ( 'clear_events', @@ -182,6 +181,25 @@ def clear_events_queue(sender, **kwargs): # DataSource handlers # +@receiver(post_save, sender=DataSource) +def enqueue_sync_job(instance, created, **kwargs): + """ + When a DataSource is saved, check its sync_interval and enqueue a sync job if appropriate. + """ + from .jobs import SyncDataSourceJob + + if instance.enabled and instance.sync_interval: + SyncDataSourceJob.enqueue_once(instance, interval=instance.sync_interval) + elif not created: + # Delete any previously scheduled recurring jobs for this DataSource + for job in SyncDataSourceJob.get_jobs(instance).defer('data').filter( + interval__isnull=False, + status=JobStatusChoices.STATUS_SCHEDULED + ): + # Call delete() per instance to ensure the associated background task is deleted as well + job.delete() + + @receiver(post_sync) def auto_sync(instance, **kwargs): """ diff --git a/netbox/core/tables/data.py b/netbox/core/tables/data.py index 4059ea9bc..5d237c689 100644 --- a/netbox/core/tables/data.py +++ b/netbox/core/tables/data.py @@ -25,6 +25,9 @@ class DataSourceTable(NetBoxTable): enabled = columns.BooleanColumn( verbose_name=_('Enabled'), ) + sync_interval = columns.ChoiceFieldColumn( + verbose_name=_('Sync interval'), + ) tags = columns.TagColumn( url_name='core:datasource_list' ) @@ -35,10 +38,10 @@ class DataSourceTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = DataSource fields = ( - 'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters', - 'created', 'last_updated', 'file_count', + 'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'sync_interval', 'comments', + 'parameters', 'created', 'last_updated', 'file_count', ) - default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'file_count') + default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'sync_interval', 'file_count') class DataFileTable(NetBoxTable): diff --git a/netbox/core/tests/test_filtersets.py b/netbox/core/tests/test_filtersets.py index 310be1d0e..b7dfd516e 100644 --- a/netbox/core/tests/test_filtersets.py +++ b/netbox/core/tests/test_filtersets.py @@ -27,7 +27,8 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests): source_url='file:///var/tmp/source1/', status=DataSourceStatusChoices.NEW, enabled=True, - description='foobar1' + description='foobar1', + sync_interval=JobIntervalChoices.INTERVAL_HOURLY ), DataSource( name='Data Source 2', @@ -35,14 +36,16 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests): source_url='file:///var/tmp/source2/', status=DataSourceStatusChoices.SYNCING, enabled=True, - description='foobar2' + description='foobar2', + sync_interval=JobIntervalChoices.INTERVAL_DAILY ), DataSource( name='Data Source 3', type='git', source_url='https://example.com/git/source3', status=DataSourceStatusChoices.COMPLETED, - enabled=False + enabled=False, + sync_interval=JobIntervalChoices.INTERVAL_WEEKLY ), ) DataSource.objects.bulk_create(data_sources) @@ -73,6 +76,10 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'status': [DataSourceStatusChoices.NEW, DataSourceStatusChoices.SYNCING]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_sync_interval(self): + params = {'sync_interval': [JobIntervalChoices.INTERVAL_HOURLY, JobIntervalChoices.INTERVAL_DAILY]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DataFile.objects.all() diff --git a/netbox/templates/core/datasource.html b/netbox/templates/core/datasource.html index a5afedec6..0d56c4087 100644 --- a/netbox/templates/core/datasource.html +++ b/netbox/templates/core/datasource.html @@ -46,6 +46,10 @@ {% trans "Status" %} {% badge object.get_status_display bg_color=object.get_status_color %} + + {% trans "Sync interval" %} + {{ object.get_sync_interval_display|placeholder }} + {% trans "Last synced" %} {{ object.last_synced|placeholder }} From 913405a3ae93ec28b8970a2dbdd81c99508dd557 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Mon, 3 Mar 2025 12:22:34 -0600 Subject: [PATCH 027/103] Adds PowerOutlet.status to detail view Also fixes color display in list table and detail template --- netbox/dcim/models/device_components.py | 3 +++ netbox/dcim/tables/devices.py | 3 +++ netbox/templates/dcim/poweroutlet.html | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 49b709944..6a994d770 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -498,6 +498,9 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki _("Parent power port ({power_port}) must belong to the same device").format(power_port=self.power_port) ) + def get_status_color(self): + return PowerOutletStatusChoices.colors.get(self.status) + # # Interfaces diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index f69c58994..06f6469d3 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -520,6 +520,9 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): verbose_name=_('Power Port'), linkify=True ) + status = columns.ChoiceFieldColumn( + verbose_name=_('Status'), + ) color = columns.ColorColumn() tags = columns.TagColumn( url_name='dcim:poweroutlet_list' diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index 146f6d580..8e44df88e 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -36,6 +36,10 @@ {% trans "Type" %} {{ object.get_type_display }} + + {% trans "Status" %} + {% badge object.get_status_display bg_color=object.get_status_color %} + {% trans "Description" %} {{ object.description|placeholder }} From 4e65117e7c77c3a07b90c448b7af645e41f08c71 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 4 Mar 2025 08:24:54 -0500 Subject: [PATCH 028/103] Closes #18627: Proxy routing (#18681) * Introduce proxy routing * Misc cleanup * Document PROXY_ROUTERS parameter --- docs/configuration/system.md | 14 ++++- netbox/core/data_backends.py | 26 ++++----- netbox/core/jobs.py | 3 +- netbox/core/plugins.py | 6 +- netbox/extras/dashboard/widgets.py | 3 +- .../management/commands/housekeeping.py | 3 +- netbox/extras/webhooks.py | 8 ++- netbox/netbox/settings.py | 13 ++++- netbox/utilities/proxy.py | 55 +++++++++++++++++++ 9 files changed, 108 insertions(+), 23 deletions(-) create mode 100644 netbox/utilities/proxy.py diff --git a/docs/configuration/system.md b/docs/configuration/system.md index af3a6f5e6..81c1a6a94 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -64,7 +64,7 @@ Email is sent from NetBox only for critical events or if configured for [logging ## HTTP_PROXIES -Default: None +Default: Empty A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://requests.readthedocs.io/en/latest/user/advanced/#proxies). For example: @@ -75,6 +75,8 @@ HTTP_PROXIES = { } ``` +If more flexibility is needed in determining which proxy to use for a given request, consider implementing one or more custom proxy routers via the [`PROXY_ROUTERS`](#proxy_routers) parameter. + --- ## INTERNAL_IPS @@ -160,6 +162,16 @@ The file path to the location where media files (such as image attachments) are --- +## PROXY_ROUTERS + +Default: `["utilities.proxy.DefaultProxyRouter"]` + +A list of Python classes responsible for determining which proxy server(s) to use for outbound HTTP requests. Each item in the list can be the class itself or the dotted path to the class. + +The `route()` method on each class must return a dictionary of candidate proxies arranged by protocol (e.g. `http` and/or `https`), or None if no viable proxy can be determined. The default class, `DefaultProxyRouter`, simply returns the content of [`HTTP_PROXIES`](#http_proxies). + +--- + ## REPORTS_ROOT Default: `$INSTALL_ROOT/netbox/reports/` diff --git a/netbox/core/data_backends.py b/netbox/core/data_backends.py index 770a3b258..9ba1d5dfd 100644 --- a/netbox/core/data_backends.py +++ b/netbox/core/data_backends.py @@ -7,13 +7,13 @@ from pathlib import Path from urllib.parse import urlparse from django import forms -from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.utils.translation import gettext as _ from netbox.data_backends import DataBackend from netbox.utils import register_data_backend from utilities.constants import HTTP_PROXY_SUPPORTED_SCHEMAS, HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS +from utilities.proxy import resolve_proxies from utilities.socks import ProxyPoolManager from .exceptions import SyncError @@ -70,18 +70,18 @@ class GitBackend(DataBackend): # Initialize backend config config = ConfigDict() - self.use_socks = False + self.socks_proxy = None # Apply HTTP proxy (if configured) - if settings.HTTP_PROXIES: - if proxy := settings.HTTP_PROXIES.get(self.url_scheme, None): - if urlparse(proxy).scheme not in HTTP_PROXY_SUPPORTED_SCHEMAS: - raise ImproperlyConfigured(f"Unsupported Git DataSource proxy scheme: {urlparse(proxy).scheme}") + proxies = resolve_proxies(url=self.url, context={'client': self}) or {} + if proxy := proxies.get(self.url_scheme): + if urlparse(proxy).scheme not in HTTP_PROXY_SUPPORTED_SCHEMAS: + raise ImproperlyConfigured(f"Unsupported Git DataSource proxy scheme: {urlparse(proxy).scheme}") - if self.url_scheme in ('http', 'https'): - config.set("http", "proxy", proxy) - if urlparse(proxy).scheme in HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS: - self.use_socks = True + if self.url_scheme in ('http', 'https'): + config.set("http", "proxy", proxy) + if urlparse(proxy).scheme in HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS: + self.socks_proxy = proxy return config @@ -98,8 +98,8 @@ class GitBackend(DataBackend): } # check if using socks for proxy - if so need to use custom pool_manager - if self.use_socks: - clone_args['pool_manager'] = ProxyPoolManager(settings.HTTP_PROXIES.get(self.url_scheme)) + if self.socks_proxy: + clone_args['pool_manager'] = ProxyPoolManager(self.socks_proxy) if self.url_scheme in ('http', 'https'): if self.params.get('username'): @@ -147,7 +147,7 @@ class S3Backend(DataBackend): # Initialize backend config return Boto3Config( - proxies=settings.HTTP_PROXIES, + proxies=resolve_proxies(url=self.url, context={'client': self}), ) @contextmanager diff --git a/netbox/core/jobs.py b/netbox/core/jobs.py index 891b1cbdb..b3dfaf1e7 100644 --- a/netbox/core/jobs.py +++ b/netbox/core/jobs.py @@ -5,6 +5,7 @@ import sys from django.conf import settings from netbox.jobs import JobRunner, system_job from netbox.search.backends import search_backend +from utilities.proxy import resolve_proxies from .choices import DataSourceStatusChoices, JobIntervalChoices from .exceptions import SyncError from .models import DataSource @@ -71,7 +72,7 @@ class SystemHousekeepingJob(JobRunner): url=settings.CENSUS_URL, params=census_data, timeout=3, - proxies=settings.HTTP_PROXIES + proxies=resolve_proxies(url=settings.CENSUS_URL) ) except requests.exceptions.RequestException: pass diff --git a/netbox/core/plugins.py b/netbox/core/plugins.py index e6d09711f..d31a699e4 100644 --- a/netbox/core/plugins.py +++ b/netbox/core/plugins.py @@ -11,6 +11,7 @@ from django.core.cache import cache from netbox.plugins import PluginConfig from netbox.registry import registry from utilities.datetime import datetime_from_timestamp +from utilities.proxy import resolve_proxies USER_AGENT_STRING = f'NetBox/{settings.RELEASE.version} {settings.RELEASE.edition}' CACHE_KEY_CATALOG_FEED = 'plugins-catalog-feed' @@ -120,10 +121,11 @@ def get_catalog_plugins(): def get_pages(): # TODO: pagination is currently broken in API payload = {'page': '1', 'per_page': '50'} + proxies = resolve_proxies(url=settings.PLUGIN_CATALOG_URL) first_page = session.get( settings.PLUGIN_CATALOG_URL, headers={'User-Agent': USER_AGENT_STRING}, - proxies=settings.HTTP_PROXIES, + proxies=proxies, timeout=3, params=payload ).json() @@ -135,7 +137,7 @@ def get_catalog_plugins(): next_page = session.get( settings.PLUGIN_CATALOG_URL, headers={'User-Agent': USER_AGENT_STRING}, - proxies=settings.HTTP_PROXIES, + proxies=proxies, timeout=3, params=payload ).json() diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index eeed5414f..c04f8f423 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -17,6 +17,7 @@ from core.models import ObjectType from extras.choices import BookmarkOrderingChoices from utilities.object_types import object_type_identifier, object_type_name from utilities.permissions import get_permission_for_model +from utilities.proxy import resolve_proxies from utilities.querydict import dict_to_querydict from utilities.templatetags.builtins.filters import render_markdown from utilities.views import get_viewname @@ -330,7 +331,7 @@ class RSSFeedWidget(DashboardWidget): response = requests.get( url=self.config['feed_url'], headers={'User-Agent': f'NetBox/{settings.RELEASE.version}'}, - proxies=settings.HTTP_PROXIES, + proxies=resolve_proxies(url=self.config['feed_url'], context={'client': self}), timeout=3 ) response.raise_for_status() diff --git a/netbox/extras/management/commands/housekeeping.py b/netbox/extras/management/commands/housekeeping.py index ade486fc0..ade20a118 100644 --- a/netbox/extras/management/commands/housekeeping.py +++ b/netbox/extras/management/commands/housekeeping.py @@ -11,6 +11,7 @@ from packaging import version from core.models import Job, ObjectChange from netbox.config import Config +from utilities.proxy import resolve_proxies class Command(BaseCommand): @@ -107,7 +108,7 @@ class Command(BaseCommand): response = requests.get( url=settings.RELEASE_CHECK_URL, headers=headers, - proxies=settings.HTTP_PROXIES + proxies=resolve_proxies(url=settings.RELEASE_CHECK_URL) ) response.raise_for_status() diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 889c97ac2..368075217 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -3,10 +3,10 @@ import hmac import logging import requests -from django.conf import settings from django_rq import job from jinja2.exceptions import TemplateError +from utilities.proxy import resolve_proxies from .constants import WEBHOOK_EVENT_TYPES logger = logging.getLogger('netbox.webhooks') @@ -63,9 +63,10 @@ def send_webhook(event_rule, model_name, event_type, data, timestamp, username, raise e # Prepare the HTTP request + url = webhook.render_payload_url(context) params = { 'method': webhook.http_method, - 'url': webhook.render_payload_url(context), + 'url': url, 'headers': headers, 'data': body.encode('utf8'), } @@ -88,7 +89,8 @@ def send_webhook(event_rule, model_name, event_type, data, timestamp, username, session.verify = webhook.ssl_verification if webhook.ca_file_path: session.verify = webhook.ca_file_path - response = session.send(prepared_request, proxies=settings.HTTP_PROXIES) + proxies = resolve_proxies(url=url, context={'client': webhook}) + response = session.send(prepared_request, proxies=proxies) if 200 <= response.status_code <= 299: logger.info(f"Request succeeded; response status {response.status_code}") diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index a17bb7730..0ad46b21e 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -9,6 +9,7 @@ import warnings from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.validators import URLValidator +from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ from netbox.config import PARAMS as CONFIG_PARAMS @@ -116,7 +117,7 @@ EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {}) FILE_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'FILE_UPLOAD_MAX_MEMORY_SIZE', 2621440) GRAPHQL_MAX_ALIASES = getattr(configuration, 'GRAPHQL_MAX_ALIASES', 10) -HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None) +HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', {}) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) ISOLATED_DEPLOYMENT = getattr(configuration, 'ISOLATED_DEPLOYMENT', False) JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {}) @@ -131,6 +132,7 @@ MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media' METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False) PLUGINS = getattr(configuration, 'PLUGINS', []) PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {}) +PROXY_ROUTERS = getattr(configuration, 'PROXY_ROUTERS', ['utilities.proxy.DefaultProxyRouter']) QUEUE_MAPPINGS = getattr(configuration, 'QUEUE_MAPPINGS', {}) REDIS = getattr(configuration, 'REDIS') # Required RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) @@ -201,6 +203,14 @@ if RELEASE_CHECK_URL: "RELEASE_CHECK_URL must be a valid URL. Example: https://api.github.com/repos/netbox-community/netbox" ) +# Validate configured proxy routers +for path in PROXY_ROUTERS: + if type(path) is str: + try: + import_string(path) + except ImportError: + raise ImproperlyConfigured(f"Invalid path in PROXY_ROUTERS: {path}") + # # Database @@ -577,6 +587,7 @@ if SENTRY_ENABLED: sample_rate=SENTRY_SAMPLE_RATE, traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE, send_default_pii=SENTRY_SEND_DEFAULT_PII, + # TODO: Support proxy routing http_proxy=HTTP_PROXIES.get('http') if HTTP_PROXIES else None, https_proxy=HTTP_PROXIES.get('https') if HTTP_PROXIES else None ) diff --git a/netbox/utilities/proxy.py b/netbox/utilities/proxy.py new file mode 100644 index 000000000..8c9e3d196 --- /dev/null +++ b/netbox/utilities/proxy.py @@ -0,0 +1,55 @@ +from django.conf import settings +from django.utils.module_loading import import_string +from urllib.parse import urlparse + +__all__ = ( + 'DefaultProxyRouter', + 'resolve_proxies', +) + + +class DefaultProxyRouter: + """ + Base class for a proxy router. + """ + @staticmethod + def _get_protocol_from_url(url): + """ + Determine the applicable protocol (e.g. HTTP or HTTPS) from the given URL. + """ + return urlparse(url).scheme + + def route(self, url=None, protocol=None, context=None): + """ + Returns the appropriate proxy given a URL or protocol. Arbitrary context data may also be passed where + available. + + Args: + url: The specific request URL for which the proxy will be used (if known) + protocol: The protocol in use (e.g. http or https) (if known) + context: Additional context to aid in proxy selection. May include e.g. the requesting client. + """ + if url and protocol is None: + protocol = self._get_protocol_from_url(url) + if protocol and protocol in settings.HTTP_PROXIES: + return { + protocol: settings.HTTP_PROXIES[protocol] + } + return settings.HTTP_PROXIES + + +def resolve_proxies(url=None, protocol=None, context=None): + """ + Return a dictionary of candidate proxies (compatible with the requests module), or None. + + Args: + url: The specific request URL for which the proxy will be used (optional) + protocol: The protocol in use (e.g. http or https) (optional) + context: Arbitrary additional context to aid in proxy selection (optional) + """ + context = context or {} + + for item in settings.PROXY_ROUTERS: + router = import_string(item) if type(item) is str else item + if proxies := router().route(url=url, protocol=protocol, context=context): + return proxies From 6bc9302ce5ecd9ce008cab9a61f26e0d54e76c34 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Thu, 6 Mar 2025 16:06:06 -0600 Subject: [PATCH 029/103] Closes #17608: Adds L2VPN.status field (#18791) --- docs/models/vpn/l2vpn.md | 13 +++++ netbox/templates/vpn/l2vpn.html | 4 ++ netbox/vpn/api/serializers_/l2vpn.py | 2 +- netbox/vpn/choices.py | 14 +++++ netbox/vpn/filtersets.py | 5 +- netbox/vpn/forms/bulk_edit.py | 6 +- netbox/vpn/forms/bulk_import.py | 5 ++ netbox/vpn/forms/filtersets.py | 7 ++- netbox/vpn/forms/model_forms.py | 6 +- .../vpn/migrations/0008_add_l2vpn_status.py | 16 ++++++ netbox/vpn/models/l2vpn.py | 13 ++++- netbox/vpn/search.py | 2 +- netbox/vpn/tables/l2vpn.py | 9 ++- netbox/vpn/tests/test_api.py | 57 +++++++++++++++++-- netbox/vpn/tests/test_filtersets.py | 12 ++++ netbox/vpn/tests/test_views.py | 23 ++++++-- 16 files changed, 169 insertions(+), 25 deletions(-) create mode 100644 netbox/vpn/migrations/0008_add_l2vpn_status.py diff --git a/docs/models/vpn/l2vpn.md b/docs/models/vpn/l2vpn.md index 1167c1c17..983095ef8 100644 --- a/docs/models/vpn/l2vpn.md +++ b/docs/models/vpn/l2vpn.md @@ -33,6 +33,19 @@ The technology employed in forming and operating the L2VPN. Choices include: !!! note Designating the type as VPWS, EPL, EP-LAN, EP-TREE will limit the L2VPN instance to two terminations. +### Status + +The operational status of the L2VPN. By default, the following statuses are available: + +* Active (default) +* Planned +* Faulty + +!!! tip "Custom L2VPN statuses" + Additional L2VPN statuses may be defined by setting `L2VPN.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +!!! info "This field was introduced in NetBox v4.3." + ### Identifier An optional numeric identifier. This can be used to track a pseudowire ID, for example. diff --git a/netbox/templates/vpn/l2vpn.html b/netbox/templates/vpn/l2vpn.html index 7f64d8086..2a826bc80 100644 --- a/netbox/templates/vpn/l2vpn.html +++ b/netbox/templates/vpn/l2vpn.html @@ -22,6 +22,10 @@ {% trans "Type" %} {{ object.get_type_display }} + + {% trans "Status" %} + {% badge object.get_status_display bg_color=object.get_status_color %} + {% trans "Description" %} {{ object.description|placeholder }} diff --git a/netbox/vpn/api/serializers_/l2vpn.py b/netbox/vpn/api/serializers_/l2vpn.py index c16cbbe1d..2148a81c8 100644 --- a/netbox/vpn/api/serializers_/l2vpn.py +++ b/netbox/vpn/api/serializers_/l2vpn.py @@ -38,7 +38,7 @@ class L2VPNSerializer(NetBoxModelSerializer): class Meta: model = L2VPN fields = [ - 'id', 'url', 'display_url', 'display', 'identifier', 'name', 'slug', 'type', 'import_targets', + 'id', 'url', 'display_url', 'display', 'identifier', 'name', 'slug', 'type', 'status', 'import_targets', 'export_targets', 'description', 'comments', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated' ] brief_fields = ('id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'description') diff --git a/netbox/vpn/choices.py b/netbox/vpn/choices.py index 7aea90232..db03e48f8 100644 --- a/netbox/vpn/choices.py +++ b/netbox/vpn/choices.py @@ -267,3 +267,17 @@ class L2VPNTypeChoices(ChoiceSet): TYPE_EPLAN, TYPE_EPTREE ) + + +class L2VPNStatusChoices(ChoiceSet): + key = 'L2VPN.status' + + STATUS_ACTIVE = 'active' + STATUS_PLANNED = 'planned' + STATUS_DECOMMISSIONING = 'decommissioning' + + CHOICES = [ + (STATUS_ACTIVE, _('Active'), 'green'), + (STATUS_PLANNED, _('Planned'), 'cyan'), + (STATUS_DECOMMISSIONING, _('Decommissioning'), 'red'), + ] diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py index 6403b662f..d7d06f991 100644 --- a/netbox/vpn/filtersets.py +++ b/netbox/vpn/filtersets.py @@ -298,6 +298,9 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): choices=L2VPNTypeChoices, null_value=None ) + status = django_filters.MultipleChoiceFilter( + choices=L2VPNStatusChoices, + ) import_target_id = django_filters.ModelMultipleChoiceFilter( field_name='import_targets', queryset=RouteTarget.objects.all(), @@ -323,7 +326,7 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = L2VPN - fields = ('id', 'identifier', 'name', 'slug', 'type', 'description') + fields = ('id', 'identifier', 'name', 'slug', 'status', 'type', 'description') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/vpn/forms/bulk_edit.py b/netbox/vpn/forms/bulk_edit.py index a7595a2a7..700dadb70 100644 --- a/netbox/vpn/forms/bulk_edit.py +++ b/netbox/vpn/forms/bulk_edit.py @@ -260,6 +260,10 @@ class IPSecProfileBulkEditForm(NetBoxModelBulkEditForm): class L2VPNBulkEditForm(NetBoxModelBulkEditForm): + status = forms.ChoiceField( + label=_('Status'), + choices=L2VPNStatusChoices, + ) type = forms.ChoiceField( label=_('Type'), choices=add_blank_choice(L2VPNTypeChoices), @@ -279,7 +283,7 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm): model = L2VPN fieldsets = ( - FieldSet('type', 'tenant', 'description'), + FieldSet('status', 'type', 'tenant', 'description'), ) nullable_fields = ('tenant', 'description', 'comments') diff --git a/netbox/vpn/forms/bulk_import.py b/netbox/vpn/forms/bulk_import.py index b8d19bb38..925558e60 100644 --- a/netbox/vpn/forms/bulk_import.py +++ b/netbox/vpn/forms/bulk_import.py @@ -260,6 +260,11 @@ class L2VPNImportForm(NetBoxModelImportForm): required=False, to_field_name='name', ) + status = CSVChoiceField( + label=_('Status'), + choices=L2VPNStatusChoices, + help_text=_('Operational status') + ) type = CSVChoiceField( label=_('Type'), choices=L2VPNTypeChoices, diff --git a/netbox/vpn/forms/filtersets.py b/netbox/vpn/forms/filtersets.py index 10dc441e2..5503166f0 100644 --- a/netbox/vpn/forms/filtersets.py +++ b/netbox/vpn/forms/filtersets.py @@ -210,9 +210,14 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = L2VPN fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('type', 'import_target_id', 'export_target_id', name=_('Attributes')), + FieldSet('type', 'status', 'import_target_id', 'export_target_id', name=_('Attributes')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) + status = forms.MultipleChoiceField( + label=_('Status'), + choices=L2VPNStatusChoices, + required=False + ) type = forms.ChoiceField( label=_('Type'), choices=add_blank_choice(L2VPNTypeChoices), diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index d6d02b4f5..1bf5b580c 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -409,7 +409,7 @@ class L2VPNForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - FieldSet('name', 'slug', 'type', 'identifier', 'description', 'tags', name=_('L2VPN')), + FieldSet('name', 'slug', 'type', 'status', 'identifier', 'description', 'tags', name=_('L2VPN')), FieldSet('import_targets', 'export_targets', name=_('Route Targets')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) @@ -417,8 +417,8 @@ class L2VPNForm(TenancyForm, NetBoxModelForm): class Meta: model = L2VPN fields = ( - 'name', 'slug', 'type', 'identifier', 'import_targets', 'export_targets', 'tenant', 'description', - 'comments', 'tags' + 'name', 'slug', 'type', 'status', 'identifier', 'import_targets', 'export_targets', 'tenant', + 'description', 'comments', 'tags' ) diff --git a/netbox/vpn/migrations/0008_add_l2vpn_status.py b/netbox/vpn/migrations/0008_add_l2vpn_status.py new file mode 100644 index 000000000..8b0267e45 --- /dev/null +++ b/netbox/vpn/migrations/0008_add_l2vpn_status.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0007_natural_ordering'), + ] + + operations = [ + migrations.AddField( + model_name='l2vpn', + name='status', + field=models.CharField(default='active', max_length=50), + ), + ] diff --git a/netbox/vpn/models/l2vpn.py b/netbox/vpn/models/l2vpn.py index 3e562531d..575f6e234 100644 --- a/netbox/vpn/models/l2vpn.py +++ b/netbox/vpn/models/l2vpn.py @@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _ from core.models import ObjectType from netbox.models import NetBoxModel, PrimaryModel from netbox.models.features import ContactsMixin -from vpn.choices import L2VPNTypeChoices +from vpn.choices import L2VPNStatusChoices, L2VPNTypeChoices from vpn.constants import L2VPN_ASSIGNMENT_MODELS __all__ = ( @@ -33,6 +33,12 @@ class L2VPN(ContactsMixin, PrimaryModel): max_length=50, choices=L2VPNTypeChoices ) + status = models.CharField( + verbose_name=_('status'), + max_length=50, + choices=L2VPNStatusChoices, + default=L2VPNStatusChoices.STATUS_ACTIVE, + ) identifier = models.BigIntegerField( verbose_name=_('identifier'), null=True, @@ -56,7 +62,7 @@ class L2VPN(ContactsMixin, PrimaryModel): null=True ) - clone_fields = ('type',) + clone_fields = ('type', 'status') class Meta: ordering = ('name', 'identifier') @@ -68,6 +74,9 @@ class L2VPN(ContactsMixin, PrimaryModel): return f'{self.name} ({self.identifier})' return f'{self.name}' + def get_status_color(self): + return L2VPNStatusChoices.colors.get(self.status) + @cached_property def can_add_termination(self): if self.type in L2VPNTypeChoices.P2P and self.terminations.count() >= 2: diff --git a/netbox/vpn/search.py b/netbox/vpn/search.py index c1914dc22..07ab9a5ca 100644 --- a/netbox/vpn/search.py +++ b/netbox/vpn/search.py @@ -79,4 +79,4 @@ class L2VPNIndex(SearchIndex): ('description', 500), ('comments', 5000), ) - display_attrs = ('type', 'identifier', 'tenant', 'description') + display_attrs = ('type', 'status', 'identifier', 'tenant', 'description') diff --git a/netbox/vpn/tables/l2vpn.py b/netbox/vpn/tables/l2vpn.py index 9a614ab98..95586461e 100644 --- a/netbox/vpn/tables/l2vpn.py +++ b/netbox/vpn/tables/l2vpn.py @@ -23,6 +23,9 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable): verbose_name=_('Name'), linkify=True ) + status = columns.ChoiceFieldColumn( + verbose_name=_('Status') + ) import_targets = columns.TemplateColumn( verbose_name=_('Import Targets'), template_code=L2VPN_TARGETS, @@ -43,10 +46,10 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = L2VPN fields = ( - 'pk', 'name', 'slug', 'identifier', 'type', 'import_targets', 'export_targets', 'tenant', 'tenant_group', - 'description', 'comments', 'tags', 'created', 'last_updated', + 'pk', 'name', 'slug', 'status', 'identifier', 'type', 'import_targets', 'export_targets', 'tenant', + 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'identifier', 'type', 'description') + default_columns = ('pk', 'name', 'status', 'identifier', 'type', 'description') class L2VPNTerminationTable(NetBoxTable): diff --git a/netbox/vpn/tests/test_api.py b/netbox/vpn/tests/test_api.py index f2d43718f..19fdf1136 100644 --- a/netbox/vpn/tests/test_api.py +++ b/netbox/vpn/tests/test_api.py @@ -1,4 +1,5 @@ from django.urls import reverse +from rest_framework import status from dcim.choices import InterfaceTypeChoices from dcim.models import Interface @@ -527,19 +528,22 @@ class L2VPNTest(APIViewTestCases.APIViewTestCase): 'name': 'L2VPN 4', 'slug': 'l2vpn-4', 'type': 'vxlan', - 'identifier': 33343344 + 'identifier': 33343344, + 'status': L2VPNStatusChoices.STATUS_ACTIVE, }, { 'name': 'L2VPN 5', 'slug': 'l2vpn-5', 'type': 'vxlan', - 'identifier': 33343345 + 'identifier': 33343345, + 'status': L2VPNStatusChoices.STATUS_PLANNED, }, { 'name': 'L2VPN 6', 'slug': 'l2vpn-6', 'type': 'vpws', - 'identifier': 33343346 + 'identifier': 33343346, + 'status': L2VPNStatusChoices.STATUS_DECOMMISSIONING, }, ] bulk_update_data = { @@ -550,12 +554,53 @@ class L2VPNTest(APIViewTestCases.APIViewTestCase): def setUpTestData(cls): l2vpns = ( - L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001), - L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002), - L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD + L2VPN( + name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001, + status=L2VPNStatusChoices.STATUS_ACTIVE, + ), + L2VPN( + name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002, + status=L2VPNStatusChoices.STATUS_PLANNED, + ), + L2VPN( + name='L2VPN 3', slug='l2vpn-3', type='vpls', + status=L2VPNStatusChoices.STATUS_DECOMMISSIONING, + ), # No RD ) L2VPN.objects.bulk_create(l2vpns) + def test_status_filter(self): + url = reverse('vpn-api:l2vpn-list') + + self.add_permissions('vpn.view_l2vpn') + response = self.client.get(url, **self.header) + response_data = response.json() + + # all L2VPNs present with not filter + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response_data['count'], 3) + + # 1 L2VPN present with active status filter + filter_url = f'{url}?status={L2VPNStatusChoices.STATUS_ACTIVE}' + response = self.client.get(filter_url, **self.header) + response_data = response.json() + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response_data['count'], 1) + + # 2 L2VPNs present with active and planned status filter + filter_url = f'{filter_url}&status={L2VPNStatusChoices.STATUS_PLANNED}' + response = self.client.get(filter_url, **self.header) + response_data = response.json() + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response_data['count'], 2) + + # 1 L2VPN present with decommissioning status filter + filter_url = f'{url}?status={L2VPNStatusChoices.STATUS_DECOMMISSIONING}' + response = self.client.get(filter_url, **self.header) + response_data = response.json() + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response_data['count'], 1) + class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase): model = L2VPNTermination diff --git a/netbox/vpn/tests/test_filtersets.py b/netbox/vpn/tests/test_filtersets.py index d2b893766..ee1f9ca72 100644 --- a/netbox/vpn/tests/test_filtersets.py +++ b/netbox/vpn/tests/test_filtersets.py @@ -769,6 +769,7 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, + status=L2VPNStatusChoices.STATUS_ACTIVE, identifier=65001, description='foobar1' ), @@ -776,6 +777,7 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VPWS, + status=L2VPNStatusChoices.STATUS_PLANNED, identifier=65002, description='foobar2' ), @@ -783,6 +785,7 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VPLS, + status=L2VPNStatusChoices.STATUS_DECOMMISSIONING, description='foobar3' ), ) @@ -814,6 +817,15 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'type': [L2VPNTypeChoices.TYPE_VXLAN, L2VPNTypeChoices.TYPE_VPWS]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_status(self): + self.assertEqual(self.filterset({}, self.queryset).qs.count(), 3) + + params = {'status': [L2VPNStatusChoices.STATUS_ACTIVE, L2VPNStatusChoices.STATUS_PLANNED]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + params = {'status': [L2VPNStatusChoices.STATUS_DECOMMISSIONING]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_description(self): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/vpn/tests/test_views.py b/netbox/vpn/tests/test_views.py index 05ac527fe..6d2239169 100644 --- a/netbox/vpn/tests/test_views.py +++ b/netbox/vpn/tests/test_views.py @@ -574,16 +574,25 @@ class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): RouteTarget.objects.bulk_create(rts) l2vpns = ( - L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650001'), - L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650002'), - L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650003') + L2VPN( + name='L2VPN 1', slug='l2vpn-1', status=L2VPNStatusChoices.STATUS_ACTIVE, + type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650001' + ), + L2VPN( + name='L2VPN 2', slug='l2vpn-2', status=L2VPNStatusChoices.STATUS_DECOMMISSIONING, + type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650002' + ), + L2VPN( + name='L2VPN 3', slug='l2vpn-3', status=L2VPNStatusChoices.STATUS_PLANNED, + type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650003' + ) ) L2VPN.objects.bulk_create(l2vpns) cls.csv_data = ( - 'name,slug,type,identifier', - 'L2VPN 5,l2vpn-5,vxlan,456', - 'L2VPN 6,l2vpn-6,vxlan,444', + 'name,status,slug,type,identifier', + 'L2VPN 5,active,l2vpn-5,vxlan,456', + 'L2VPN 6,planned,l2vpn-6,vxlan,444', ) cls.csv_update_data = ( @@ -594,12 +603,14 @@ class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.bulk_edit_data = { 'description': 'New Description', + 'status': L2VPNStatusChoices.STATUS_DECOMMISSIONING, } cls.form_data = { 'name': 'L2VPN 8', 'slug': 'l2vpn-8', 'type': L2VPNTypeChoices.TYPE_VXLAN, + 'status': L2VPNStatusChoices.STATUS_PLANNED, 'identifier': 123, 'description': 'Description', 'import_targets': [rts[0].pk], From bbf4eea76c8a77db070763a5b7e63221842c23f1 Mon Sep 17 00:00:00 2001 From: bctiemann Date: Fri, 7 Mar 2025 13:20:34 -0500 Subject: [PATCH 030/103] Fixes: #18808 - Fix incorrect dependencies on squashed migrations (#18827) --- netbox/account/migrations/0001_initial.py | 2 +- netbox/circuits/migrations/0002_squashed_0029.py | 8 ++++---- netbox/circuits/migrations/0038_squashed_0042.py | 4 ++-- netbox/circuits/migrations/0043_circuittype_color.py | 2 +- .../0006_datasource_type_remove_choices.py | 2 +- netbox/dcim/migrations/0002_squashed.py | 6 +++--- netbox/dcim/migrations/0003_squashed_0130.py | 10 +++++----- netbox/dcim/migrations/0131_squashed_0159.py | 8 ++++---- netbox/dcim/migrations/0160_squashed_0166.py | 6 +++--- netbox/dcim/migrations/0167_squashed_0182.py | 6 +++--- .../0183_devicetype_exclude_from_utilization.py | 2 +- netbox/extras/migrations/0002_squashed_0059.py | 8 ++++---- netbox/extras/migrations/0060_squashed_0086.py | 12 ++++++------ netbox/extras/migrations/0087_squashed_0098.py | 4 ++-- .../extras/migrations/0099_cachedvalue_ordering.py | 2 +- netbox/ipam/migrations/0001_squashed.py | 6 +++--- netbox/ipam/migrations/0002_squashed_0046.py | 10 +++++----- netbox/ipam/migrations/0047_squashed_0053.py | 2 +- netbox/ipam/migrations/0054_squashed_0067.py | 6 +++--- netbox/ipam/migrations/0068_move_l2vpn.py | 2 +- netbox/tenancy/migrations/0001_squashed_0012.py | 2 +- .../0012_contactassignment_custom_fields.py | 2 +- .../virtualization/migrations/0001_squashed_0022.py | 8 ++++---- .../virtualization/migrations/0023_squashed_0036.py | 4 ++-- .../migrations/0037_protect_child_interfaces.py | 2 +- netbox/vpn/migrations/0001_initial.py | 2 +- netbox/wireless/migrations/0001_squashed_0008.py | 2 +- 27 files changed, 65 insertions(+), 65 deletions(-) diff --git a/netbox/account/migrations/0001_initial.py b/netbox/account/migrations/0001_initial.py index 72c079565..badd459ca 100644 --- a/netbox/account/migrations/0001_initial.py +++ b/netbox/account/migrations/0001_initial.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('users', '0004_netboxgroup_netboxuser'), + ('users', '0002_squashed_0004'), ] operations = [ diff --git a/netbox/circuits/migrations/0002_squashed_0029.py b/netbox/circuits/migrations/0002_squashed_0029.py index cb61d8feb..0062575cd 100644 --- a/netbox/circuits/migrations/0002_squashed_0029.py +++ b/netbox/circuits/migrations/0002_squashed_0029.py @@ -5,11 +5,11 @@ import taggit.managers class Migration(migrations.Migration): dependencies = [ - ('dcim', '0001_initial'), + ('dcim', '0001_squashed'), ('contenttypes', '0002_remove_content_type_name'), - ('circuits', '0001_initial'), - ('extras', '0001_initial'), - ('tenancy', '0001_initial'), + ('circuits', '0001_squashed'), + ('extras', '0001_squashed'), + ('tenancy', '0001_squashed_0012'), ] replaces = [ diff --git a/netbox/circuits/migrations/0038_squashed_0042.py b/netbox/circuits/migrations/0038_squashed_0042.py index fa944b763..be07638b4 100644 --- a/netbox/circuits/migrations/0038_squashed_0042.py +++ b/netbox/circuits/migrations/0038_squashed_0042.py @@ -15,8 +15,8 @@ class Migration(migrations.Migration): ] dependencies = [ - ('circuits', '0037_new_cabling_models'), - ('dcim', '0160_populate_cable_ends'), + ('circuits', '0003_squashed_0037'), + ('dcim', '0160_squashed_0166'), ] operations = [ diff --git a/netbox/circuits/migrations/0043_circuittype_color.py b/netbox/circuits/migrations/0043_circuittype_color.py index 6c4dffeb6..400c419ef 100644 --- a/netbox/circuits/migrations/0043_circuittype_color.py +++ b/netbox/circuits/migrations/0043_circuittype_color.py @@ -6,7 +6,7 @@ import utilities.fields class Migration(migrations.Migration): dependencies = [ - ('circuits', '0042_provideraccount'), + ('circuits', '0038_squashed_0042'), ] operations = [ diff --git a/netbox/core/migrations/0006_datasource_type_remove_choices.py b/netbox/core/migrations/0006_datasource_type_remove_choices.py index 7c9914298..6a7fe2521 100644 --- a/netbox/core/migrations/0006_datasource_type_remove_choices.py +++ b/netbox/core/migrations/0006_datasource_type_remove_choices.py @@ -5,7 +5,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('core', '0005_job_created_auto_now'), + ('core', '0001_squashed_0005'), ] operations = [ diff --git a/netbox/dcim/migrations/0002_squashed.py b/netbox/dcim/migrations/0002_squashed.py index 2e830560f..ae1966e58 100644 --- a/netbox/dcim/migrations/0002_squashed.py +++ b/netbox/dcim/migrations/0002_squashed.py @@ -7,11 +7,11 @@ import taggit.managers class Migration(migrations.Migration): dependencies = [ - ('dcim', '0001_initial'), + ('dcim', '0001_squashed'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('contenttypes', '0002_remove_content_type_name'), - ('extras', '0001_initial'), - ('tenancy', '0001_initial'), + ('extras', '0001_squashed'), + ('tenancy', '0001_squashed_0012'), ] replaces = [ diff --git a/netbox/dcim/migrations/0003_squashed_0130.py b/netbox/dcim/migrations/0003_squashed_0130.py index 0248d9ba1..330a64e63 100644 --- a/netbox/dcim/migrations/0003_squashed_0130.py +++ b/netbox/dcim/migrations/0003_squashed_0130.py @@ -5,12 +5,12 @@ import taggit.managers class Migration(migrations.Migration): dependencies = [ - ('dcim', '0002_auto_20160622_1821'), - ('virtualization', '0001_virtualization'), + ('dcim', '0002_squashed'), + ('virtualization', '0001_squashed_0022'), ('contenttypes', '0002_remove_content_type_name'), - ('ipam', '0001_initial'), - ('tenancy', '0001_initial'), - ('extras', '0002_custom_fields'), + ('ipam', '0001_squashed'), + ('tenancy', '0001_squashed_0012'), + ('extras', '0002_squashed_0059'), ] replaces = [ diff --git a/netbox/dcim/migrations/0131_squashed_0159.py b/netbox/dcim/migrations/0131_squashed_0159.py index 3866e8cc8..7c2ef7006 100644 --- a/netbox/dcim/migrations/0131_squashed_0159.py +++ b/netbox/dcim/migrations/0131_squashed_0159.py @@ -43,12 +43,12 @@ class Migration(migrations.Migration): ] dependencies = [ - ('tenancy', '0012_standardize_models'), + ('tenancy', '0001_squashed_0012'), ('extras', '0002_squashed_0059'), - ('dcim', '0130_sitegroup'), + ('dcim', '0003_squashed_0130'), ('contenttypes', '0002_remove_content_type_name'), - ('ipam', '0053_asn_model'), - ('wireless', '0001_wireless'), + ('ipam', '0047_squashed_0053'), + ('wireless', '0001_squashed_0008'), ] operations = [ diff --git a/netbox/dcim/migrations/0160_squashed_0166.py b/netbox/dcim/migrations/0160_squashed_0166.py index 0deb58bab..5cff94f4a 100644 --- a/netbox/dcim/migrations/0160_squashed_0166.py +++ b/netbox/dcim/migrations/0160_squashed_0166.py @@ -18,9 +18,9 @@ class Migration(migrations.Migration): dependencies = [ ('ipam', '0047_squashed_0053'), - ('tenancy', '0009_standardize_description_comments'), - ('circuits', '0037_new_cabling_models'), - ('dcim', '0159_populate_cable_paths'), + ('tenancy', '0001_squashed_0012'), + ('circuits', '0003_squashed_0037'), + ('dcim', '0131_squashed_0159'), ] operations = [ diff --git a/netbox/dcim/migrations/0167_squashed_0182.py b/netbox/dcim/migrations/0167_squashed_0182.py index d0ad5379f..ba077ff4e 100644 --- a/netbox/dcim/migrations/0167_squashed_0182.py +++ b/netbox/dcim/migrations/0167_squashed_0182.py @@ -27,10 +27,10 @@ class Migration(migrations.Migration): ] dependencies = [ - ('extras', '0086_configtemplate'), - ('tenancy', '0010_tenant_relax_uniqueness'), + ('extras', '0060_squashed_0086'), + ('tenancy', '0002_squashed_0011'), ('ipam', '0047_squashed_0053'), - ('dcim', '0166_virtualdevicecontext'), + ('dcim', '0160_squashed_0166'), ] operations = [ diff --git a/netbox/dcim/migrations/0183_devicetype_exclude_from_utilization.py b/netbox/dcim/migrations/0183_devicetype_exclude_from_utilization.py index f9f2c20b4..2e3edb08a 100644 --- a/netbox/dcim/migrations/0183_devicetype_exclude_from_utilization.py +++ b/netbox/dcim/migrations/0183_devicetype_exclude_from_utilization.py @@ -5,7 +5,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0182_zero_length_cable_fix'), + ('dcim', '0167_squashed_0182'), ] operations = [ diff --git a/netbox/extras/migrations/0002_squashed_0059.py b/netbox/extras/migrations/0002_squashed_0059.py index b664b286e..3aa7644fd 100644 --- a/netbox/extras/migrations/0002_squashed_0059.py +++ b/netbox/extras/migrations/0002_squashed_0059.py @@ -3,10 +3,10 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0002_auto_20160622_1821'), - ('extras', '0001_initial'), - ('virtualization', '0001_virtualization'), - ('tenancy', '0001_initial'), + ('dcim', '0002_squashed'), + ('extras', '0001_squashed'), + ('virtualization', '0001_squashed_0022'), + ('tenancy', '0001_squashed_0012'), ] replaces = [ diff --git a/netbox/extras/migrations/0060_squashed_0086.py b/netbox/extras/migrations/0060_squashed_0086.py index 3bde7480f..2e4437c6b 100644 --- a/netbox/extras/migrations/0060_squashed_0086.py +++ b/netbox/extras/migrations/0060_squashed_0086.py @@ -45,13 +45,13 @@ class Migration(migrations.Migration): dependencies = [ ('virtualization', '0001_squashed_0022'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('core', '0001_initial'), + ('core', '0001_squashed_0005'), ('contenttypes', '0002_remove_content_type_name'), - ('wireless', '0008_wirelesslan_status'), - ('dcim', '0166_virtualdevicecontext'), - ('tenancy', '0009_standardize_description_comments'), - ('extras', '0059_exporttemplate_as_attachment'), - ('circuits', '0041_standardize_description_comments'), + ('wireless', '0001_squashed_0008'), + ('dcim', '0160_squashed_0166'), + ('tenancy', '0001_squashed_0012'), + ('extras', '0002_squashed_0059'), + ('circuits', '0038_squashed_0042'), ] operations = [ diff --git a/netbox/extras/migrations/0087_squashed_0098.py b/netbox/extras/migrations/0087_squashed_0098.py index 839f4cbe4..21a6116b7 100644 --- a/netbox/extras/migrations/0087_squashed_0098.py +++ b/netbox/extras/migrations/0087_squashed_0098.py @@ -26,9 +26,9 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), - ('extras', '0086_configtemplate'), + ('extras', '0060_squashed_0086'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('core', '0002_managedfile'), + ('core', '0001_squashed_0005'), ] operations = [ diff --git a/netbox/extras/migrations/0099_cachedvalue_ordering.py b/netbox/extras/migrations/0099_cachedvalue_ordering.py index 36b91d59b..d3ddc5533 100644 --- a/netbox/extras/migrations/0099_cachedvalue_ordering.py +++ b/netbox/extras/migrations/0099_cachedvalue_ordering.py @@ -5,7 +5,7 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('extras', '0098_webhook_custom_field_data_webhook_tags'), + ('extras', '0087_squashed_0098'), ] operations = [ diff --git a/netbox/ipam/migrations/0001_squashed.py b/netbox/ipam/migrations/0001_squashed.py index 896d7c4c9..8df31a4d6 100644 --- a/netbox/ipam/migrations/0001_squashed.py +++ b/netbox/ipam/migrations/0001_squashed.py @@ -13,9 +13,9 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), - ('dcim', '0002_auto_20160622_1821'), - ('extras', '0001_initial'), - ('tenancy', '0001_initial'), + ('dcim', '0002_squashed'), + ('extras', '0001_squashed'), + ('tenancy', '0001_squashed_0012'), ] replaces = [ diff --git a/netbox/ipam/migrations/0002_squashed_0046.py b/netbox/ipam/migrations/0002_squashed_0046.py index 6c03753d8..428a93f3a 100644 --- a/netbox/ipam/migrations/0002_squashed_0046.py +++ b/netbox/ipam/migrations/0002_squashed_0046.py @@ -5,12 +5,12 @@ import taggit.managers class Migration(migrations.Migration): dependencies = [ - ('dcim', '0003_auto_20160628_1721'), - ('virtualization', '0001_virtualization'), + ('dcim', '0003_squashed_0130'), + ('virtualization', '0001_squashed_0022'), ('contenttypes', '0002_remove_content_type_name'), - ('ipam', '0001_initial'), - ('extras', '0002_custom_fields'), - ('tenancy', '0001_initial'), + ('ipam', '0001_squashed'), + ('extras', '0002_squashed_0059'), + ('tenancy', '0001_squashed_0012'), ] replaces = [ diff --git a/netbox/ipam/migrations/0047_squashed_0053.py b/netbox/ipam/migrations/0047_squashed_0053.py index a05d0cb81..e10bdf7e0 100644 --- a/netbox/ipam/migrations/0047_squashed_0053.py +++ b/netbox/ipam/migrations/0047_squashed_0053.py @@ -19,7 +19,7 @@ class Migration(migrations.Migration): ] dependencies = [ - ('ipam', '0046_set_vlangroup_scope_types'), + ('ipam', '0002_squashed_0046'), ('tenancy', '0001_squashed_0012'), ('extras', '0002_squashed_0059'), ('contenttypes', '0002_remove_content_type_name'), diff --git a/netbox/ipam/migrations/0054_squashed_0067.py b/netbox/ipam/migrations/0054_squashed_0067.py index 929a27fda..34ef34bb5 100644 --- a/netbox/ipam/migrations/0054_squashed_0067.py +++ b/netbox/ipam/migrations/0054_squashed_0067.py @@ -28,11 +28,11 @@ class Migration(migrations.Migration): ] dependencies = [ - ('tenancy', '0007_contact_link'), + ('tenancy', '0002_squashed_0011'), ('contenttypes', '0002_remove_content_type_name'), ('extras', '0060_squashed_0086'), - ('ipam', '0053_asn_model'), - ('tenancy', '0009_standardize_description_comments'), + ('ipam', '0047_squashed_0053'), + ('tenancy', '0002_squashed_0011'), ] operations = [ diff --git a/netbox/ipam/migrations/0068_move_l2vpn.py b/netbox/ipam/migrations/0068_move_l2vpn.py index 9240240bc..16935b1a6 100644 --- a/netbox/ipam/migrations/0068_move_l2vpn.py +++ b/netbox/ipam/migrations/0068_move_l2vpn.py @@ -16,7 +16,7 @@ def update_content_types(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('ipam', '0067_ipaddress_index_host'), + ('ipam', '0054_squashed_0067'), ] operations = [ diff --git a/netbox/tenancy/migrations/0001_squashed_0012.py b/netbox/tenancy/migrations/0001_squashed_0012.py index 8f3f74d9f..d7e0817ee 100644 --- a/netbox/tenancy/migrations/0001_squashed_0012.py +++ b/netbox/tenancy/migrations/0001_squashed_0012.py @@ -9,7 +9,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('extras', '0001_initial'), + ('extras', '0001_squashed'), ] replaces = [ diff --git a/netbox/tenancy/migrations/0012_contactassignment_custom_fields.py b/netbox/tenancy/migrations/0012_contactassignment_custom_fields.py index 7f681fd91..849386624 100644 --- a/netbox/tenancy/migrations/0012_contactassignment_custom_fields.py +++ b/netbox/tenancy/migrations/0012_contactassignment_custom_fields.py @@ -6,7 +6,7 @@ import utilities.json class Migration(migrations.Migration): dependencies = [ - ('tenancy', '0011_contactassignment_tags'), + ('tenancy', '0002_squashed_0011'), ] operations = [ diff --git a/netbox/virtualization/migrations/0001_squashed_0022.py b/netbox/virtualization/migrations/0001_squashed_0022.py index c7aa35ec7..482108a76 100644 --- a/netbox/virtualization/migrations/0001_squashed_0022.py +++ b/netbox/virtualization/migrations/0001_squashed_0022.py @@ -13,10 +13,10 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('dcim', '0002_auto_20160622_1821'), - ('ipam', '0001_initial'), - ('extras', '0001_initial'), - ('tenancy', '0001_initial'), + ('dcim', '0002_squashed'), + ('ipam', '0001_squashed'), + ('extras', '0001_squashed'), + ('tenancy', '0001_squashed_0012'), ] replaces = [ diff --git a/netbox/virtualization/migrations/0023_squashed_0036.py b/netbox/virtualization/migrations/0023_squashed_0036.py index 0665aaab6..6ff8fcae4 100644 --- a/netbox/virtualization/migrations/0023_squashed_0036.py +++ b/netbox/virtualization/migrations/0023_squashed_0036.py @@ -26,9 +26,9 @@ class Migration(migrations.Migration): dependencies = [ ('dcim', '0003_squashed_0130'), - ('extras', '0098_webhook_custom_field_data_webhook_tags'), + ('extras', '0087_squashed_0098'), ('ipam', '0047_squashed_0053'), - ('virtualization', '0022_vminterface_parent'), + ('virtualization', '0001_squashed_0022'), ] operations = [ diff --git a/netbox/virtualization/migrations/0037_protect_child_interfaces.py b/netbox/virtualization/migrations/0037_protect_child_interfaces.py index a9d2075c1..a19e4e9ce 100644 --- a/netbox/virtualization/migrations/0037_protect_child_interfaces.py +++ b/netbox/virtualization/migrations/0037_protect_child_interfaces.py @@ -6,7 +6,7 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('virtualization', '0036_virtualmachine_config_template'), + ('virtualization', '0023_squashed_0036'), ] operations = [ diff --git a/netbox/vpn/migrations/0001_initial.py b/netbox/vpn/migrations/0001_initial.py index b44ae3e52..eed3c6329 100644 --- a/netbox/vpn/migrations/0001_initial.py +++ b/netbox/vpn/migrations/0001_initial.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), ('extras', '0099_cachedvalue_ordering'), - ('ipam', '0067_ipaddress_index_host'), + ('ipam', '0054_squashed_0067'), ('tenancy', '0012_contactassignment_custom_fields'), ] diff --git a/netbox/wireless/migrations/0001_squashed_0008.py b/netbox/wireless/migrations/0001_squashed_0008.py index 8886580e1..75d9ae22a 100644 --- a/netbox/wireless/migrations/0001_squashed_0008.py +++ b/netbox/wireless/migrations/0001_squashed_0008.py @@ -21,7 +21,7 @@ class Migration(migrations.Migration): dependencies = [ ('ipam', '0002_squashed_0046'), - ('tenancy', '0007_contact_link'), + ('tenancy', '0002_squashed_0011'), ('extras', '0002_squashed_0059'), ('dcim', '0003_squashed_0130'), ] From c35f5f829a97bb9ecaf65b255101f38c80a82250 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 7 Mar 2025 13:49:06 -0500 Subject: [PATCH 031/103] Closes #7598: Enable custom field filtering for GraphQL (#18701) --- docs/development/adding-models.md | 8 +- docs/integrations/graphql-api.md | 39 +- docs/plugins/development/filtersets.md | 2 +- netbox/circuits/graphql/enums.py | 20 + netbox/circuits/graphql/filter_mixins.py | 19 + netbox/circuits/graphql/filters.py | 183 +++- netbox/circuits/graphql/types.py | 14 +- netbox/core/graphql/filter_mixins.py | 36 + netbox/core/graphql/filters.py | 81 +- netbox/core/graphql/mixins.py | 5 +- netbox/core/graphql/types.py | 7 + netbox/dcim/graphql/enums.py | 77 ++ netbox/dcim/graphql/filter_mixins.py | 149 ++++ netbox/dcim/graphql/filters.py | 806 +++++++++++++++--- netbox/dcim/graphql/types.py | 55 +- netbox/extras/graphql/enums.py | 26 + netbox/extras/graphql/filter_mixins.py | 52 ++ netbox/extras/graphql/filters.py | 288 ++++++- netbox/extras/graphql/types.py | 21 +- netbox/ipam/graphql/enums.py | 27 + netbox/ipam/graphql/filter_mixins.py | 25 + netbox/ipam/graphql/filters.py | 285 +++++-- netbox/ipam/graphql/types.py | 27 +- netbox/netbox/graphql/enums.py | 13 + netbox/netbox/graphql/filter_lookups.py | 219 +++++ netbox/netbox/graphql/filter_mixins.py | 296 +++---- netbox/netbox/graphql/types.py | 1 + netbox/netbox/settings.py | 1 - netbox/netbox/tests/test_graphql.py | 4 +- netbox/tenancy/graphql/enums.py | 9 + netbox/tenancy/graphql/filter_mixins.py | 38 + netbox/tenancy/graphql/filters.py | 178 +++- netbox/tenancy/graphql/mixins.py | 1 - netbox/tenancy/graphql/types.py | 138 +-- netbox/users/graphql/filters.py | 31 +- netbox/utilities/choices.py | 19 + netbox/virtualization/graphql/enums.py | 11 + .../virtualization/graphql/filter_mixins.py | 26 + netbox/virtualization/graphql/filters.py | 149 +++- netbox/virtualization/graphql/types.py | 19 +- netbox/vpn/graphql/enums.py | 31 + netbox/vpn/graphql/filters.py | 161 +++- netbox/vpn/graphql/types.py | 11 +- netbox/wireless/graphql/enums.py | 17 + netbox/wireless/graphql/filter_mixins.py | 26 + netbox/wireless/graphql/filters.py | 62 +- netbox/wireless/graphql/types.py | 9 +- 47 files changed, 3041 insertions(+), 681 deletions(-) create mode 100644 netbox/circuits/graphql/enums.py create mode 100644 netbox/circuits/graphql/filter_mixins.py create mode 100644 netbox/core/graphql/filter_mixins.py create mode 100644 netbox/dcim/graphql/enums.py create mode 100644 netbox/dcim/graphql/filter_mixins.py create mode 100644 netbox/extras/graphql/enums.py create mode 100644 netbox/extras/graphql/filter_mixins.py create mode 100644 netbox/ipam/graphql/enums.py create mode 100644 netbox/ipam/graphql/filter_mixins.py create mode 100644 netbox/netbox/graphql/enums.py create mode 100644 netbox/netbox/graphql/filter_lookups.py create mode 100644 netbox/tenancy/graphql/enums.py create mode 100644 netbox/tenancy/graphql/filter_mixins.py create mode 100644 netbox/virtualization/graphql/enums.py create mode 100644 netbox/virtualization/graphql/filter_mixins.py create mode 100644 netbox/vpn/graphql/enums.py create mode 100644 netbox/wireless/graphql/enums.py create mode 100644 netbox/wireless/graphql/filter_mixins.py diff --git a/docs/development/adding-models.md b/docs/development/adding-models.md index 0bf020662..da0e49511 100644 --- a/docs/development/adding-models.md +++ b/docs/development/adding-models.md @@ -76,11 +76,13 @@ Create the following for each model: ## 13. GraphQL API components -Create a GraphQL object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`. +Create the following for each model: -**Note:** GraphQL unit tests may fail citing null values on a non-nullable field if related objects are prefetched. You may need to fix this by setting the type annotation to be `= strawberry_django.field(select_related=["policy"])` or similar. +* GraphQL object type for the model in `graphql/types.py` (subclass the appropriate class from `netbox.graphql.types`) +* Add a GraphQL filter for the model in `graphql/filters.py` +* Extend the query class for the app in `graphql/schema.py` with the individual object and object list fields -Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention. +**Note:** GraphQL unit tests may fail citing null values on a non-nullable field if related objects are prefetched. You may need to fix this by setting the type annotation to be `= strawberry_django.field(select_related=["foo"])` or similar. ## 14. Add tests diff --git a/docs/integrations/graphql-api.md b/docs/integrations/graphql-api.md index c02045f34..23824ad2b 100644 --- a/docs/integrations/graphql-api.md +++ b/docs/integrations/graphql-api.md @@ -11,7 +11,7 @@ curl -H "Authorization: Token $TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ http://netbox/graphql/ \ ---data '{"query": "query {circuit_list(status:\"active\") {cid provider {name}}}"}' +--data '{"query": "query {circuit_list(filters:{status: STATUS_ACTIVE}) {cid provider {name}}}"}' ``` The response will include the requested data formatted as JSON: @@ -51,19 +51,48 @@ For more detail on constructing GraphQL queries, see the [GraphQL queries docume ## Filtering -The GraphQL API employs the same filtering logic as the UI and REST API. Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only sites within the North Carolina region with a status of active: +!!! note "Changed in NetBox v4.3" + The filtering syntax fo the GraphQL API has changed substantially in NetBox v4.3. + +Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only active sites: ``` query { - site_list(filters: {region: "us-nc", status: "active"}) { + site_list( + filters: { + status: STATUS_ACTIVE + } + ) { name } } ``` -In addition, filtering can be done on list of related objects as shown in the following query: + +Filters can be combined with logical operators, such as `OR` and `NOT`. For example, the following will return every site that is planned _or_ assigned to a tenant named Foo: ``` -{ +query { + site_list( + filters: { + status: STATUS_PLANNED, + OR: { + tenant: { + name: { + exact: "Foo" + } + } + } + } + ) { + name + } +} +``` + +Filtering can also be applied to related objects. For example, the following query will return only enabled interfaces for each device: + +``` +query { device_list { id name diff --git a/docs/plugins/development/filtersets.md b/docs/plugins/development/filtersets.md index d803ce2f4..224802397 100644 --- a/docs/plugins/development/filtersets.md +++ b/docs/plugins/development/filtersets.md @@ -1,6 +1,6 @@ # Filters & Filter Sets -Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI, REST API, or GraphQL API. NetBox employs the [django-filters2](https://django-tables2.readthedocs.io/en/latest/) library to define filter sets. +Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI or REST API. (Note that the GraphQL API uses a separate filter class.) NetBox employs the [django-filters2](https://django-tables2.readthedocs.io/en/latest/) library to define filter sets. ## FilterSet Classes diff --git a/netbox/circuits/graphql/enums.py b/netbox/circuits/graphql/enums.py new file mode 100644 index 000000000..609e4435b --- /dev/null +++ b/netbox/circuits/graphql/enums.py @@ -0,0 +1,20 @@ +import strawberry + +from circuits.choices import * + +__all__ = ( + 'CircuitStatusEnum', + 'CircuitCommitRateEnum', + 'CircuitTerminationSideEnum', + 'CircuitTerminationPortSpeedEnum', + 'CircuitPriorityEnum', + 'VirtualCircuitTerminationRoleEnum', +) + + +CircuitCommitRateEnum = strawberry.enum(CircuitCommitRateChoices.as_enum()) +CircuitPriorityEnum = strawberry.enum(CircuitPriorityChoices.as_enum()) +CircuitStatusEnum = strawberry.enum(CircuitStatusChoices.as_enum()) +CircuitTerminationSideEnum = strawberry.enum(CircuitTerminationSideChoices.as_enum()) +CircuitTerminationPortSpeedEnum = strawberry.enum(CircuitTerminationPortSpeedChoices.as_enum()) +VirtualCircuitTerminationRoleEnum = strawberry.enum(VirtualCircuitTerminationRoleChoices.as_enum()) diff --git a/netbox/circuits/graphql/filter_mixins.py b/netbox/circuits/graphql/filter_mixins.py new file mode 100644 index 000000000..3ae6fa82e --- /dev/null +++ b/netbox/circuits/graphql/filter_mixins.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django + +from netbox.graphql.filter_mixins import OrganizationalModelFilterMixin + +if TYPE_CHECKING: + from netbox.graphql.enums import ColorEnum + +__all__ = ( + 'BaseCircuitTypeFilterMixin', +) + + +@dataclass +class BaseCircuitTypeFilterMixin(OrganizationalModelFilterMixin): + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() diff --git a/netbox/circuits/graphql/filters.py b/netbox/circuits/graphql/filters.py index 7d066f428..48b4252ac 100644 --- a/netbox/circuits/graphql/filters.py +++ b/netbox/circuits/graphql/filters.py @@ -1,7 +1,30 @@ -import strawberry_django +from datetime import date +from typing import Annotated, TYPE_CHECKING -from circuits import filtersets, models -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup, DateFilterLookup + +from circuits import models +from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin +from dcim.graphql.filter_mixins import CabledObjectModelFilterMixin +from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin +from netbox.graphql.filter_mixins import ( + DistanceFilterMixin, + ImageAttachmentFilterMixin, + OrganizationalModelFilterMixin, + PrimaryModelFilterMixin, +) +from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin +from .filter_mixins import BaseCircuitTypeFilterMixin + +if TYPE_CHECKING: + from core.graphql.filters import ContentTypeFilter + from dcim.graphql.filters import InterfaceFilter + from ipam.graphql.filters import ASNFilter + from netbox.graphql.filter_lookups import IntegerLookup + from .enums import * __all__ = ( 'CircuitFilter', @@ -19,66 +42,160 @@ __all__ = ( @strawberry_django.filter(models.CircuitTermination, lookups=True) -@autotype_decorator(filtersets.CircuitTerminationFilterSet) -class CircuitTerminationFilter(BaseFilterMixin): - pass +class CircuitTerminationFilter( + BaseObjectTypeFilterMixin, + CustomFieldsFilterMixin, + TagsFilterMixin, + ChangeLogFilterMixin, + CabledObjectModelFilterMixin, +): + circuit: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + term_side: Annotated['CircuitTerminationSideEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + termination_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + termination_id: ID | None = strawberry_django.filter_field() + port_speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + upstream_speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + xconnect_id: FilterLookup[str] | None = strawberry_django.filter_field() + pp_info: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.Circuit, lookups=True) -@autotype_decorator(filtersets.CircuitFilterSet) -class CircuitFilter(BaseFilterMixin): - pass +class CircuitFilter( + ContactFilterMixin, + ImageAttachmentFilterMixin, + DistanceFilterMixin, + TenancyFilterMixin, + PrimaryModelFilterMixin +): + cid: FilterLookup[str] | None = strawberry_django.filter_field() + provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + provider_id: ID | None = strawberry_django.filter_field() + provider_account: Annotated['ProviderAccountFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + provider_account_id: ID | None = strawberry_django.filter_field() + type: Annotated['CircuitTypeFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + type_id: ID | None = strawberry_django.filter_field() + status: Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + install_date: DateFilterLookup[date] | None = strawberry_django.filter_field() + termination_date: DateFilterLookup[date] | None = strawberry_django.filter_field() + commit_rate: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.CircuitType, lookups=True) -@autotype_decorator(filtersets.CircuitTypeFilterSet) -class CircuitTypeFilter(BaseFilterMixin): +class CircuitTypeFilter(BaseCircuitTypeFilterMixin): pass @strawberry_django.filter(models.CircuitGroup, lookups=True) -@autotype_decorator(filtersets.CircuitGroupFilterSet) -class CircuitGroupFilter(BaseFilterMixin): +class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilterMixin): pass @strawberry_django.filter(models.CircuitGroupAssignment, lookups=True) -@autotype_decorator(filtersets.CircuitGroupAssignmentFilterSet) -class CircuitGroupAssignmentFilter(BaseFilterMixin): - pass +class CircuitGroupAssignmentFilter( + BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin +): + member_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + member_id: ID | None = strawberry_django.filter_field() + group: Annotated['CircuitGroupFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + group_id: ID | None = strawberry_django.filter_field() + priority: Annotated['CircuitPriorityEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Provider, lookups=True) -@autotype_decorator(filtersets.ProviderFilterSet) -class ProviderFilter(BaseFilterMixin): - pass +class ProviderFilter(ContactFilterMixin, PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() @strawberry_django.filter(models.ProviderAccount, lookups=True) -@autotype_decorator(filtersets.ProviderAccountFilterSet) -class ProviderAccountFilter(BaseFilterMixin): - pass +class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilterMixin): + provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + provider_id: ID | None = strawberry_django.filter_field() + account: FilterLookup[str] | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.ProviderNetwork, lookups=True) -@autotype_decorator(filtersets.ProviderNetworkFilterSet) -class ProviderNetworkFilter(BaseFilterMixin): - pass +class ProviderNetworkFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + provider_id: ID | None = strawberry_django.filter_field() + service_id: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.VirtualCircuitType, lookups=True) -@autotype_decorator(filtersets.VirtualCircuitTypeFilterSet) -class VirtualCircuitTypeFilter(BaseFilterMixin): +class VirtualCircuitTypeFilter(BaseCircuitTypeFilterMixin): pass @strawberry_django.filter(models.VirtualCircuit, lookups=True) -@autotype_decorator(filtersets.VirtualCircuitFilterSet) -class VirtualCircuitFilter(BaseFilterMixin): - pass +class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin): + cid: FilterLookup[str] | None = strawberry_django.filter_field() + provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + provider_network_id: ID | None = strawberry_django.filter_field() + provider_account: Annotated['ProviderAccountFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + provider_account_id: ID | None = strawberry_django.filter_field() + type: Annotated['VirtualCircuitTypeFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + type_id: ID | None = strawberry_django.filter_field() + status: Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + group_assignments: Annotated['CircuitGroupAssignmentFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.VirtualCircuitTermination, lookups=True) -@autotype_decorator(filtersets.VirtualCircuitTerminationFilterSet) -class VirtualCircuitTerminationFilter(BaseFilterMixin): - pass +class VirtualCircuitTerminationFilter( + BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin +): + virtual_circuit: Annotated['VirtualCircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + virtual_circuit_id: ID | None = strawberry_django.filter_field() + role: Annotated['VirtualCircuitTerminationRoleEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + interface: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + interface_id: ID | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index 564b5ed6f..860c19852 100644 --- a/netbox/circuits/graphql/types.py +++ b/netbox/circuits/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, List, Union +from typing import Annotated, List, TYPE_CHECKING, Union import strawberry import strawberry_django @@ -10,11 +10,15 @@ from netbox.graphql.types import BaseObjectType, NetBoxObjectType, ObjectType, O from tenancy.graphql.types import TenantType from .filters import * +if TYPE_CHECKING: + from dcim.graphql.types import InterfaceType, LocationType, RegionType, SiteGroupType, SiteType + from ipam.graphql.types import ASNType + __all__ = ( - 'CircuitTerminationType', - 'CircuitType', 'CircuitGroupAssignmentType', 'CircuitGroupType', + 'CircuitTerminationType', + 'CircuitType', 'CircuitTypeType', 'ProviderType', 'ProviderAccountType', @@ -62,7 +66,7 @@ class ProviderNetworkType(NetBoxObjectType): @strawberry_django.type( models.CircuitTermination, - exclude=('termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'), + exclude=['termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'], filters=CircuitTerminationFilter ) class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType): @@ -117,7 +121,7 @@ class CircuitGroupType(OrganizationalObjectType): @strawberry_django.type( models.CircuitGroupAssignment, - exclude=('member_type', 'member_id'), + exclude=['member_type', 'member_id'], filters=CircuitGroupAssignmentFilter ) class CircuitGroupAssignmentType(TagsMixin, BaseObjectType): diff --git a/netbox/core/graphql/filter_mixins.py b/netbox/core/graphql/filter_mixins.py new file mode 100644 index 000000000..670ec2ebb --- /dev/null +++ b/netbox/core/graphql/filter_mixins.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry import ID +from strawberry_django import DatetimeFilterLookup + +if TYPE_CHECKING: + from .filters import * + +__all__ = ( + 'BaseFilterMixin', + 'BaseObjectTypeFilterMixin', + 'ChangeLogFilterMixin', +) + + +# @strawberry.input +class BaseFilterMixin: ... + + +@dataclass +class BaseObjectTypeFilterMixin(BaseFilterMixin): + id: ID | None = strawberry.UNSET + + +@dataclass +class ChangeLogFilterMixin(BaseFilterMixin): + id: ID | None = strawberry.UNSET + changelog: Annotated['ObjectChangeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + last_updated: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() diff --git a/netbox/core/graphql/filters.py b/netbox/core/graphql/filters.py index 82da685a5..e5d44674a 100644 --- a/netbox/core/graphql/filters.py +++ b/netbox/core/graphql/filters.py @@ -1,28 +1,89 @@ -import strawberry_django +from datetime import datetime +from typing import Annotated, TYPE_CHECKING -from core import filtersets, models -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin +import strawberry +import strawberry_django +from django.contrib.contenttypes.models import ContentType as DjangoContentType +from strawberry.scalars import ID +from strawberry_django import DatetimeFilterLookup, FilterLookup + +from core import models +from core.graphql.filter_mixins import BaseFilterMixin +from netbox.graphql.filter_mixins import PrimaryModelFilterMixin + +if TYPE_CHECKING: + from netbox.graphql.filter_lookups import IntegerLookup, JSONFilter + from users.graphql.filters import UserFilter __all__ = ( 'DataFileFilter', 'DataSourceFilter', 'ObjectChangeFilter', + 'ContentTypeFilter', ) @strawberry_django.filter(models.DataFile, lookups=True) -@autotype_decorator(filtersets.DataFileFilterSet) class DataFileFilter(BaseFilterMixin): - pass + id: ID | None = strawberry_django.filter_field() + created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + last_updated: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + source: Annotated['DataSourceFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + source_id: ID | None = strawberry_django.filter_field() + path: FilterLookup[str] | None = strawberry_django.filter_field() + size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + hash: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.DataSource, lookups=True) -@autotype_decorator(filtersets.DataSourceFilterSet) -class DataSourceFilter(BaseFilterMixin): - pass +class DataSourceFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + type: FilterLookup[str] | None = strawberry_django.filter_field() + source_url: FilterLookup[str] | None = strawberry_django.filter_field() + status: FilterLookup[str] | None = strawberry_django.filter_field() + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + ignore_rules: FilterLookup[str] | None = strawberry_django.filter_field() + parameters: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + last_synced: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + datafiles: Annotated['DataFileFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ObjectChange, lookups=True) -@autotype_decorator(filtersets.ObjectChangeFilterSet) class ObjectChangeFilter(BaseFilterMixin): - pass + id: ID | None = strawberry_django.filter_field() + time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() + user_name: FilterLookup[str] | None = strawberry_django.filter_field() + request_id: FilterLookup[str] | None = strawberry_django.filter_field() + action: FilterLookup[str] | None = strawberry_django.filter_field() + changed_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + changed_object_type_id: ID | None = strawberry_django.filter_field() + changed_object_id: ID | None = strawberry_django.filter_field() + related_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + related_object_id: ID | None = strawberry_django.filter_field() + object_repr: FilterLookup[str] | None = strawberry_django.filter_field() + prechange_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + postchange_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter(DjangoContentType, lookups=True) +class ContentTypeFilter(BaseFilterMixin): + id: ID | None = strawberry_django.filter_field() + app_label: FilterLookup[str] | None = strawberry_django.filter_field() + model: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/core/graphql/mixins.py b/netbox/core/graphql/mixins.py index 5195b52a0..72191e6fd 100644 --- a/netbox/core/graphql/mixins.py +++ b/netbox/core/graphql/mixins.py @@ -1,4 +1,4 @@ -from typing import Annotated, List +from typing import Annotated, List, TYPE_CHECKING import strawberry import strawberry_django @@ -6,6 +6,9 @@ from django.contrib.contenttypes.models import ContentType from core.models import ObjectChange +if TYPE_CHECKING: + from netbox.core.graphql.types import ObjectChangeType + __all__ = ( 'ChangelogMixin', ) diff --git a/netbox/core/graphql/types.py b/netbox/core/graphql/types.py index 09385d7c1..1c57a2dbc 100644 --- a/netbox/core/graphql/types.py +++ b/netbox/core/graphql/types.py @@ -2,12 +2,14 @@ from typing import Annotated, List import strawberry import strawberry_django +from django.contrib.contenttypes.models import ContentType as DjangoContentType from core import models from netbox.graphql.types import BaseObjectType, NetBoxObjectType from .filters import * __all__ = ( + 'ContentType', 'DataFileType', 'DataSourceType', 'ObjectChangeType', @@ -40,3 +42,8 @@ class DataSourceType(NetBoxObjectType): ) class ObjectChangeType(BaseObjectType): pass + + +@strawberry_django.type(DjangoContentType, fields='__all__') +class ContentType: + pass diff --git a/netbox/dcim/graphql/enums.py b/netbox/dcim/graphql/enums.py new file mode 100644 index 000000000..a60c0df14 --- /dev/null +++ b/netbox/dcim/graphql/enums.py @@ -0,0 +1,77 @@ +import strawberry + +from dcim.choices import * + +__all__ = ( + 'CableEndEnum', + 'CableLengthUnitEnum', + 'CableTypeEnum', + 'ConsolePortSpeedEnum', + 'ConsolePortTypeEnum', + 'DeviceAirflowEnum', + 'DeviceFaceEnum', + 'DeviceStatusEnum', + 'InterfaceDuplexEnum', + 'InterfaceModeEnum', + 'InterfacePoEModeEnum', + 'InterfacePoETypeEnum', + 'InterfaceSpeedEnum', + 'InterfaceTypeEnum', + 'InventoryItemStatusEnum', + 'LinkStatusEnum', + 'LocationStatusEnum', + 'ModuleAirflowEnum', + 'ModuleStatusEnum', + 'PortTypeEnum', + 'PowerFeedPhaseEnum', + 'PowerFeedStatusEnum', + 'PowerFeedSupplyEnum', + 'PowerFeedTypeEnum', + 'PowerOutletFeedLegEnum', + 'PowerOutletTypeEnum', + 'PowerPortTypeEnum', + 'RackAirflowEnum', + 'RackDimensionUnitEnum', + 'RackFormFactorEnum', + 'RackStatusEnum', + 'RackWidthEnum', + 'SiteStatusEnum', + 'SubdeviceRoleEnum', + 'VirtualDeviceContextStatusEnum', +) + +CableEndEnum = strawberry.enum(CableEndChoices.as_enum()) +CableLengthUnitEnum = strawberry.enum(CableLengthUnitChoices.as_enum()) +CableTypeEnum = strawberry.enum(CableTypeChoices.as_enum()) +ConsolePortSpeedEnum = strawberry.enum(ConsolePortSpeedChoices.as_enum()) +ConsolePortTypeEnum = strawberry.enum(ConsolePortTypeChoices.as_enum()) +DeviceAirflowEnum = strawberry.enum(DeviceAirflowChoices.as_enum()) +DeviceFaceEnum = strawberry.enum(DeviceFaceChoices.as_enum()) +DeviceStatusEnum = strawberry.enum(DeviceStatusChoices.as_enum()) +InterfaceDuplexEnum = strawberry.enum(InterfaceDuplexChoices.as_enum()) +InterfaceModeEnum = strawberry.enum(InterfaceModeChoices.as_enum()) +InterfacePoEModeEnum = strawberry.enum(InterfacePoEModeChoices.as_enum()) +InterfacePoETypeEnum = strawberry.enum(InterfacePoETypeChoices.as_enum()) +InterfaceSpeedEnum = strawberry.enum(InterfaceSpeedChoices.as_enum()) +InterfaceTypeEnum = strawberry.enum(InterfaceTypeChoices.as_enum()) +InventoryItemStatusEnum = strawberry.enum(InventoryItemStatusChoices.as_enum()) +LinkStatusEnum = strawberry.enum(LinkStatusChoices.as_enum()) +LocationStatusEnum = strawberry.enum(LocationStatusChoices.as_enum()) +ModuleAirflowEnum = strawberry.enum(ModuleAirflowChoices.as_enum()) +ModuleStatusEnum = strawberry.enum(ModuleStatusChoices.as_enum()) +PortTypeEnum = strawberry.enum(PortTypeChoices.as_enum()) +PowerFeedPhaseEnum = strawberry.enum(PowerFeedPhaseChoices.as_enum()) +PowerFeedStatusEnum = strawberry.enum(PowerFeedStatusChoices.as_enum()) +PowerFeedSupplyEnum = strawberry.enum(PowerFeedSupplyChoices.as_enum()) +PowerFeedTypeEnum = strawberry.enum(PowerFeedTypeChoices.as_enum()) +PowerOutletFeedLegEnum = strawberry.enum(PowerOutletFeedLegChoices.as_enum()) +PowerOutletTypeEnum = strawberry.enum(PowerOutletTypeChoices.as_enum()) +PowerPortTypeEnum = strawberry.enum(PowerPortTypeChoices.as_enum()) +RackAirflowEnum = strawberry.enum(RackAirflowChoices.as_enum()) +RackDimensionUnitEnum = strawberry.enum(RackDimensionUnitChoices.as_enum()) +RackFormFactorEnum = strawberry.enum(RackFormFactorChoices.as_enum()) +RackStatusEnum = strawberry.enum(RackStatusChoices.as_enum()) +RackWidthEnum = strawberry.enum(RackWidthChoices.as_enum()) +SiteStatusEnum = strawberry.enum(SiteStatusChoices.as_enum()) +SubdeviceRoleEnum = strawberry.enum(SubdeviceRoleChoices.as_enum()) +VirtualDeviceContextStatusEnum = strawberry.enum(VirtualDeviceContextStatusChoices.as_enum()) diff --git a/netbox/dcim/graphql/filter_mixins.py b/netbox/dcim/graphql/filter_mixins.py new file mode 100644 index 000000000..47a75d08e --- /dev/null +++ b/netbox/dcim/graphql/filter_mixins.py @@ -0,0 +1,149 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry import ID +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins import BaseFilterMixin, ChangeLogFilterMixin +from core.graphql.filters import ContentTypeFilter +from netbox.graphql.filter_mixins import NetBoxModelFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin +from .enums import * + +if TYPE_CHECKING: + from netbox.graphql.filter_lookups import IntegerLookup + from extras.graphql.filters import ConfigTemplateFilter + from ipam.graphql.filters import VLANFilter, VLANTranslationPolicyFilter + from .filters import * + +__all__ = ( + 'CabledObjectModelFilterMixin', + 'ComponentModelFilterMixin', + 'ComponentTemplateFilterMixin', + 'InterfaceBaseFilterMixin', + 'ModularComponentModelFilterMixin', + 'ModularComponentTemplateFilterMixin', + 'RackBaseFilterMixin', + 'RenderConfigFilterMixin', + 'ScopedFilterMixin', +) + + +@dataclass +class ScopedFilterMixin(BaseFilterMixin): + scope_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + scope_id: ID | None = strawberry_django.filter_field() + + +@dataclass +class ComponentModelFilterMixin(NetBoxModelFilterMixin): + device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + device_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + label: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + + +@dataclass +class ModularComponentModelFilterMixin(ComponentModelFilterMixin): + module: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + module_id: ID | None = strawberry_django.filter_field() + inventory_items: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class CabledObjectModelFilterMixin(BaseFilterMixin): + cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + cable_id: ID | None = strawberry_django.filter_field() + cable_end: CableEndEnum | None = strawberry_django.filter_field() + mark_connected: FilterLookup[bool] | None = strawberry_django.filter_field() + + +@dataclass +class ComponentTemplateFilterMixin(ChangeLogFilterMixin): + device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + device_type_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + label: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + + +@dataclass +class ModularComponentTemplateFilterMixin(ComponentTemplateFilterMixin): + module_type: Annotated['ModuleTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class RenderConfigFilterMixin(BaseFilterMixin): + config_template: Annotated['ConfigTemplateFilter', strawberry.lazy('extras.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + config_template_id: ID | None = strawberry_django.filter_field() + + +@dataclass +class InterfaceBaseFilterMixin(BaseFilterMixin): + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + mtu: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + mode: InterfaceModeEnum | None = strawberry_django.filter_field() + parent: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + parent_id: ID | None = strawberry_django.filter_field() + bridge: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + bridge_id: ID | None = strawberry_django.filter_field() + untagged_vlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tagged_vlans: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + qinq_svlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vlan_translation_policy: Annotated['VLANTranslationPolicyFilter', strawberry.lazy('ipam.graphql.filters')] | None \ + = strawberry_django.filter_field() + primary_mac_address: Annotated['MACAddressFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + primary_mac_address_id: ID | None = strawberry_django.filter_field() + + +@dataclass +class RackBaseFilterMixin(WeightFilterMixin, PrimaryModelFilterMixin): + width: Annotated['RackWidthEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + u_height: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + starting_unit: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + desc_units: FilterLookup[bool] | None = strawberry_django.filter_field() + outer_width: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + outer_depth: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + outer_unit: Annotated['RackDimensionUnitEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + mounting_depth: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + max_weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 94f2c6d38..4203517cc 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -1,7 +1,46 @@ -import strawberry_django +from typing import Annotated, TYPE_CHECKING -from dcim import filtersets, models -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins import ChangeLogFilterMixin +from dcim import models +from extras.graphql.filter_mixins import ConfigContextFilterMixin +from netbox.graphql.filter_mixins import ( + PrimaryModelFilterMixin, + OrganizationalModelFilterMixin, + NestedGroupModelFilterMixin, + ImageAttachmentFilterMixin, + WeightFilterMixin, +) +from tenancy.graphql.filter_mixins import TenancyFilterMixin, ContactFilterMixin +from .filter_mixins import ( + CabledObjectModelFilterMixin, + ComponentModelFilterMixin, + ComponentTemplateFilterMixin, + InterfaceBaseFilterMixin, + ModularComponentModelFilterMixin, + ModularComponentTemplateFilterMixin, + RackBaseFilterMixin, + RenderConfigFilterMixin, +) + +if TYPE_CHECKING: + from core.graphql.filters import ContentTypeFilter + from extras.graphql.filters import ConfigTemplateFilter, ImageAttachmentFilter + from ipam.graphql.filters import ( + ASNFilter, FHRPGroupAssignmentFilter, IPAddressFilter, PrefixFilter, VLANGroupFilter, VRFFilter, + ) + from netbox.graphql.enums import ColorEnum + from netbox.graphql.filter_lookups import FloatLookup, IntegerArrayLookup, IntegerLookup, TreeNodeFilter + from users.graphql.filters import UserFilter + from virtualization.graphql.filters import ClusterFilter + from vpn.graphql.filters import L2VPNFilter, TunnelTerminationFilter + from wireless.graphql.enums import WirelessChannelEnum, WirelessRoleEnum + from wireless.graphql.filters import WirelessLANFilter, WirelessLinkFilter + from .enums import * __all__ = ( 'CableFilter', @@ -13,7 +52,6 @@ __all__ = ( 'DeviceFilter', 'DeviceBayFilter', 'DeviceBayTemplateFilter', - 'InventoryItemTemplateFilter', 'DeviceRoleFilter', 'DeviceTypeFilter', 'FrontPortFilter', @@ -22,6 +60,7 @@ __all__ = ( 'InterfaceTemplateFilter', 'InventoryItemFilter', 'InventoryItemRoleFilter', + 'InventoryItemTemplateFilter', 'LocationFilter', 'MACAddressFilter', 'ManufacturerFilter', @@ -51,258 +90,763 @@ __all__ = ( @strawberry_django.filter(models.Cable, lookups=True) -@autotype_decorator(filtersets.CableFilterSet) -class CableFilter(BaseFilterMixin): - pass +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() + label: FilterLookup[str] | None = strawberry_django.filter_field() + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + length: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + length_unit: Annotated['CableLengthUnitEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + terminations: Annotated['CableTerminationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.CableTermination, lookups=True) -@autotype_decorator(filtersets.CableTerminationFilterSet) -class CableTerminationFilter(BaseFilterMixin): - pass +class CableTerminationFilter(ChangeLogFilterMixin): + cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + cable_id: ID | None = strawberry_django.filter_field() + cable_end: Annotated['CableEndEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + termination_type: Annotated['CableTerminationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + termination_id: ID | None = strawberry_django.filter_field() @strawberry_django.filter(models.ConsolePort, lookups=True) -@autotype_decorator(filtersets.ConsolePortFilterSet) -class ConsolePortFilter(BaseFilterMixin): - pass +class ConsolePortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): + type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + speed: Annotated['ConsolePortSpeedEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ConsolePortTemplate, lookups=True) -@autotype_decorator(filtersets.ConsolePortTemplateFilterSet) -class ConsolePortTemplateFilter(BaseFilterMixin): - pass +class ConsolePortTemplateFilter(ModularComponentTemplateFilterMixin): + type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ConsoleServerPort, lookups=True) -@autotype_decorator(filtersets.ConsoleServerPortFilterSet) -class ConsoleServerPortFilter(BaseFilterMixin): - pass +class ConsoleServerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): + type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + speed: Annotated['ConsolePortSpeedEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ConsoleServerPortTemplate, lookups=True) -@autotype_decorator(filtersets.ConsoleServerPortTemplateFilterSet) -class ConsoleServerPortTemplateFilter(BaseFilterMixin): - pass +class ConsoleServerPortTemplateFilter(ModularComponentTemplateFilterMixin): + type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Device, lookups=True) -@autotype_decorator(filtersets.DeviceFilterSet) -class DeviceFilter(BaseFilterMixin): - pass +class DeviceFilter( + ContactFilterMixin, + TenancyFilterMixin, + ImageAttachmentFilterMixin, + RenderConfigFilterMixin, + ConfigContextFilterMixin, + PrimaryModelFilterMixin, +): + device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + device_type_id: ID | None = strawberry_django.filter_field() + role: Annotated['DeviceRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + role_id: ID | None = strawberry_django.filter_field() + platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + name: FilterLookup[str] | None = strawberry_django.filter_field() + serial: FilterLookup[str] | None = strawberry_django.filter_field() + asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() + site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + site_id: ID | None = strawberry_django.filter_field() + location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + rack_id: ID | None = strawberry_django.filter_field() + position: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + face: Annotated['DeviceFaceEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + status: Annotated['DeviceStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + airflow: Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + primary_ip4: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + primary_ip4_id: ID | None = strawberry_django.filter_field() + primary_ip6: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + primary_ip6_id: ID | None = strawberry_django.filter_field() + oob_ip: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + oob_ip_id: ID | None = strawberry_django.filter_field() + cluster: Annotated['ClusterFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + cluster_id: ID | None = strawberry_django.filter_field() + virtual_chassis: Annotated['VirtualChassisFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + virtual_chassis_id: ID | None = strawberry_django.filter_field() + vc_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + vc_priority: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + latitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + longitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + interfaces: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + consoleports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + consoleserverports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + poweroutlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + powerports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + devicebays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + frontports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + rearports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + modulebays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + modules: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + console_port_count: FilterLookup[int] | None = strawberry_django.filter_field() + console_server_port_count: FilterLookup[int] | None = strawberry_django.filter_field() + power_port_count: FilterLookup[int] | None = strawberry_django.filter_field() + power_outlet_count: FilterLookup[int] | None = strawberry_django.filter_field() + interface_count: FilterLookup[int] | None = strawberry_django.filter_field() + front_port_count: FilterLookup[int] | None = strawberry_django.filter_field() + rear_port_count: FilterLookup[int] | None = strawberry_django.filter_field() + device_bay_count: FilterLookup[int] | None = strawberry_django.filter_field() + module_bay_count: FilterLookup[int] | None = strawberry_django.filter_field() + inventory_item_count: FilterLookup[int] | None = strawberry_django.filter_field() @strawberry_django.filter(models.DeviceBay, lookups=True) -@autotype_decorator(filtersets.DeviceBayFilterSet) -class DeviceBayFilter(BaseFilterMixin): - pass +class DeviceBayFilter(ComponentModelFilterMixin): + installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + installed_device_id: ID | None = strawberry_django.filter_field() @strawberry_django.filter(models.DeviceBayTemplate, lookups=True) -@autotype_decorator(filtersets.DeviceBayTemplateFilterSet) -class DeviceBayTemplateFilter(BaseFilterMixin): +class DeviceBayTemplateFilter(ComponentTemplateFilterMixin): pass @strawberry_django.filter(models.InventoryItemTemplate, lookups=True) -@autotype_decorator(filtersets.InventoryItemTemplateFilterSet) -class InventoryItemTemplateFilter(BaseFilterMixin): - pass +class InventoryItemTemplateFilter(ComponentTemplateFilterMixin): + parent: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + component_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + component_id: ID | None = strawberry_django.filter_field() + role: Annotated['InventoryItemRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + role_id: ID | None = strawberry_django.filter_field() + manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + part_id: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.DeviceRole, lookups=True) -@autotype_decorator(filtersets.DeviceRoleFilterSet) -class DeviceRoleFilter(BaseFilterMixin): - pass +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) -@autotype_decorator(filtersets.DeviceTypeFilterSet) -class DeviceTypeFilter(BaseFilterMixin): - pass +class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin): + manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + model: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + default_platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + default_platform_id: ID | None = strawberry_django.filter_field() + part_number: FilterLookup[str] | None = strawberry_django.filter_field() + u_height: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + exclude_from_utilization: FilterLookup[bool] | None = strawberry_django.filter_field() + is_full_depth: FilterLookup[bool] | None = strawberry_django.filter_field() + subdevice_role: Annotated['SubdeviceRoleEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + airflow: Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + front_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + rear_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + console_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + console_server_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + power_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + power_outlet_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + interface_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + front_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + rear_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + device_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + module_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + inventory_item_template_count: FilterLookup[int] | None = strawberry_django.filter_field() @strawberry_django.filter(models.FrontPort, lookups=True) -@autotype_decorator(filtersets.FrontPortFilterSet) -class FrontPortFilter(BaseFilterMixin): - pass +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() + rear_port: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + rear_port_id: ID | None = strawberry_django.filter_field() + rear_port_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.FrontPortTemplate, lookups=True) -@autotype_decorator(filtersets.FrontPortTemplateFilterSet) -class FrontPortTemplateFilter(BaseFilterMixin): - pass +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() + rear_port: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + rear_port_id: ID | None = strawberry_django.filter_field() + rear_port_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.MACAddress, lookups=True) -@autotype_decorator(filtersets.MACAddressFilterSet) -class MACAddressFilter(BaseFilterMixin): - pass +class MACAddressFilter(PrimaryModelFilterMixin): + mac_address: FilterLookup[str] | None = strawberry_django.filter_field() + assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + assigned_object_id: ID | None = strawberry_django.filter_field() @strawberry_django.filter(models.Interface, lookups=True) -@autotype_decorator(filtersets.InterfaceFilterSet) -class InterfaceFilter(BaseFilterMixin): - pass +class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin, CabledObjectModelFilterMixin): + vcdcs: Annotated['VirtualDeviceContextFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + lag: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + lag_id: ID | None = strawberry_django.filter_field() + type: Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field() + speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + duplex: Annotated['InterfaceDuplexEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + wwn: FilterLookup[str] | None = strawberry_django.filter_field() + rf_role: Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + rf_channel: Annotated['WirelessChannelEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + rf_channel_frequency: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + rf_channel_width: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + tx_power: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + poe_mode: Annotated['InterfacePoEModeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + poe_type: Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + wireless_link: Annotated['WirelessLinkFilter', strawberry.lazy('wireless.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + wireless_link_id: ID | None = strawberry_django.filter_field() + wireless_lans: Annotated['WirelessLANFilter', strawberry.lazy('wireless.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + vrf_id: ID | None = strawberry_django.filter_field() + ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + mac_addresses: Annotated['MACAddressFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + fhrp_group_assignments: Annotated['FHRPGroupAssignmentFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tunnel_terminations: Annotated['TunnelTerminationFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + l2vpn_terminations: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.InterfaceTemplate, lookups=True) -@autotype_decorator(filtersets.InterfaceTemplateFilterSet) -class InterfaceTemplateFilter(BaseFilterMixin): - pass +class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin): + type: Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field() + bridge: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + bridge_id: ID | None = strawberry_django.filter_field() + poe_mode: Annotated['InterfacePoEModeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + poe_type: Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + rf_role: Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.InventoryItem, lookups=True) -@autotype_decorator(filtersets.InventoryItemFilterSet) -class InventoryItemFilter(BaseFilterMixin): - pass +class InventoryItemFilter(ComponentModelFilterMixin): + parent: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + parent_id: ID | None = strawberry_django.filter_field() + component_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + component_id: ID | None = strawberry_django.filter_field() + status: Annotated['InventoryItemStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + role: Annotated['InventoryItemRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + role_id: ID | None = strawberry_django.filter_field() + manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + part_id: FilterLookup[str] | None = strawberry_django.filter_field() + serial: FilterLookup[str] | None = strawberry_django.filter_field() + asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() + discovered: FilterLookup[bool] | None = strawberry_django.filter_field() @strawberry_django.filter(models.InventoryItemRole, lookups=True) -@autotype_decorator(filtersets.InventoryItemRoleFilterSet) -class InventoryItemRoleFilter(BaseFilterMixin): - pass +class InventoryItemRoleFilter(OrganizationalModelFilterMixin): + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() @strawberry_django.filter(models.Location, lookups=True) -@autotype_decorator(filtersets.LocationFilterSet) -class LocationFilter(BaseFilterMixin): - pass +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() + status: Annotated['LocationStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + facility: FilterLookup[str] | None = strawberry_django.filter_field() + prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Manufacturer, lookups=True) -@autotype_decorator(filtersets.ManufacturerFilterSet) -class ManufacturerFilter(BaseFilterMixin): +class ManufacturerFilter(ContactFilterMixin, OrganizationalModelFilterMixin): pass @strawberry_django.filter(models.Module, lookups=True) -@autotype_decorator(filtersets.ModuleFilterSet) -class ModuleFilter(BaseFilterMixin): - pass +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() + module_bay: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + module_bay_id: ID | None = strawberry_django.filter_field() + module_type: Annotated['ModuleTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + module_type_id: ID | None = strawberry_django.filter_field() + status: Annotated['ModuleStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + serial: FilterLookup[str] | None = strawberry_django.filter_field() + asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.ModuleBay, lookups=True) -@autotype_decorator(filtersets.ModuleBayFilterSet) -class ModuleBayFilter(BaseFilterMixin): - pass +class ModuleBayFilter(ModularComponentModelFilterMixin): + parent: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + parent_id: ID | None = strawberry_django.filter_field() + position: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.ModuleBayTemplate, lookups=True) -@autotype_decorator(filtersets.ModuleBayTemplateFilterSet) -class ModuleBayTemplateFilter(BaseFilterMixin): - pass +class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin): + position: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.ModuleType, lookups=True) -@autotype_decorator(filtersets.ModuleTypeFilterSet) -class ModuleTypeFilter(BaseFilterMixin): - pass +class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin): + manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + model: FilterLookup[str] | None = strawberry_django.filter_field() + part_number: FilterLookup[str] | None = strawberry_django.filter_field() + airflow: Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Platform, lookups=True) -@autotype_decorator(filtersets.PlatformFilterSet) -class PlatformFilter(BaseFilterMixin): - pass +class PlatformFilter(OrganizationalModelFilterMixin): + manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + config_template: Annotated['ConfigTemplateFilter', strawberry.lazy('extras.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + config_template_id: ID | None = strawberry_django.filter_field() @strawberry_django.filter(models.PowerFeed, lookups=True) -@autotype_decorator(filtersets.PowerFeedFilterSet) -class PowerFeedFilter(BaseFilterMixin): - pass +class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): + power_panel: Annotated['PowerPanelFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + power_panel_id: ID | None = strawberry_django.filter_field() + rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + rack_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['PowerFeedStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + type: Annotated['PowerFeedTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + supply: Annotated['PowerFeedSupplyEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + phase: Annotated['PowerFeedPhaseEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + voltage: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + amperage: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + max_utilization: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + available_power: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.PowerOutlet, lookups=True) -@autotype_decorator(filtersets.PowerOutletFilterSet) -class PowerOutletFilter(BaseFilterMixin): - pass +class PowerOutletFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): + type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + power_port: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + power_port_id: ID | None = strawberry_django.filter_field() + feed_leg: Annotated['PowerOutletFeedLegEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() @strawberry_django.filter(models.PowerOutletTemplate, lookups=True) -@autotype_decorator(filtersets.PowerOutletTemplateFilterSet) -class PowerOutletTemplateFilter(BaseFilterMixin): - pass +class PowerOutletTemplateFilter(ModularComponentModelFilterMixin): + type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + power_port: Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + power_port_id: ID | None = strawberry_django.filter_field() + feed_leg: Annotated['PowerOutletFeedLegEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.PowerPanel, lookups=True) -@autotype_decorator(filtersets.PowerPanelFilterSet) -class PowerPanelFilter(BaseFilterMixin): - pass +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() + location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + name: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.PowerPort, lookups=True) -@autotype_decorator(filtersets.PowerPortFilterSet) -class PowerPortFilter(BaseFilterMixin): - pass +class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): + type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + maximum_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + allocated_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.PowerPortTemplate, lookups=True) -@autotype_decorator(filtersets.PowerPortTemplateFilterSet) -class PowerPortTemplateFilter(BaseFilterMixin): - pass +class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin): + type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + maximum_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + allocated_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.RackType, lookups=True) -@autotype_decorator(filtersets.RackTypeFilterSet) -class RackTypeFilter(BaseFilterMixin): - pass +class RackTypeFilter(RackBaseFilterMixin): + form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + model: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.Rack, lookups=True) -@autotype_decorator(filtersets.RackFilterSet) -class RackFilter(BaseFilterMixin): - pass +class RackFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, RackBaseFilterMixin): + form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + rack_type: Annotated['RackTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + rack_type_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + facility_id: FilterLookup[str] | None = strawberry_django.filter_field() + site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + site_id: ID | None = strawberry_django.filter_field() + location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + status: Annotated['RackStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + role: Annotated['RackRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + role_id: ID | None = strawberry_django.filter_field() + serial: FilterLookup[str] | None = strawberry_django.filter_field() + asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() + airflow: Annotated['RackAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.RackReservation, lookups=True) -@autotype_decorator(filtersets.RackReservationFilterSet) -class RackReservationFilter(BaseFilterMixin): - pass +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() + units: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() + user_id: ID | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.RackRole, lookups=True) -@autotype_decorator(filtersets.RackRoleFilterSet) -class RackRoleFilter(BaseFilterMixin): - pass +class RackRoleFilter(OrganizationalModelFilterMixin): + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() @strawberry_django.filter(models.RearPort, lookups=True) -@autotype_decorator(filtersets.RearPortFilterSet) -class RearPortFilter(BaseFilterMixin): - pass +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() + positions: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.RearPortTemplate, lookups=True) -@autotype_decorator(filtersets.RearPortTemplateFilterSet) -class RearPortTemplateFilter(BaseFilterMixin): - pass +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() + positions: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Region, lookups=True) -@autotype_decorator(filtersets.RegionFilterSet) -class RegionFilter(BaseFilterMixin): - pass +class RegionFilter(ContactFilterMixin, NestedGroupModelFilterMixin): + prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Site, lookups=True) -@autotype_decorator(filtersets.SiteFilterSet) -class SiteFilter(BaseFilterMixin): - pass +class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['SiteStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + region: Annotated['RegionFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + region_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + group: Annotated['SiteGroupFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + facility: FilterLookup[str] | None = strawberry_django.filter_field() + asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + time_zone: FilterLookup[str] | None = strawberry_django.filter_field() + physical_address: FilterLookup[str] | None = strawberry_django.filter_field() + shipping_address: FilterLookup[str] | None = strawberry_django.filter_field() + latitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + longitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.SiteGroup, lookups=True) -@autotype_decorator(filtersets.SiteGroupFilterSet) -class SiteGroupFilter(BaseFilterMixin): - pass +class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilterMixin): + prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.VirtualChassis, lookups=True) -@autotype_decorator(filtersets.VirtualChassisFilterSet) -class VirtualChassisFilter(BaseFilterMixin): - pass +class VirtualChassisFilter(PrimaryModelFilterMixin): + master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + master_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + domain: FilterLookup[str] | None = strawberry_django.filter_field() + member_count: FilterLookup[int] | None = strawberry_django.filter_field() @strawberry_django.filter(models.VirtualDeviceContext, lookups=True) -@autotype_decorator(filtersets.VirtualDeviceContextFilterSet) -class VirtualDeviceContextFilter(BaseFilterMixin): - pass +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() + name: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['VirtualDeviceContextStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + identifier: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + primary_ip4: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + primary_ip4_id: ID | None = strawberry_django.filter_field() + primary_ip6: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + primary_ip6_id: ID | None = strawberry_django.filter_field() + comments: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 8d992176a..6cd072479 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, List, Union +from typing import Annotated, List, TYPE_CHECKING, Union import strawberry import strawberry_django @@ -6,7 +6,11 @@ import strawberry_django from core.graphql.mixins import ChangelogMixin from dcim import models from extras.graphql.mixins import ( - ConfigContextMixin, ContactsMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin, + ConfigContextMixin, + ContactsMixin, + CustomFieldsMixin, + ImageAttachmentsMixin, + TagsMixin, ) from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin from netbox.graphql.scalars import BigInt @@ -14,6 +18,23 @@ from netbox.graphql.types import BaseObjectType, NetBoxObjectType, Organizationa from .filters import * from .mixins import CabledObjectMixin, PathEndpointMixin +if TYPE_CHECKING: + from circuits.graphql.types import CircuitTerminationType + from extras.graphql.types import ConfigTemplateType + from ipam.graphql.types import ( + ASNType, + IPAddressType, + PrefixType, + ServiceType, + VLANTranslationPolicyType, + VLANType, + VRFType, + ) + from tenancy.graphql.types import TenantType + from users.graphql.types import UserType + from virtualization.graphql.types import ClusterType, VMInterfaceType, VirtualMachineType + from wireless.graphql.types import WirelessLANType, WirelessLinkType + __all__ = ( 'CableType', 'ComponentType', @@ -111,7 +132,7 @@ class ModularComponentTemplateType(ComponentTemplateType): @strawberry_django.type( models.CableTermination, - exclude=('termination_type', 'termination_id', '_device', '_rack', '_location', '_site'), + exclude=['termination_type', 'termination_id', '_device', '_rack', '_location', '_site'], filters=CableTerminationFilter ) class CableTerminationType(NetBoxObjectType): @@ -167,7 +188,7 @@ class CableType(NetBoxObjectType): @strawberry_django.type( models.ConsolePort, - exclude=('_path',), + exclude=['_path'], filters=ConsolePortFilter ) class ConsolePortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): @@ -185,7 +206,7 @@ class ConsolePortTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.ConsoleServerPort, - exclude=('_path',), + exclude=['_path'], filters=ConsoleServerPortFilter ) class ConsoleServerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): @@ -276,7 +297,7 @@ class DeviceBayTemplateType(ComponentTemplateType): @strawberry_django.type( models.InventoryItemTemplate, - exclude=('component_type', 'component_id', 'parent'), + exclude=['component_type', 'component_id', 'parent'], filters=InventoryItemTemplateFilter ) class InventoryItemTemplateType(ComponentTemplateType): @@ -369,7 +390,7 @@ class FrontPortTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.MACAddress, - exclude=('assigned_object_type', 'assigned_object_id'), + exclude=['assigned_object_type', 'assigned_object_id'], filters=MACAddressFilter ) class MACAddressType(NetBoxObjectType): @@ -385,7 +406,7 @@ class MACAddressType(NetBoxObjectType): @strawberry_django.type( models.Interface, - exclude=('_path',), + exclude=['_path'], filters=InterfaceFilter ) class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, PathEndpointMixin): @@ -424,7 +445,7 @@ class InterfaceTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.InventoryItem, - exclude=('component_type', 'component_id', 'parent'), + exclude=['component_type', 'component_id', 'parent'], filters=InventoryItemFilter ) class InventoryItemType(ComponentType): @@ -463,7 +484,7 @@ class InventoryItemRoleType(OrganizationalObjectType): @strawberry_django.type( models.Location, # fields='__all__', - exclude=('parent',), # bug - temp + exclude=['parent'], # bug - temp filters=LocationFilter ) class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, OrganizationalObjectType): @@ -524,7 +545,7 @@ class ModuleType(NetBoxObjectType): @strawberry_django.type( models.ModuleBay, # fields='__all__', - exclude=('parent',), + exclude=['parent'], filters=ModuleBayFilter ) class ModuleBayType(ModularComponentType): @@ -579,7 +600,7 @@ class PlatformType(OrganizationalObjectType): @strawberry_django.type( models.PowerFeed, - exclude=('_path',), + exclude=['_path'], filters=PowerFeedFilter ) class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin): @@ -590,7 +611,7 @@ class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin): @strawberry_django.type( models.PowerOutlet, - exclude=('_path',), + exclude=['_path'], filters=PowerOutletFilter ) class PowerOutletType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): @@ -621,7 +642,7 @@ class PowerPanelType(NetBoxObjectType, ContactsMixin): @strawberry_django.type( models.PowerPort, - exclude=('_path',), + exclude=['_path'], filters=PowerPortFilter ) class PowerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): @@ -712,8 +733,7 @@ class RearPortTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.Region, - exclude=('parent',), - # fields='__all__', + exclude=['parent'], filters=RegionFilter ) class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): @@ -772,8 +792,7 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje @strawberry_django.type( models.SiteGroup, - # fields='__all__', - exclude=('parent',), # bug - temp + exclude=['parent'], # bug - temp filters=SiteGroupFilter ) class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): diff --git a/netbox/extras/graphql/enums.py b/netbox/extras/graphql/enums.py new file mode 100644 index 000000000..0d352b835 --- /dev/null +++ b/netbox/extras/graphql/enums.py @@ -0,0 +1,26 @@ +import strawberry + +from extras.choices import * + +__all__ = ( + 'CustomFieldChoiceSetBaseEnum', + 'CustomFieldFilterLogicEnum', + 'CustomFieldTypeEnum', + 'CustomFieldUIEditableEnum', + 'CustomFieldUIVisibleEnum', + 'CustomLinkButtonClassEnum', + 'EventRuleActionEnum', + 'JournalEntryKindEnum', + 'WebhookHttpMethodEnum', +) + + +CustomFieldChoiceSetBaseEnum = strawberry.enum(CustomFieldChoiceSetBaseChoices.as_enum()) +CustomFieldFilterLogicEnum = strawberry.enum(CustomFieldFilterLogicChoices.as_enum()) +CustomFieldTypeEnum = strawberry.enum(CustomFieldTypeChoices.as_enum()) +CustomFieldUIEditableEnum = strawberry.enum(CustomFieldUIEditableChoices.as_enum()) +CustomFieldUIVisibleEnum = strawberry.enum(CustomFieldUIVisibleChoices.as_enum()) +CustomLinkButtonClassEnum = strawberry.enum(CustomLinkButtonClassChoices.as_enum()) +EventRuleActionEnum = strawberry.enum(EventRuleActionChoices.as_enum()) +JournalEntryKindEnum = strawberry.enum(JournalEntryKindChoices.as_enum()) +WebhookHttpMethodEnum = strawberry.enum(WebhookHttpMethodChoices.as_enum()) diff --git a/netbox/extras/graphql/filter_mixins.py b/netbox/extras/graphql/filter_mixins.py new file mode 100644 index 000000000..7e9a970f2 --- /dev/null +++ b/netbox/extras/graphql/filter_mixins.py @@ -0,0 +1,52 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins import BaseFilterMixin + +if TYPE_CHECKING: + from netbox.graphql.filter_lookups import JSONFilter + from .filters import * + +__all__ = ( + 'CustomFieldsFilterMixin', + 'JournalEntriesFilterMixin', + 'TagsFilterMixin', + 'ConfigContextFilterMixin', + 'TagBaseFilterMixin', +) + + +@dataclass +class CustomFieldsFilterMixin(BaseFilterMixin): + custom_field_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class JournalEntriesFilterMixin(BaseFilterMixin): + journal_entries: Annotated['JournalEntryFilter', strawberry.lazy('extras.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class TagsFilterMixin(BaseFilterMixin): + tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field() + + +@dataclass +class ConfigContextFilterMixin(BaseFilterMixin): + local_context_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class TagBaseFilterMixin(BaseFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/extras/graphql/filters.py b/netbox/extras/graphql/filters.py index ff2e6a0f1..e22bda0ac 100644 --- a/netbox/extras/graphql/filters.py +++ b/netbox/extras/graphql/filters.py @@ -1,7 +1,26 @@ -import strawberry_django +from typing import Annotated, TYPE_CHECKING -from extras import filtersets, models -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin +from extras import models +from extras.graphql.filter_mixins import TagBaseFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin +from netbox.graphql.filter_mixins import SyncedDataFilterMixin + +if TYPE_CHECKING: + from core.graphql.filters import ContentTypeFilter + from dcim.graphql.filters import ( + DeviceRoleFilter, DeviceTypeFilter, LocationFilter, PlatformFilter, RegionFilter, SiteFilter, SiteGroupFilter, + ) + from tenancy.graphql.filters import TenantFilter, TenantGroupFilter + from netbox.graphql.enums import ColorEnum + from netbox.graphql.filter_lookups import IntegerLookup, JSONFilter, StringArrayLookup, TreeNodeFilter + from users.graphql.filters import GroupFilter, UserFilter + from virtualization.graphql.filters import ClusterFilter, ClusterGroupFilter, ClusterTypeFilter + from .enums import * __all__ = ( 'ConfigContextFilter', @@ -21,78 +40,263 @@ __all__ = ( @strawberry_django.filter(models.ConfigContext, lookups=True) -@autotype_decorator(filtersets.ConfigContextFilterSet) -class ConfigContextFilter(BaseFilterMixin): - pass +class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin): + name: FilterLookup[str] = strawberry_django.filter_field() + weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + description: FilterLookup[str] = strawberry_django.filter_field() + is_active: FilterLookup[bool] = strawberry_django.filter_field() + regions: Annotated['RegionFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + region_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + site_groups: Annotated['SiteGroupFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + site_group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + sites: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + locations: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + device_types: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + roles: Annotated['DeviceRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + platforms: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + cluster_types: Annotated['ClusterTypeFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + cluster_groups: Annotated['ClusterGroupFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + clusters: Annotated['ClusterFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tenant_groups: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tenant_group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + tenants: Annotated['TenantFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field() + data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ConfigTemplate, lookups=True) -@autotype_decorator(filtersets.ConfigTemplateFilterSet) -class ConfigTemplateFilter(BaseFilterMixin): - pass +class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + template_code: FilterLookup[str] | None = strawberry_django.filter_field() + environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.CustomField, lookups=True) -@autotype_decorator(filtersets.CustomFieldFilterSet) -class CustomFieldFilter(BaseFilterMixin): - pass +class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): + type: Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + object_types: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + related_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + name: FilterLookup[str] | None = strawberry_django.filter_field() + label: FilterLookup[str] | None = strawberry_django.filter_field() + group_name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + required: FilterLookup[bool] | None = strawberry_django.filter_field() + unique: FilterLookup[bool] | None = strawberry_django.filter_field() + search_weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + filter_logic: Annotated['CustomFieldFilterLogicEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + default: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + related_object_filter: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + validation_minimum: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + validation_maximum: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + validation_regex: FilterLookup[str] | None = strawberry_django.filter_field() + choice_set: Annotated['CustomFieldChoiceSetFilter', strawberry.lazy('extras.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + choice_set_id: ID | None = strawberry_django.filter_field() + ui_visible: Annotated['CustomFieldUIVisibleEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + ui_editable: Annotated['CustomFieldUIEditableEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + is_cloneable: FilterLookup[bool] | None = strawberry_django.filter_field() + comments: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.CustomFieldChoiceSet, lookups=True) -@autotype_decorator(filtersets.CustomFieldChoiceSetFilterSet) -class CustomFieldChoiceSetFilter(BaseFilterMixin): - pass +class CustomFieldChoiceSetFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + base_choices: Annotated['CustomFieldChoiceSetBaseEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + extra_choices: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + order_alphabetically: FilterLookup[bool] | None = strawberry_django.filter_field() @strawberry_django.filter(models.CustomLink, lookups=True) -@autotype_decorator(filtersets.CustomLinkFilterSet) -class CustomLinkFilter(BaseFilterMixin): - pass +class CustomLinkFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + link_text: FilterLookup[str] | None = strawberry_django.filter_field() + link_url: FilterLookup[str] | None = strawberry_django.filter_field() + weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + group_name: FilterLookup[str] | None = strawberry_django.filter_field() + button_class: Annotated['CustomLinkButtonClassEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + new_window: FilterLookup[bool] | None = strawberry_django.filter_field() @strawberry_django.filter(models.ExportTemplate, lookups=True) -@autotype_decorator(filtersets.ExportTemplateFilterSet) -class ExportTemplateFilter(BaseFilterMixin): - pass +class ExportTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + template_code: FilterLookup[str] | None = strawberry_django.filter_field() + mime_type: FilterLookup[str] | None = strawberry_django.filter_field() + file_extension: FilterLookup[str] | None = strawberry_django.filter_field() + as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field() @strawberry_django.filter(models.ImageAttachment, lookups=True) -@autotype_decorator(filtersets.ImageAttachmentFilterSet) -class ImageAttachmentFilter(BaseFilterMixin): - pass +class ImageAttachmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): + object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + object_id: ID | None = strawberry_django.filter_field() + image_height: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + image_width: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + name: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.JournalEntry, lookups=True) -@autotype_decorator(filtersets.JournalEntryFilterSet) -class JournalEntryFilter(BaseFilterMixin): - pass +class JournalEntryFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin): + assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + assigned_object_type_id: ID | None = strawberry_django.filter_field() + assigned_object_id: ID | None = strawberry_django.filter_field() + created_by: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + kind: Annotated['JournalEntryKindEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + comments: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.NotificationGroup, lookups=True) -@autotype_decorator(filtersets.NotificationGroupFilterSet) -class NotificationGroupFilter(BaseFilterMixin): - pass +class NotificationGroupFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + groups: Annotated['GroupFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() + users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() @strawberry_django.filter(models.SavedFilter, lookups=True) -@autotype_decorator(filtersets.SavedFilterFilterSet) -class SavedFilterFilter(BaseFilterMixin): - pass +class SavedFilterFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() + user_id: ID | None = strawberry_django.filter_field() + weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + shared: FilterLookup[bool] | None = strawberry_django.filter_field() + parameters: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Tag, lookups=True) -@autotype_decorator(filtersets.TagFilterSet) -class TagFilter(BaseFilterMixin): - pass +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) -@autotype_decorator(filtersets.WebhookFilterSet) -class WebhookFilter(BaseFilterMixin): - pass +class WebhookFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + payload_url: FilterLookup[str] | None = strawberry_django.filter_field() + http_method: Annotated['WebhookHttpMethodEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + http_content_type: FilterLookup[str] | None = strawberry_django.filter_field() + additional_headers: FilterLookup[str] | None = strawberry_django.filter_field() + body_template: FilterLookup[str] | None = strawberry_django.filter_field() + secret: FilterLookup[str] | None = strawberry_django.filter_field() + ssl_verification: FilterLookup[bool] | None = strawberry_django.filter_field() + ca_file_path: FilterLookup[str] | None = strawberry_django.filter_field() + events: Annotated['EventRuleFilter', strawberry.lazy('extras.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.EventRule, lookups=True) -@autotype_decorator(filtersets.EventRuleFilterSet) -class EventRuleFilter(BaseFilterMixin): - pass +class EventRuleFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + event_types: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + conditions: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + action_type: Annotated['EventRuleActionEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + action_object_type: FilterLookup[str] | None = strawberry_django.filter_field() + action_object_type_id: ID | None = strawberry_django.filter_field() + action_object_id: ID | None = strawberry_django.filter_field() + action_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + comments: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index a53c7bed3..1ceb2682c 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, List +from typing import Annotated, List, TYPE_CHECKING import strawberry import strawberry_django @@ -8,6 +8,22 @@ from extras.graphql.mixins import CustomFieldsMixin, TagsMixin from netbox.graphql.types import BaseObjectType, ContentTypeType, ObjectType, OrganizationalObjectType from .filters import * +if TYPE_CHECKING: + from core.graphql.types import DataFileType, DataSourceType + from dcim.graphql.types import ( + DeviceRoleType, + DeviceType, + DeviceTypeType, + LocationType, + PlatformType, + RegionType, + SiteGroupType, + SiteType, + ) + from tenancy.graphql.types import TenantGroupType, TenantType + from users.graphql.types import GroupType, UserType + from virtualization.graphql.types import ClusterGroupType, ClusterType, ClusterTypeType, VirtualMachineType + __all__ = ( 'ConfigContextType', 'ConfigTemplateType', @@ -35,7 +51,6 @@ __all__ = ( class ConfigContextType(ObjectType): data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None - roles: List[Annotated["DeviceRoleType", strawberry.lazy('dcim.graphql.types')]] device_types: List[Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')]] tags: List[Annotated["TagType", strawberry.lazy('extras.graphql.types')]] @@ -78,7 +93,7 @@ class CustomFieldType(ObjectType): @strawberry_django.type( models.CustomFieldChoiceSet, - exclude=('extra_choices', ), + exclude=['extra_choices'], filters=CustomFieldChoiceSetFilter ) class CustomFieldChoiceSetType(ObjectType): diff --git a/netbox/ipam/graphql/enums.py b/netbox/ipam/graphql/enums.py new file mode 100644 index 000000000..34fb1a6fd --- /dev/null +++ b/netbox/ipam/graphql/enums.py @@ -0,0 +1,27 @@ +import strawberry + +from ipam.choices import * + +__all__ = ( + 'FHRPGroupAuthTypeEnum', + 'FHRPGroupProtocolEnum', + 'IPAddressFamilyEnum', + 'IPAddressRoleEnum', + 'IPAddressStatusEnum', + 'IPRangeStatusEnum', + 'PrefixStatusEnum', + 'ServiceProtocolEnum', + 'VLANStatusEnum', + 'VLANQinQRoleEnum', +) + +FHRPGroupAuthTypeEnum = strawberry.enum(FHRPGroupAuthTypeChoices.as_enum()) +FHRPGroupProtocolEnum = strawberry.enum(FHRPGroupProtocolChoices.as_enum()) +IPAddressFamilyEnum = strawberry.enum(IPAddressFamilyChoices.as_enum()) +IPAddressRoleEnum = strawberry.enum(IPAddressRoleChoices.as_enum()) +IPAddressStatusEnum = strawberry.enum(IPAddressStatusChoices.as_enum()) +IPRangeStatusEnum = strawberry.enum(IPRangeStatusChoices.as_enum()) +PrefixStatusEnum = strawberry.enum(PrefixStatusChoices.as_enum()) +ServiceProtocolEnum = strawberry.enum(ServiceProtocolChoices.as_enum()) +VLANStatusEnum = strawberry.enum(VLANStatusChoices.as_enum()) +VLANQinQRoleEnum = strawberry.enum(VLANQinQRoleChoices.as_enum()) diff --git a/netbox/ipam/graphql/filter_mixins.py b/netbox/ipam/graphql/filter_mixins.py new file mode 100644 index 000000000..511850285 --- /dev/null +++ b/netbox/ipam/graphql/filter_mixins.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django + +from core.graphql.filter_mixins import BaseFilterMixin + +if TYPE_CHECKING: + from netbox.graphql.filter_lookups import IntegerLookup + from .enums import * + +__all__ = ( + 'ServiceBaseFilterMixin', +) + + +@dataclass +class ServiceBaseFilterMixin(BaseFilterMixin): + protocol: Annotated['ServiceProtocolEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + ports: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/ipam/graphql/filters.py b/netbox/ipam/graphql/filters.py index 1b0e0133b..2f4e185f1 100644 --- a/netbox/ipam/graphql/filters.py +++ b/netbox/ipam/graphql/filters.py @@ -1,7 +1,28 @@ -import strawberry_django +from datetime import date +from typing import Annotated, TYPE_CHECKING -from ipam import filtersets, models -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin +import netaddr +import strawberry +import strawberry_django +from django.db.models import Q +from netaddr.core import AddrFormatError +from strawberry.scalars import ID +from strawberry_django import FilterLookup, DateFilterLookup + +from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin +from dcim.graphql.filter_mixins import ScopedFilterMixin +from ipam import models +from ipam.graphql.filter_mixins import ServiceBaseFilterMixin +from netbox.graphql.filter_mixins import NetBoxModelFilterMixin, OrganizationalModelFilterMixin, PrimaryModelFilterMixin +from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin + +if TYPE_CHECKING: + from netbox.graphql.filter_lookups import IntegerArrayLookup, IntegerLookup + from core.graphql.filters import ContentTypeFilter + from dcim.graphql.filters import DeviceFilter, SiteFilter + from virtualization.graphql.filters import VirtualMachineFilter + from vpn.graphql.filters import L2VPNFilter + from .enums import * __all__ = ( 'ASNFilter', @@ -26,108 +47,258 @@ __all__ = ( @strawberry_django.filter(models.ASN, lookups=True) -@autotype_decorator(filtersets.ASNFilterSet) -class ASNFilter(BaseFilterMixin): - pass +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() + asn: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ASNRange, lookups=True) -@autotype_decorator(filtersets.ASNRangeFilterSet) -class ASNRangeFilter(BaseFilterMixin): - pass +class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + rir_id: ID | None = strawberry_django.filter_field() + start: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + end: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Aggregate, lookups=True) -@autotype_decorator(filtersets.AggregateFilterSet) -class AggregateFilter(BaseFilterMixin): - pass +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() + rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + rir_id: ID | None = strawberry_django.filter_field() + date_added: DateFilterLookup[date] | None = strawberry_django.filter_field() @strawberry_django.filter(models.FHRPGroup, lookups=True) -@autotype_decorator(filtersets.FHRPGroupFilterSet) -class FHRPGroupFilter(BaseFilterMixin): - pass +class FHRPGroupFilter(PrimaryModelFilterMixin): + group_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + name: FilterLookup[str] | None = strawberry_django.filter_field() + protocol: Annotated['FHRPGroupProtocolEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + auth_type: Annotated['FHRPGroupAuthTypeEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + auth_key: FilterLookup[str] | None = strawberry_django.filter_field() + ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.FHRPGroupAssignment, lookups=True) -@autotype_decorator(filtersets.FHRPGroupAssignmentFilterSet) -class FHRPGroupAssignmentFilter(BaseFilterMixin): - pass +class FHRPGroupAssignmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): + interface_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + interface_id: FilterLookup[str] | None = strawberry_django.filter_field() + group: Annotated['FHRPGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + group_id: ID | None = strawberry_django.filter_field() + priority: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.IPAddress, lookups=True) -@autotype_decorator(filtersets.IPAddressFilterSet) -class IPAddressFilter(BaseFilterMixin): - pass +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() + vrf_id: ID | None = strawberry_django.filter_field() + status: Annotated['IPAddressStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + role: Annotated['IPAddressRoleEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + assigned_object_id: ID | None = strawberry_django.filter_field() + nat_inside: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + nat_inside_id: ID | None = strawberry_django.filter_field() + nat_outside: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + nat_outside_id: ID | None = strawberry_django.filter_field() + dns_name: FilterLookup[str] | None = strawberry_django.filter_field() + + @strawberry_django.filter_field() + def parent(self, value: list[str], prefix) -> Q: + if not value: + return Q() + q = Q() + for subnet in value: + try: + query = str(netaddr.IPNetwork(subnet.strip()).cidr) + q |= Q(address__net_host_contained=query) + except (AddrFormatError, ValueError): + return Q() + return q @strawberry_django.filter(models.IPRange, lookups=True) -@autotype_decorator(filtersets.IPRangeFilterSet) -class IPRangeFilter(BaseFilterMixin): - pass +class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): + start_address: FilterLookup[str] | None = strawberry_django.filter_field() + end_address: FilterLookup[str] | None = strawberry_django.filter_field() + size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + vrf_id: ID | None = strawberry_django.filter_field() + status: Annotated['IPRangeStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + role: Annotated['IPAddressRoleEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + mark_utilized: FilterLookup[bool] | None = strawberry_django.filter_field() + + @strawberry_django.filter_field() + def parent(self, value: list[str], prefix) -> Q: + if not value: + return Q() + q = Q() + for subnet in value: + try: + query = str(netaddr.IPNetwork(subnet.strip()).cidr) + q |= Q(start_address__net_host_contained=query, end_address__net_host_contained=query) + except (AddrFormatError, ValueError): + return Q() + return q @strawberry_django.filter(models.Prefix, lookups=True) -@autotype_decorator(filtersets.PrefixFilterSet) -class PrefixFilter(BaseFilterMixin): - pass +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() + vrf_id: ID | None = strawberry_django.filter_field() + vlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + vlan_id: ID | None = strawberry_django.filter_field() + status: Annotated['PrefixStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + role: Annotated['RoleFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + role_id: ID | None = strawberry_django.filter_field() + is_pool: FilterLookup[bool] | None = strawberry_django.filter_field() + mark_utilized: FilterLookup[bool] | None = strawberry_django.filter_field() @strawberry_django.filter(models.RIR, lookups=True) -@autotype_decorator(filtersets.RIRFilterSet) -class RIRFilter(BaseFilterMixin): - pass +class RIRFilter(OrganizationalModelFilterMixin): + is_private: FilterLookup[bool] | None = strawberry_django.filter_field() @strawberry_django.filter(models.Role, lookups=True) -@autotype_decorator(filtersets.RoleFilterSet) -class RoleFilter(BaseFilterMixin): - pass +class RoleFilter(OrganizationalModelFilterMixin): + weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.RouteTarget, lookups=True) -@autotype_decorator(filtersets.RouteTargetFilterSet) -class RouteTargetFilter(BaseFilterMixin): - pass +class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.Service, lookups=True) -@autotype_decorator(filtersets.ServiceFilterSet) -class ServiceFilter(BaseFilterMixin): - pass +class ServiceFilter(ContactFilterMixin, ServiceBaseFilterMixin, PrimaryModelFilterMixin): + device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + device_id: ID | None = strawberry_django.filter_field() + virtual_machine: Annotated['VirtualMachineFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + virtual_machine_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + ipaddresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ServiceTemplate, lookups=True) -@autotype_decorator(filtersets.ServiceTemplateFilterSet) -class ServiceTemplateFilter(BaseFilterMixin): - pass +class ServiceTemplateFilter(ServiceBaseFilterMixin, PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.VLAN, lookups=True) -@autotype_decorator(filtersets.VLANFilterSet) -class VLANFilter(BaseFilterMixin): - pass +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() + group: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + group_id: ID | None = strawberry_django.filter_field() + vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + name: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['VLANStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = strawberry_django.filter_field() + role: Annotated['RoleFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + role_id: ID | None = strawberry_django.filter_field() + qinq_svlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + qinq_svlan_id: ID | None = strawberry_django.filter_field() + qinq_cvlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + qinq_cvlan_id: ID | None = strawberry_django.filter_field() + qinq_role: Annotated['VLANQinQRoleEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + l2vpn_terminations: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.VLANGroup, lookups=True) -@autotype_decorator(filtersets.VLANGroupFilterSet) -class VLANGroupFilter(BaseFilterMixin): - pass +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) -@autotype_decorator(filtersets.VLANTranslationPolicyFilterSet) -class VLANTranslationPolicyFilter(BaseFilterMixin): - pass +class VLANTranslationPolicyFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.VLANTranslationRule, lookups=True) -@autotype_decorator(filtersets.VLANTranslationRuleFilterSet) -class VLANTranslationRuleFilter(BaseFilterMixin): - pass +class VLANTranslationRuleFilter(NetBoxModelFilterMixin): + policy: Annotated['VLANTranslationPolicyFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + policy_id: ID | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + local_vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + remote_vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.VRF, lookups=True) -@autotype_decorator(filtersets.VRFFilterSet) -class VRFFilter(BaseFilterMixin): - pass +class VRFFilter(TenancyFilterMixin, PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + rd: FilterLookup[str] | None = strawberry_django.filter_field() + enforce_unique: FilterLookup[bool] | None = strawberry_django.filter_field() + import_targets: Annotated['RouteTargetFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + export_targets: Annotated['RouteTargetFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index b16cf29fe..e63bebcb1 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, List, Union +from typing import Annotated, List, TYPE_CHECKING, Union import strawberry import strawberry_django @@ -11,6 +11,21 @@ from netbox.graphql.types import BaseObjectType, NetBoxObjectType, Organizationa from .filters import * from .mixins import IPAddressesMixin +if TYPE_CHECKING: + from dcim.graphql.types import ( + DeviceType, + InterfaceType, + LocationType, + RackType, + RegionType, + SiteGroupType, + SiteType, + ) + from tenancy.graphql.types import TenantType + from virtualization.graphql.types import ClusterGroupType, ClusterType, VMInterfaceType, VirtualMachineType + from vpn.graphql.types import L2VPNType, TunnelTerminationType + from wireless.graphql.types import WirelessLANType + __all__ = ( 'ASNType', 'ASNRangeType', @@ -101,7 +116,7 @@ class FHRPGroupType(NetBoxObjectType, IPAddressesMixin): @strawberry_django.type( models.FHRPGroupAssignment, - exclude=('interface_type', 'interface_id'), + exclude=['interface_type', 'interface_id'], filters=FHRPGroupAssignmentFilter ) class FHRPGroupAssignmentType(BaseObjectType): @@ -117,7 +132,7 @@ class FHRPGroupAssignmentType(BaseObjectType): @strawberry_django.type( models.IPAddress, - exclude=('assigned_object_type', 'assigned_object_id', 'address'), + exclude=['assigned_object_type', 'assigned_object_id', 'address'], filters=IPAddressFilter ) class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType): @@ -154,7 +169,7 @@ class IPRangeType(NetBoxObjectType): @strawberry_django.type( models.Prefix, - exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'), + exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'], filters=PrefixFilter ) class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType): @@ -236,7 +251,7 @@ class ServiceTemplateType(NetBoxObjectType): @strawberry_django.type( models.VLAN, - exclude=('qinq_svlan',), + exclude=['qinq_svlan'], filters=VLANFilter ) class VLANType(NetBoxObjectType): @@ -259,7 +274,7 @@ class VLANType(NetBoxObjectType): @strawberry_django.type( models.VLANGroup, - exclude=('scope_type', 'scope_id'), + exclude=['scope_type', 'scope_id'], filters=VLANGroupFilter ) class VLANGroupType(OrganizationalObjectType): diff --git a/netbox/netbox/graphql/enums.py b/netbox/netbox/graphql/enums.py new file mode 100644 index 000000000..df62f8b3d --- /dev/null +++ b/netbox/netbox/graphql/enums.py @@ -0,0 +1,13 @@ +import strawberry + +from netbox.choices import * + +__all__ = ( + 'ColorEnum', + 'DistanceUnitEnum', + 'WeightUnitEnum', +) + +ColorEnum = strawberry.enum(ColorChoices.as_enum()) +DistanceUnitEnum = strawberry.enum(DistanceUnitChoices.as_enum()) +WeightUnitEnum = strawberry.enum(WeightUnitChoices.as_enum()) diff --git a/netbox/netbox/graphql/filter_lookups.py b/netbox/netbox/graphql/filter_lookups.py new file mode 100644 index 000000000..859236e4d --- /dev/null +++ b/netbox/netbox/graphql/filter_lookups.py @@ -0,0 +1,219 @@ +from enum import Enum +from typing import TypeVar, Tuple, Generic + +import strawberry +import strawberry_django +from django.core.exceptions import FieldDoesNotExist +from django.db.models import Q, QuerySet +from django.db.models.fields.related import ForeignKey, ManyToManyField, ManyToManyRel, ManyToOneRel +from strawberry import ID +from strawberry.types import Info +from strawberry_django import ( + ComparisonFilterLookup, + DateFilterLookup, + DatetimeFilterLookup, + FilterLookup, + RangeLookup, + TimeFilterLookup, + process_filters, +) + +__all__ = ( + 'ArrayLookup', + 'FloatArrayLookup', + 'FloatLookup', + 'IntegerArrayLookup', + 'IntegerLookup', + 'JSONFilter', + 'StringArrayLookup', + 'TreeNodeFilter', +) + +T = TypeVar('T') +SKIP_MSG = 'Filter will be skipped on `null` value' + + +@strawberry.input(one_of=True, description='Lookup for JSON field. Only one of the lookup fields can be set.') +class JSONLookup: + string_lookup: FilterLookup[str] | None = strawberry_django.filter_field() + int_range_lookup: RangeLookup[int] | None = strawberry_django.filter_field() + int_comparison_lookup: ComparisonFilterLookup[int] | None = strawberry_django.filter_field() + float_range_lookup: RangeLookup[float] | None = strawberry_django.filter_field() + float_comparison_lookup: ComparisonFilterLookup[float] | None = strawberry_django.filter_field() + date_lookup: DateFilterLookup[str] | None = strawberry_django.filter_field() + datetime_lookup: DatetimeFilterLookup[str] | None = strawberry_django.filter_field() + time_lookup: TimeFilterLookup[str] | None = strawberry_django.filter_field() + boolean_lookup: FilterLookup[bool] | None = strawberry_django.filter_field() + + def get_filter(self): + for field in self.__strawberry_definition__.fields: + value = getattr(self, field.name, None) + if value is not strawberry.UNSET: + return value + return None + + +@strawberry.input(one_of=True, description='Lookup for Integer fields. Only one of the lookup fields can be set.') +class IntegerLookup: + filter_lookup: FilterLookup[int] | None = strawberry_django.filter_field() + range_lookup: RangeLookup[int] | None = strawberry_django.filter_field() + comparison_lookup: ComparisonFilterLookup[int] | None = strawberry_django.filter_field() + + def get_filter(self): + for field in self.__strawberry_definition__.fields: + value = getattr(self, field.name, None) + if value is not strawberry.UNSET: + return value + return None + + @strawberry_django.filter_field + def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]: + filters = self.get_filter() + + if not filters: + return queryset, Q() + + return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix) + + +@strawberry.input(one_of=True, description='Lookup for Float fields. Only one of the lookup fields can be set.') +class FloatLookup: + filter_lookup: FilterLookup[float] | None = strawberry_django.filter_field() + range_lookup: RangeLookup[float] | None = strawberry_django.filter_field() + comparison_lookup: ComparisonFilterLookup[float] | None = strawberry_django.filter_field() + + def get_filter(self): + for field in self.__strawberry_definition__.fields: + value = getattr(self, field.name, None) + if value is not strawberry.UNSET: + return value + return None + + @strawberry_django.filter_field + def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]: + filters = self.get_filter() + + if not filters: + return queryset, Q() + + return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix) + + +@strawberry.input +class JSONFilter: + """ + Class for JSON field lookups with paths + """ + + path: str + lookup: JSONLookup + + @strawberry_django.filter_field + def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]: + filters = self.lookup.get_filter() + + if not filters: + return queryset, Q() + + json_path = f'{prefix}{self.path}__' + return process_filters(filters=filters, queryset=queryset, info=info, prefix=json_path) + + +@strawberry.enum +class TreeNodeMatch(Enum): + EXACT = 'exact' # Just the node itself + DESCENDANTS = 'descendants' # Node and all descendants + SELF_AND_DESCENDANTS = 'self_and_descendants' # Node and all descendants + CHILDREN = 'children' # Just immediate children + SIBLINGS = 'siblings' # Nodes with same parent + ANCESTORS = 'ancestors' # All parent nodes + PARENT = 'parent' # Just immediate parent + + +@strawberry.input +class TreeNodeFilter: + id: ID + match_type: TreeNodeMatch + + @strawberry_django.filter_field + def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]: + model_field_name = prefix.removesuffix('__').removesuffix('_id') + model_field = None + try: + model_field = queryset.model._meta.get_field(model_field_name) + except FieldDoesNotExist: + try: + model_field = queryset.model._meta.get_field(f'{model_field_name}s') + except FieldDoesNotExist: + return queryset, Q(pk__in=[]) + + if hasattr(model_field, 'related_model'): + related_model = model_field.related_model + else: + return queryset, Q(pk__in=[]) + + # Generate base Q filter for the related model without prefix + q_filter = generate_tree_node_q_filter(related_model, self) + + # Handle different relationship types + if isinstance(model_field, (ManyToManyField, ManyToManyRel)): + return queryset, Q(**{f'{model_field_name}__in': related_model.objects.filter(q_filter)}) + elif isinstance(model_field, ForeignKey): + return queryset, Q(**{f'{model_field_name}__{k}': v for k, v in q_filter.children}) + elif isinstance(model_field, ManyToOneRel): + return queryset, Q(**{f'{model_field_name}__in': related_model.objects.filter(q_filter)}) + else: + return queryset, Q(**{f'{model_field_name}__{k}': v for k, v in q_filter.children}) + + +def generate_tree_node_q_filter(model_class, filter_value: TreeNodeFilter) -> Q: + """ + Generate appropriate Q filter for MPTT tree filtering based on match type + """ + try: + node = model_class.objects.get(id=filter_value.id) + except model_class.DoesNotExist: + return Q(pk__in=[]) + + if filter_value.match_type == TreeNodeMatch.EXACT: + return Q(id=filter_value.id) + elif filter_value.match_type == TreeNodeMatch.DESCENDANTS: + return Q(tree_id=node.tree_id, lft__gt=node.lft, rght__lt=node.rght) + elif filter_value.match_type == TreeNodeMatch.SELF_AND_DESCENDANTS: + return Q(tree_id=node.tree_id, lft__gte=node.lft, rght__lte=node.rght) + elif filter_value.match_type == TreeNodeMatch.CHILDREN: + return Q(tree_id=node.tree_id, level=node.level + 1, lft__gt=node.lft, rght__lt=node.rght) + elif filter_value.match_type == TreeNodeMatch.SIBLINGS: + return Q(tree_id=node.tree_id, level=node.level, parent=node.parent) & ~Q(id=node.id) + elif filter_value.match_type == TreeNodeMatch.ANCESTORS: + return Q(tree_id=node.tree_id, lft__lt=node.lft, rght__gt=node.rght) + elif filter_value.match_type == TreeNodeMatch.PARENT: + return Q(id=node.parent_id) if node.parent_id else Q(pk__in=[]) + return Q() + + +@strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.') +class ArrayLookup(Generic[T]): + """ + Class for Array field lookups + """ + + contains: list[T] | None = strawberry_django.filter_field(description='Contains the value') + contained_by: list[T] | None = strawberry_django.filter_field(description='Contained by the value') + overlap: list[T] | None = strawberry_django.filter_field(description='Overlaps with the value') + length: int | None = strawberry_django.filter_field(description='Length of the array') + + +@strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.') +class IntegerArrayLookup(ArrayLookup[int]): + pass + + +@strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.') +class FloatArrayLookup(ArrayLookup[float]): + pass + + +@strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.') +class StringArrayLookup(ArrayLookup[str]): + pass diff --git a/netbox/netbox/graphql/filter_mixins.py b/netbox/netbox/graphql/filter_mixins.py index 2044a1dde..ce6d81a86 100644 --- a/netbox/netbox/graphql/filter_mixins.py +++ b/netbox/netbox/graphql/filter_mixins.py @@ -1,209 +1,109 @@ -from functools import partialmethod -from typing import List +from dataclasses import dataclass +from datetime import datetime +from typing import TypeVar, TYPE_CHECKING, Annotated -import django_filters import strawberry import strawberry_django -from django.core.exceptions import FieldDoesNotExist -from strawberry import auto +from strawberry import ID +from strawberry_django import FilterLookup, DatetimeFilterLookup -from ipam.fields import ASNField -from netbox.graphql.scalars import BigInt -from utilities.fields import ColorField, CounterCacheField -from utilities.filters import * +from core.graphql.filter_mixins import BaseFilterMixin, BaseObjectTypeFilterMixin, ChangeLogFilterMixin +from extras.graphql.filter_mixins import CustomFieldsFilterMixin, JournalEntriesFilterMixin, TagsFilterMixin +from netbox.graphql.filter_lookups import IntegerLookup + +__all__ = ( + 'DistanceFilterMixin', + 'ImageAttachmentFilterMixin', + 'NestedGroupModelFilterMixin', + 'NetBoxModelFilterMixin', + 'OrganizationalModelFilterMixin', + 'PrimaryModelFilterMixin', + 'SyncedDataFilterMixin', + 'WeightFilterMixin', +) + +T = TypeVar('T') -def map_strawberry_type(field): - should_create_function = False - attr_type = None - - # NetBox Filter types - put base classes after derived classes - if isinstance(field, ContentTypeFilter): - should_create_function = True - attr_type = str | None - elif isinstance(field, MultiValueArrayFilter): - pass - elif isinstance(field, MultiValueCharFilter): - # Note: Need to use the legacy FilterLookup from filters, not from - # strawberry_django.FilterLookup as we currently have USE_DEPRECATED_FILTERS - attr_type = strawberry_django.filters.FilterLookup[str] | None - elif isinstance(field, MultiValueDateFilter): - attr_type = auto - elif isinstance(field, MultiValueDateTimeFilter): - attr_type = auto - elif isinstance(field, MultiValueDecimalFilter): - pass - elif isinstance(field, MultiValueMACAddressFilter): - should_create_function = True - attr_type = List[str] | None - elif isinstance(field, MultiValueNumberFilter): - should_create_function = True - attr_type = List[str] | None - elif isinstance(field, MultiValueTimeFilter): - pass - elif isinstance(field, MultiValueWWNFilter): - should_create_function = True - attr_type = List[str] | None - elif isinstance(field, NullableCharFieldFilter): - pass - elif isinstance(field, NumericArrayFilter): - should_create_function = True - attr_type = int | None - elif isinstance(field, TreeNodeMultipleChoiceFilter): - should_create_function = True - attr_type = List[str] | None - - # From django_filters - ordering of these matters as base classes must - # come after derived classes so the base class doesn't get matched first - # a pass for the check (no attr_type) means we don't currently handle - # or use that type - elif issubclass(type(field), django_filters.OrderingFilter): - pass - elif issubclass(type(field), django_filters.BaseRangeFilter): - pass - elif issubclass(type(field), django_filters.BaseInFilter): - pass - elif issubclass(type(field), django_filters.LookupChoiceFilter): - pass - elif issubclass(type(field), django_filters.AllValuesMultipleFilter): - pass - elif issubclass(type(field), django_filters.AllValuesFilter): - pass - elif issubclass(type(field), django_filters.TimeRangeFilter): - pass - elif issubclass(type(field), django_filters.IsoDateTimeFromToRangeFilter): - should_create_function = True - attr_type = str | None - elif issubclass(type(field), django_filters.DateTimeFromToRangeFilter): - should_create_function = True - attr_type = str | None - elif issubclass(type(field), django_filters.DateFromToRangeFilter): - should_create_function = True - attr_type = str | None - elif issubclass(type(field), django_filters.DateRangeFilter): - should_create_function = True - attr_type = str | None - elif issubclass(type(field), django_filters.RangeFilter): - pass - elif issubclass(type(field), django_filters.NumericRangeFilter): - pass - elif issubclass(type(field), django_filters.NumberFilter): - should_create_function = True - attr_type = int | None - elif issubclass(type(field), django_filters.ModelMultipleChoiceFilter): - should_create_function = True - attr_type = List[str] | None - elif issubclass(type(field), django_filters.ModelChoiceFilter): - should_create_function = True - attr_type = str | None - elif issubclass(type(field), django_filters.DurationFilter): - pass - elif issubclass(type(field), django_filters.IsoDateTimeFilter): - pass - elif issubclass(type(field), django_filters.DateTimeFilter): - attr_type = auto - elif issubclass(type(field), django_filters.TimeFilter): - attr_type = auto - elif issubclass(type(field), django_filters.DateFilter): - attr_type = auto - elif issubclass(type(field), django_filters.TypedMultipleChoiceFilter): - pass - elif issubclass(type(field), django_filters.MultipleChoiceFilter): - attr_type = str | None - elif issubclass(type(field), django_filters.TypedChoiceFilter): - pass - elif issubclass(type(field), django_filters.ChoiceFilter): - pass - elif issubclass(type(field), django_filters.BooleanFilter): - should_create_function = True - attr_type = bool | None - elif issubclass(type(field), django_filters.UUIDFilter): - should_create_function = True - attr_type = str | None - elif issubclass(type(field), django_filters.CharFilter): - # looks like only used by 'q' - should_create_function = True - attr_type = str | None - - return should_create_function, attr_type +if TYPE_CHECKING: + from .enums import * + from core.graphql.filters import * + from extras.graphql.filters import * -def autotype_decorator(filterset): - """ - Decorator used to auto creates a dataclass used by Strawberry based on a filterset. - Must go after the Strawberry decorator as follows: - - @strawberry_django.filter(models.Example, lookups=True) - @autotype_decorator(filtersets.ExampleFilterSet) - class ExampleFilter(BaseFilterMixin): - pass - - The Filter itself must be derived from BaseFilterMixin. For items listed in meta.fields - of the filterset, usually just a type specifier is generated, so for - `fields = [created, ]` the dataclass would be: - - class ExampleFilter(BaseFilterMixin): - created: auto - - For other filter fields a function needs to be created for Strawberry with the - naming convention `filter_{fieldname}` which is auto detected and called by - Strawberry, this function uses the filterset to handle the query. - """ - def create_attribute_and_function(cls, fieldname, attr_type, should_create_function): - if fieldname not in cls.__annotations__ and attr_type: - cls.__annotations__[fieldname] = attr_type - - filter_name = f"filter_{fieldname}" - if should_create_function and not hasattr(cls, filter_name): - filter_by_filterset = getattr(cls, 'filter_by_filterset') - setattr(cls, filter_name, partialmethod(filter_by_filterset, key=fieldname)) - - def wrapper(cls): - cls.filterset = filterset - fields = filterset.get_fields() - model = filterset._meta.model - for fieldname in fields.keys(): - should_create_function = False - attr_type = auto - if fieldname not in cls.__annotations__: - try: - field = model._meta.get_field(fieldname) - except FieldDoesNotExist: - continue - - if isinstance(field, CounterCacheField): - should_create_function = True - attr_type = BigInt | None - elif isinstance(field, ASNField): - should_create_function = True - attr_type = List[str] | None - elif isinstance(field, ColorField): - should_create_function = True - attr_type = List[str] | None - - create_attribute_and_function(cls, fieldname, attr_type, should_create_function) - - declared_filters = filterset.declared_filters - for fieldname, field in declared_filters.items(): - - should_create_function, attr_type = map_strawberry_type(field) - if attr_type is None: - raise NotImplementedError(f"GraphQL Filter field unknown: {fieldname}: {field}") - - create_attribute_and_function(cls, fieldname, attr_type, should_create_function) - - return cls - - return wrapper +class NetBoxModelFilterMixin( + ChangeLogFilterMixin, + CustomFieldsFilterMixin, + JournalEntriesFilterMixin, + TagsFilterMixin, + BaseObjectTypeFilterMixin, +): + pass -@strawberry.input -class BaseFilterMixin: +@dataclass +class NestedGroupModelFilterMixin(NetBoxModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + lft: IntegerLookup | None = strawberry_django.filter_field() + rght: IntegerLookup | None = strawberry_django.filter_field() + tree_id: IntegerLookup | None = strawberry_django.filter_field() + level: IntegerLookup | None = strawberry_django.filter_field() + parent_id: ID | None = strawberry_django.filter_field() - def filter_by_filterset(self, queryset, key): - filterset = self.filterset(data={key: getattr(self, key)}, queryset=queryset) - if not filterset.is_valid(): - # We could raise validation error but strawberry logs it all to the - # console i.e. raise ValidationError(f"{k}: {v[0]}") - return filterset.qs.none() - return filterset.qs + +@dataclass +class OrganizationalModelFilterMixin( + ChangeLogFilterMixin, + CustomFieldsFilterMixin, + TagsFilterMixin, + BaseObjectTypeFilterMixin, +): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + + +@dataclass +class PrimaryModelFilterMixin(NetBoxModelFilterMixin): + description: FilterLookup[str] | None = strawberry_django.filter_field() + comments: FilterLookup[str] | None = strawberry_django.filter_field() + + +@dataclass +class ImageAttachmentFilterMixin(BaseFilterMixin): + images: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class WeightFilterMixin(BaseFilterMixin): + weight: FilterLookup[float] | None = strawberry_django.filter_field() + weight_unit: Annotated['WeightUnitEnum', strawberry.lazy('netbox.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class SyncedDataFilterMixin(BaseFilterMixin): + data_source: Annotated['DataSourceFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + data_source_id: FilterLookup[int] | None = strawberry_django.filter_field() + data_file: Annotated['DataFileFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + data_file_id: FilterLookup[int] | None = strawberry_django.filter_field() + data_path: FilterLookup[str] | None = strawberry_django.filter_field() + auto_sync_enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + data_synced: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + + +@dataclass +class DistanceFilterMixin(BaseFilterMixin): + distance: FilterLookup[float] | None = strawberry_django.filter_field() + distance_unit: Annotated['DistanceUnitEnum', strawberry.lazy('netbox.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py index a4fc99080..5df4cfd38 100644 --- a/netbox/netbox/graphql/types.py +++ b/netbox/netbox/graphql/types.py @@ -8,6 +8,7 @@ from extras.graphql.mixins import CustomFieldsMixin, JournalEntriesMixin, TagsMi __all__ = ( 'BaseObjectType', + 'ContentTypeType', 'ObjectType', 'OrganizationalObjectType', 'NetBoxObjectType', diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 0ad46b21e..bb187ac73 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -787,7 +787,6 @@ LOCALE_PATHS = ( STRAWBERRY_DJANGO = { "DEFAULT_PK_FIELD_NAME": "id", "TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING": True, - "USE_DEPRECATED_FILTERS": True, } # diff --git a/netbox/netbox/tests/test_graphql.py b/netbox/netbox/tests/test_graphql.py index b04d42d24..ca231526f 100644 --- a/netbox/netbox/tests/test_graphql.py +++ b/netbox/netbox/tests/test_graphql.py @@ -99,8 +99,8 @@ class GraphQLAPITestCase(APITestCase): # Test OR logic query = """{ location_list( filters: { - status: \"""" + LocationStatusChoices.STATUS_PLANNED + """\", - OR: {status: \"""" + LocationStatusChoices.STATUS_STAGING + """\"} + status: STATUS_PLANNED, + OR: {status: STATUS_STAGING} }) { id site {id} } diff --git a/netbox/tenancy/graphql/enums.py b/netbox/tenancy/graphql/enums.py new file mode 100644 index 000000000..90fc30483 --- /dev/null +++ b/netbox/tenancy/graphql/enums.py @@ -0,0 +1,9 @@ +import strawberry + +from tenancy.choices import * + +__all__ = ( + 'ContactPriorityEnum', +) + +ContactPriorityEnum = strawberry.enum(ContactPriorityChoices.as_enum()) diff --git a/netbox/tenancy/graphql/filter_mixins.py b/netbox/tenancy/graphql/filter_mixins.py new file mode 100644 index 000000000..cc4a4297c --- /dev/null +++ b/netbox/tenancy/graphql/filter_mixins.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry import ID + +from core.graphql.filter_mixins import BaseFilterMixin + +if TYPE_CHECKING: + from netbox.graphql.filter_lookups import TreeNodeFilter + from .filters import ContactFilter, TenantFilter, TenantGroupFilter + +__all__ = ( + 'ContactFilterMixin', + 'TenancyFilterMixin', +) + + +@dataclass +class ContactFilterMixin(BaseFilterMixin): + contacts: Annotated['ContactFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class TenancyFilterMixin(BaseFilterMixin): + tenant: Annotated['TenantFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tenant_id: ID | None = strawberry_django.filter_field() + tenant_group: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tenant_group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/tenancy/graphql/filters.py b/netbox/tenancy/graphql/filters.py index e82b1cd07..5abfa0a6c 100644 --- a/netbox/tenancy/graphql/filters.py +++ b/netbox/tenancy/graphql/filters.py @@ -1,7 +1,49 @@ -import strawberry_django +from typing import Annotated, TYPE_CHECKING -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin -from tenancy import filtersets, models +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins import ChangeLogFilterMixin +from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin +from netbox.graphql.filter_mixins import ( + NestedGroupModelFilterMixin, + OrganizationalModelFilterMixin, + PrimaryModelFilterMixin, +) +from tenancy import models +from .filter_mixins import ContactFilterMixin + +if TYPE_CHECKING: + from core.graphql.filters import ContentTypeFilter + from circuits.graphql.filters import CircuitFilter + from dcim.graphql.filters import ( + CableFilter, + DeviceFilter, + LocationFilter, + PowerFeedFilter, + RackFilter, + RackReservationFilter, + SiteFilter, + VirtualDeviceContextFilter, + ) + from ipam.graphql.filters import ( + AggregateFilter, + ASNFilter, + ASNRangeFilter, + IPAddressFilter, + IPRangeFilter, + PrefixFilter, + RouteTargetFilter, + VLANFilter, + VRFFilter, + ) + from netbox.graphql.filter_lookups import TreeNodeFilter + from wireless.graphql.filters import WirelessLANFilter, WirelessLinkFilter + from virtualization.graphql.filters import ClusterFilter, VirtualMachineFilter + from vpn.graphql.filters import L2VPNFilter, TunnelFilter + from .enums import * __all__ = ( 'TenantFilter', @@ -14,36 +56,132 @@ __all__ = ( @strawberry_django.filter(models.Tenant, lookups=True) -@autotype_decorator(filtersets.TenantFilterSet) -class TenantFilter(BaseFilterMixin): - pass +class TenantFilter(PrimaryModelFilterMixin, ContactFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + group: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + circuits: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + sites: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + vlans: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + wireless_lans: Annotated['WirelessLANFilter', strawberry.lazy('wireless.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + route_targets: Annotated['RouteTargetFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + locations: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + ip_ranges: Annotated['IPRangeFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + rackreservations: Annotated['RackReservationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + racks: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + vdcs: Annotated['VirtualDeviceContextFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + cables: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + virtual_machines: Annotated['VirtualMachineFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vrfs: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + asn_ranges: Annotated['ASNRangeFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + wireless_links: Annotated['WirelessLinkFilter', strawberry.lazy('wireless.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + aggregates: Annotated['AggregateFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + power_feeds: Annotated['PowerFeedFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + devices: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tunnels: Annotated['TunnelFilter', strawberry.lazy('vpn.graphql.filters')] | None = strawberry_django.filter_field() + ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + clusters: Annotated['ClusterFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + l2vpns: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = strawberry_django.filter_field() @strawberry_django.filter(models.TenantGroup, lookups=True) -@autotype_decorator(filtersets.TenantGroupFilterSet) -class TenantGroupFilter(BaseFilterMixin): - pass +class TenantGroupFilter(OrganizationalModelFilterMixin): + parent: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + parent_id: ID | None = strawberry.UNSET + tenants: Annotated['TenantFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + children: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters'), True] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Contact, lookups=True) -@autotype_decorator(filtersets.ContactFilterSet) -class ContactFilter(BaseFilterMixin): - pass +class ContactFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + title: FilterLookup[str] | None = strawberry_django.filter_field() + phone: FilterLookup[str] | None = strawberry_django.filter_field() + email: FilterLookup[str] | None = strawberry_django.filter_field() + address: FilterLookup[str] | None = strawberry_django.filter_field() + link: FilterLookup[str] | None = strawberry_django.filter_field() + group: Annotated['ContactGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + assignments: Annotated['ContactAssignmentFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ContactRole, lookups=True) -@autotype_decorator(filtersets.ContactRoleFilterSet) -class ContactRoleFilter(BaseFilterMixin): +class ContactRoleFilter(OrganizationalModelFilterMixin): pass @strawberry_django.filter(models.ContactGroup, lookups=True) -@autotype_decorator(filtersets.ContactGroupFilterSet) -class ContactGroupFilter(BaseFilterMixin): - pass +class ContactGroupFilter(NestedGroupModelFilterMixin): + parent: Annotated['ContactGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ContactAssignment, lookups=True) -@autotype_decorator(filtersets.ContactAssignmentFilterSet) -class ContactAssignmentFilter(BaseFilterMixin): - pass +class ContactAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin): + object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + object_id: ID | None = strawberry_django.filter_field() + contact: Annotated['ContactFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + contact_id: ID | None = strawberry_django.filter_field() + role: Annotated['ContactRoleFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + role_id: ID | None = strawberry_django.filter_field() + priority: Annotated['ContactPriorityEnum', strawberry.lazy('tenancy.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/tenancy/graphql/mixins.py b/netbox/tenancy/graphql/mixins.py index 9cdba100e..9437a06f2 100644 --- a/netbox/tenancy/graphql/mixins.py +++ b/netbox/tenancy/graphql/mixins.py @@ -9,5 +9,4 @@ __all__ = ( @strawberry.type class ContactAssignmentsMixin: - assignments: List[Annotated["ContactAssignmentType", strawberry.lazy('tenancy.graphql.types')]] # noqa: F821 diff --git a/netbox/tenancy/graphql/types.py b/netbox/tenancy/graphql/types.py index 7baa136b3..c340cdf7c 100644 --- a/netbox/tenancy/graphql/types.py +++ b/netbox/tenancy/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, List +from typing import Annotated, List, TYPE_CHECKING import strawberry import strawberry_django @@ -6,8 +6,36 @@ import strawberry_django from extras.graphql.mixins import CustomFieldsMixin, TagsMixin from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType from tenancy import models -from .mixins import ContactAssignmentsMixin from .filters import * +from .mixins import ContactAssignmentsMixin + +if TYPE_CHECKING: + from circuits.graphql.types import CircuitType + from dcim.graphql.types import ( + CableType, + DeviceType, + LocationType, + PowerFeedType, + RackType, + RackReservationType, + SiteType, + VirtualDeviceContextType, + ) + from ipam.graphql.types import ( + AggregateType, + ASNType, + ASNRangeType, + IPAddressType, + IPRangeType, + PrefixType, + RouteTargetType, + VLANType, + VRFType, + ) + from netbox.graphql.types import ContentTypeType + from wireless.graphql.types import WirelessLANType, WirelessLinkType + from virtualization.graphql.types import ClusterType, VirtualMachineType + from vpn.graphql.types import L2VPNType, TunnelType __all__ = ( 'ContactAssignmentType', @@ -23,92 +51,70 @@ __all__ = ( # Tenants # -@strawberry_django.type( - models.Tenant, - fields='__all__', - filters=TenantFilter -) + +@strawberry_django.type(models.Tenant, fields='__all__', filters=TenantFilter) class TenantType(NetBoxObjectType): - group: Annotated["TenantGroupType", strawberry.lazy('tenancy.graphql.types')] | None - - asns: List[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]] - circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]] - sites: List[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]] - vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] - wireless_lans: List[Annotated["WirelessLANType", strawberry.lazy('wireless.graphql.types')]] - route_targets: List[Annotated["RouteTargetType", strawberry.lazy('ipam.graphql.types')]] - locations: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]] - ip_ranges: List[Annotated["IPRangeType", strawberry.lazy('ipam.graphql.types')]] - rackreservations: List[Annotated["RackReservationType", strawberry.lazy('dcim.graphql.types')]] - racks: List[Annotated["RackType", strawberry.lazy('dcim.graphql.types')]] - vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]] - prefixes: List[Annotated["PrefixType", strawberry.lazy('ipam.graphql.types')]] - cables: List[Annotated["CableType", strawberry.lazy('dcim.graphql.types')]] - virtual_machines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]] - vrfs: List[Annotated["VRFType", strawberry.lazy('ipam.graphql.types')]] - asn_ranges: List[Annotated["ASNRangeType", strawberry.lazy('ipam.graphql.types')]] - wireless_links: List[Annotated["WirelessLinkType", strawberry.lazy('wireless.graphql.types')]] - aggregates: List[Annotated["AggregateType", strawberry.lazy('ipam.graphql.types')]] - power_feeds: List[Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')]] - devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] - tunnels: List[Annotated["TunnelType", strawberry.lazy('vpn.graphql.types')]] - ip_addresses: List[Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')]] - clusters: List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]] - l2vpns: List[Annotated["L2VPNType", strawberry.lazy('vpn.graphql.types')]] + group: Annotated['TenantGroupType', strawberry.lazy('tenancy.graphql.types')] | None + contacts: List[Annotated['ContactType', strawberry.lazy('tenancy.graphql.types')]] + asns: List[Annotated['ASNType', strawberry.lazy('ipam.graphql.types')]] + circuits: List[Annotated['CircuitType', strawberry.lazy('circuits.graphql.types')]] + sites: List[Annotated['SiteType', strawberry.lazy('dcim.graphql.types')]] + vlans: List[Annotated['VLANType', strawberry.lazy('ipam.graphql.types')]] + wireless_lans: List[Annotated['WirelessLANType', strawberry.lazy('wireless.graphql.types')]] + route_targets: List[Annotated['RouteTargetType', strawberry.lazy('ipam.graphql.types')]] + locations: List[Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]] + ip_ranges: List[Annotated['IPRangeType', strawberry.lazy('ipam.graphql.types')]] + rackreservations: List[Annotated['RackReservationType', strawberry.lazy('dcim.graphql.types')]] + racks: List[Annotated['RackType', strawberry.lazy('dcim.graphql.types')]] + vdcs: List[Annotated['VirtualDeviceContextType', strawberry.lazy('dcim.graphql.types')]] + prefixes: List[Annotated['PrefixType', strawberry.lazy('ipam.graphql.types')]] + cables: List[Annotated['CableType', strawberry.lazy('dcim.graphql.types')]] + virtual_machines: List[Annotated['VirtualMachineType', strawberry.lazy('virtualization.graphql.types')]] + vrfs: List[Annotated['VRFType', strawberry.lazy('ipam.graphql.types')]] + asn_ranges: List[Annotated['ASNRangeType', strawberry.lazy('ipam.graphql.types')]] + wireless_links: List[Annotated['WirelessLinkType', strawberry.lazy('wireless.graphql.types')]] + aggregates: List[Annotated['AggregateType', strawberry.lazy('ipam.graphql.types')]] + power_feeds: List[Annotated['PowerFeedType', strawberry.lazy('dcim.graphql.types')]] + devices: List[Annotated['DeviceType', strawberry.lazy('dcim.graphql.types')]] + tunnels: List[Annotated['TunnelType', strawberry.lazy('vpn.graphql.types')]] + ip_addresses: List[Annotated['IPAddressType', strawberry.lazy('ipam.graphql.types')]] + clusters: List[Annotated['ClusterType', strawberry.lazy('virtualization.graphql.types')]] + l2vpns: List[Annotated['L2VPNType', strawberry.lazy('vpn.graphql.types')]] -@strawberry_django.type( - models.TenantGroup, - fields='__all__', - filters=TenantGroupFilter -) +@strawberry_django.type(models.TenantGroup, fields='__all__', filters=TenantGroupFilter) class TenantGroupType(OrganizationalObjectType): - parent: Annotated["TenantGroupType", strawberry.lazy('tenancy.graphql.types')] | None + parent: Annotated['TenantGroupType', strawberry.lazy('tenancy.graphql.types')] | None tenants: List[TenantType] - children: List[Annotated["TenantGroupType", strawberry.lazy('tenancy.graphql.types')]] + children: List[Annotated['TenantGroupType', strawberry.lazy('tenancy.graphql.types')]] # # Contacts # -@strawberry_django.type( - models.Contact, - fields='__all__', - filters=ContactFilter -) + +@strawberry_django.type(models.Contact, fields='__all__', filters=ContactFilter) class ContactType(ContactAssignmentsMixin, NetBoxObjectType): - group: Annotated["ContactGroupType", strawberry.lazy('tenancy.graphql.types')] | None + group: Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')] | None -@strawberry_django.type( - models.ContactRole, - fields='__all__', - filters=ContactRoleFilter -) +@strawberry_django.type(models.ContactRole, fields='__all__', filters=ContactRoleFilter) class ContactRoleType(ContactAssignmentsMixin, OrganizationalObjectType): pass -@strawberry_django.type( - models.ContactGroup, - fields='__all__', - filters=ContactGroupFilter -) +@strawberry_django.type(models.ContactGroup, fields='__all__', filters=ContactGroupFilter) class ContactGroupType(OrganizationalObjectType): - parent: Annotated["ContactGroupType", strawberry.lazy('tenancy.graphql.types')] | None + parent: Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')] | None contacts: List[ContactType] - children: List[Annotated["ContactGroupType", strawberry.lazy('tenancy.graphql.types')]] + children: List[Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')]] -@strawberry_django.type( - models.ContactAssignment, - fields='__all__', - filters=ContactAssignmentFilter -) +@strawberry_django.type(models.ContactAssignment, fields='__all__', filters=ContactAssignmentFilter) class ContactAssignmentType(CustomFieldsMixin, TagsMixin, BaseObjectType): - object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None - contact: Annotated["ContactType", strawberry.lazy('tenancy.graphql.types')] | None - role: Annotated["ContactRoleType", strawberry.lazy('tenancy.graphql.types')] | None + object_type: Annotated['ContentTypeType', strawberry.lazy('netbox.graphql.types')] | None + contact: Annotated['ContactType', strawberry.lazy('tenancy.graphql.types')] | None + role: Annotated['ContactRoleType', strawberry.lazy('tenancy.graphql.types')] | None diff --git a/netbox/users/graphql/filters.py b/netbox/users/graphql/filters.py index d30781b1c..8f8a8f946 100644 --- a/netbox/users/graphql/filters.py +++ b/netbox/users/graphql/filters.py @@ -1,7 +1,12 @@ -import strawberry_django +from datetime import datetime +from typing import Annotated -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin -from users import filtersets, models +import strawberry +import strawberry_django +from strawberry_django import DatetimeFilterLookup, FilterLookup + +from core.graphql.filter_mixins import BaseObjectTypeFilterMixin +from users import models __all__ = ( 'GroupFilter', @@ -10,12 +15,20 @@ __all__ = ( @strawberry_django.filter(models.Group, lookups=True) -@autotype_decorator(filtersets.GroupFilterSet) -class GroupFilter(BaseFilterMixin): - pass +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) -@autotype_decorator(filtersets.UserFilterSet) -class UserFilter(BaseFilterMixin): - pass +class UserFilter(BaseObjectTypeFilterMixin): + username: FilterLookup[str] | None = strawberry_django.filter_field() + first_name: FilterLookup[str] | None = strawberry_django.filter_field() + last_name: FilterLookup[str] | None = strawberry_django.filter_field() + email: FilterLookup[str] | None = strawberry_django.filter_field() + is_superuser: FilterLookup[bool] | None = strawberry_django.filter_field() + is_staff: FilterLookup[bool] | None = strawberry_django.filter_field() + is_active: FilterLookup[bool] | None = strawberry_django.filter_field() + date_joined: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + last_login: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + groups: Annotated['GroupFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index 25d055942..7b3648afa 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -1,3 +1,5 @@ +import enum + from django.conf import settings from django.utils.translation import gettext_lazy as _ @@ -65,6 +67,23 @@ class ChoiceSet(metaclass=ChoiceSetMeta): def values(cls): return [c[0] for c in unpack_grouped_choices(cls._choices)] + @classmethod + def as_enum(cls, name=None): + """ + Return the ChoiceSet as an Enum. If no name is provided, "Choices" will be stripped from the class name (if + present) and "Enum" will be appended. For example, "CircuitStatusChoices" will become "CircuitStatusEnum". + """ + name = name or f"{cls.__name__.split('Choices')[0]}Enum" + data = {} + choices = cls.values() + + for attr in dir(cls): + value = getattr(cls, attr) + if attr.isupper() and value in choices: + data[attr] = value + + return enum.Enum(name, data) + def unpack_grouped_choices(choices): """ diff --git a/netbox/virtualization/graphql/enums.py b/netbox/virtualization/graphql/enums.py new file mode 100644 index 000000000..5b1c54e0c --- /dev/null +++ b/netbox/virtualization/graphql/enums.py @@ -0,0 +1,11 @@ +import strawberry + +from virtualization.choices import * + +__all__ = ( + 'ClusterStatusEnum', + 'VirtualMachineStatusEnum', +) + +ClusterStatusEnum = strawberry.enum(ClusterStatusChoices.as_enum()) +VirtualMachineStatusEnum = strawberry.enum(VirtualMachineStatusChoices.as_enum()) diff --git a/netbox/virtualization/graphql/filter_mixins.py b/netbox/virtualization/graphql/filter_mixins.py new file mode 100644 index 000000000..e4c334425 --- /dev/null +++ b/netbox/virtualization/graphql/filter_mixins.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry import ID +from strawberry_django import FilterLookup + +from netbox.graphql.filter_mixins import NetBoxModelFilterMixin + +if TYPE_CHECKING: + from .filters import VirtualMachineFilter + +__all__ = ( + 'VMComponentFilterMixin', +) + + +@dataclass +class VMComponentFilterMixin(NetBoxModelFilterMixin): + virtual_manchine: Annotated['VirtualMachineFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + virtual_machine_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/virtualization/graphql/filters.py b/netbox/virtualization/graphql/filters.py index 610275d37..ab4753616 100644 --- a/netbox/virtualization/graphql/filters.py +++ b/netbox/virtualization/graphql/filters.py @@ -1,7 +1,33 @@ -import strawberry_django +from typing import Annotated, TYPE_CHECKING -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin -from virtualization import filtersets, models +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup + +from dcim.graphql.filter_mixins import InterfaceBaseFilterMixin, RenderConfigFilterMixin, ScopedFilterMixin +from extras.graphql.filter_mixins import ConfigContextFilterMixin +from netbox.graphql.filter_mixins import ( + ImageAttachmentFilterMixin, + OrganizationalModelFilterMixin, + PrimaryModelFilterMixin, +) +from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin +from virtualization import models +from virtualization.graphql.filter_mixins import VMComponentFilterMixin + +if TYPE_CHECKING: + from .enums import * + from netbox.graphql.filter_lookups import FloatLookup, IntegerLookup + from dcim.graphql.filters import DeviceFilter, DeviceRoleFilter, MACAddressFilter, PlatformFilter, SiteFilter + from ipam.graphql.filters import ( + FHRPGroupAssignmentFilter, + IPAddressFilter, + ServiceFilter, + VLANGroupFilter, + VRFFilter, + ) + from vpn.graphql.filters import L2VPNFilter, TunnelTerminationFilter __all__ = ( 'ClusterFilter', @@ -14,36 +40,119 @@ __all__ = ( @strawberry_django.filter(models.Cluster, lookups=True) -@autotype_decorator(filtersets.ClusterFilterSet) -class ClusterFilter(BaseFilterMixin): - pass +class ClusterFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + type: Annotated['ClusterTypeFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + type_id: ID | None = strawberry_django.filter_field() + group: Annotated['ClusterGroupFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + group_id: ID | None = strawberry_django.filter_field() + status: Annotated['ClusterStatusEnum', strawberry.lazy('virtualization.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ClusterGroup, lookups=True) -@autotype_decorator(filtersets.ClusterGroupFilterSet) -class ClusterGroupFilter(BaseFilterMixin): - pass +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) -@autotype_decorator(filtersets.ClusterTypeFilterSet) -class ClusterTypeFilter(BaseFilterMixin): +class ClusterTypeFilter(OrganizationalModelFilterMixin): pass @strawberry_django.filter(models.VirtualMachine, lookups=True) -@autotype_decorator(filtersets.VirtualMachineFilterSet) -class VirtualMachineFilter(BaseFilterMixin): - pass +class VirtualMachineFilter( + ContactFilterMixin, + ImageAttachmentFilterMixin, + RenderConfigFilterMixin, + ConfigContextFilterMixin, + TenancyFilterMixin, + PrimaryModelFilterMixin, +): + name: FilterLookup[str] | None = strawberry_django.filter_field() + site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + site_id: ID | None = strawberry_django.filter_field() + cluster: Annotated['ClusterFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + cluster_id: ID | None = strawberry_django.filter_field() + device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + device_id: ID | None = strawberry_django.filter_field() + platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + platform_id: ID | None = strawberry_django.filter_field() + status: Annotated['VirtualMachineStatusEnum', strawberry.lazy('virtualization.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + role: Annotated['DeviceRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + role_id: ID | None = strawberry_django.filter_field() + primary_ip4: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + primary_ip4_id: ID | None = strawberry_django.filter_field() + primary_ip6: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + primary_ip6_id: ID | None = strawberry_django.filter_field() + vcpus: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + memory: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + disk: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + serial: FilterLookup[str] | None = strawberry_django.filter_field() + interface_count: FilterLookup[int] | None = strawberry_django.filter_field() + virtual_disk_count: FilterLookup[int] | None = strawberry_django.filter_field() + interfaces: Annotated['VMInterfaceFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + services: Annotated['ServiceFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + virtual_disks: Annotated['VirtualDiskFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.VMInterface, lookups=True) -@autotype_decorator(filtersets.VMInterfaceFilterSet) -class VMInterfaceFilter(BaseFilterMixin): - pass +class VMInterfaceFilter(VMComponentFilterMixin, InterfaceBaseFilterMixin): + ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + vrf_id: ID | None = strawberry_django.filter_field() + fhrp_group_assignments: Annotated['FHRPGroupAssignmentFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tunnel_terminations: Annotated['TunnelTerminationFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + l2vpn_terminations: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + mac_addresses: Annotated['MACAddressFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.VirtualDisk, lookups=True) -@autotype_decorator(filtersets.VirtualDiskFilterSet) -class VirtualDiskFilter(BaseFilterMixin): - pass +class VirtualDiskFilter(VMComponentFilterMixin): + size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index 33d6ce450..2fcffc20f 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, List, Union +from typing import Annotated, List, TYPE_CHECKING, Union import strawberry import strawberry_django @@ -10,6 +10,21 @@ from netbox.graphql.types import OrganizationalObjectType, NetBoxObjectType from virtualization import models from .filters import * +if TYPE_CHECKING: + from dcim.graphql.types import ( + DeviceRoleType, + DeviceType, + LocationType, + MACAddressType, + PlatformType, + RegionType, + SiteGroupType, + SiteType, + ) + from extras.graphql.types import ConfigTemplateType + from ipam.graphql.types import IPAddressType, ServiceType, VLANTranslationPolicyType, VLANType, VRFType + from tenancy.graphql.types import TenantType + __all__ = ( 'ClusterType', 'ClusterGroupType', @@ -30,7 +45,7 @@ class ComponentType(NetBoxObjectType): @strawberry_django.type( models.Cluster, - exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'), + exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'], filters=ClusterFilter ) class ClusterType(VLANGroupsMixin, NetBoxObjectType): diff --git a/netbox/vpn/graphql/enums.py b/netbox/vpn/graphql/enums.py new file mode 100644 index 000000000..053932ade --- /dev/null +++ b/netbox/vpn/graphql/enums.py @@ -0,0 +1,31 @@ +import strawberry + +from vpn.choices import * + +__all__ = ( + 'AuthenticationAlgorithmEnum', + 'AuthenticationMethodEnum', + 'DHGroupEnum', + 'EncryptionAlgorithmEnum', + 'IKEModeEnum', + 'IKEVersionEnum', + 'IPSecModeEnum', + 'L2VPNTypeEnum', + 'TunnelEncapsulationEnum', + 'TunnelStatusEnum', + 'TunnelTerminationRoleEnum', + 'TunnelTerminationTypeEnum', +) + +AuthenticationAlgorithmEnum = strawberry.enum(AuthenticationAlgorithmChoices.as_enum()) +AuthenticationMethodEnum = strawberry.enum(AuthenticationMethodChoices.as_enum()) +DHGroupEnum = strawberry.enum(DHGroupChoices.as_enum()) +EncryptionAlgorithmEnum = strawberry.enum(EncryptionAlgorithmChoices.as_enum()) +IKEModeEnum = strawberry.enum(IKEModeChoices.as_enum()) +IKEVersionEnum = strawberry.enum(IKEVersionChoices.as_enum()) +IPSecModeEnum = strawberry.enum(IPSecModeChoices.as_enum()) +L2VPNTypeEnum = strawberry.enum(L2VPNTypeChoices.as_enum()) +TunnelEncapsulationEnum = strawberry.enum(TunnelEncapsulationChoices.as_enum()) +TunnelStatusEnum = strawberry.enum(TunnelStatusChoices.as_enum()) +TunnelTerminationRoleEnum = strawberry.enum(TunnelTerminationRoleChoices.as_enum()) +TunnelTerminationTypeEnum = strawberry.enum(TunnelTerminationTypeChoices.as_enum()) diff --git a/netbox/vpn/graphql/filters.py b/netbox/vpn/graphql/filters.py index 34594458b..4e12012dd 100644 --- a/netbox/vpn/graphql/filters.py +++ b/netbox/vpn/graphql/filters.py @@ -1,7 +1,21 @@ -import strawberry_django +from typing import Annotated, TYPE_CHECKING -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin -from vpn import filtersets, models +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin +from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin +from netbox.graphql.filter_mixins import NetBoxModelFilterMixin, OrganizationalModelFilterMixin, PrimaryModelFilterMixin +from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin +from vpn import models + +if TYPE_CHECKING: + from core.graphql.filters import ContentTypeFilter + from ipam.graphql.filters import IPAddressFilter, RouteTargetFilter + from netbox.graphql.filter_lookups import IntegerLookup + from .enums import * __all__ = ( 'TunnelGroupFilter', @@ -18,60 +32,143 @@ __all__ = ( @strawberry_django.filter(models.TunnelGroup, lookups=True) -@autotype_decorator(filtersets.TunnelGroupFilterSet) -class TunnelGroupFilter(BaseFilterMixin): +class TunnelGroupFilter(OrganizationalModelFilterMixin): pass @strawberry_django.filter(models.TunnelTermination, lookups=True) -@autotype_decorator(filtersets.TunnelTerminationFilterSet) -class TunnelTerminationFilter(BaseFilterMixin): - pass +class TunnelTerminationFilter( + BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin +): + tunnel: Annotated['TunnelFilter', strawberry.lazy('vpn.graphql.filters')] | None = strawberry_django.filter_field() + tunnel_id: ID | None = strawberry_django.filter_field() + role: Annotated['TunnelTerminationRoleEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + termination_type: Annotated['TunnelTerminationTypeEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + termination_type_id: ID | None = strawberry_django.filter_field() + termination_id: ID | None = strawberry_django.filter_field() + outside_ip: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + outside_ip_id: ID | None = strawberry_django.filter_field() @strawberry_django.filter(models.Tunnel, lookups=True) -@autotype_decorator(filtersets.TunnelFilterSet) -class TunnelFilter(BaseFilterMixin): - pass +class TunnelFilter(TenancyFilterMixin, PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['TunnelStatusEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + group: Annotated['TunnelGroupFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + group_id: ID | None = strawberry_django.filter_field() + encapsulation: Annotated['TunnelEncapsulationEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + ipsec_profile: Annotated['IPSecProfileFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tunnel_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.IKEProposal, lookups=True) -@autotype_decorator(filtersets.IKEProposalFilterSet) -class IKEProposalFilter(BaseFilterMixin): - pass +class IKEProposalFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + authentication_method: Annotated['AuthenticationMethodEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + encryption_algorithm: Annotated['EncryptionAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + authentication_algorithm: Annotated['AuthenticationAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + group: Annotated['DHGroupEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() + sa_lifetime: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.IKEPolicy, lookups=True) -@autotype_decorator(filtersets.IKEPolicyFilterSet) -class IKEPolicyFilter(BaseFilterMixin): - pass +class IKEPolicyFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + version: Annotated['IKEVersionEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() + mode: Annotated['IKEModeEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() + proposals: Annotated['IKEProposalFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + preshared_key: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.IPSecProposal, lookups=True) -@autotype_decorator(filtersets.IPSecProposalFilterSet) -class IPSecProposalFilter(BaseFilterMixin): - pass +class IPSecProposalFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + encryption_algorithm: Annotated['EncryptionAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + authentication_algorithm: Annotated['AuthenticationAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + sa_lifetime_seconds: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + sa_lifetime_data: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.IPSecPolicy, lookups=True) -@autotype_decorator(filtersets.IPSecPolicyFilterSet) -class IPSecPolicyFilter(BaseFilterMixin): - pass +class IPSecPolicyFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + proposals: Annotated['IPSecProposalFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + pfs_group: Annotated['DHGroupEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() @strawberry_django.filter(models.IPSecProfile, lookups=True) -@autotype_decorator(filtersets.IPSecProfileFilterSet) -class IPSecProfileFilter(BaseFilterMixin): - pass +class IPSecProfileFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + mode: Annotated['IPSecModeEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() + ike_policy: Annotated['IKEPolicyFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + ike_policy_id: ID | None = strawberry_django.filter_field() + ipsec_policy: Annotated['IPSecPolicyFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + ipsec_policy_id: ID | None = strawberry_django.filter_field() @strawberry_django.filter(models.L2VPN, lookups=True) -@autotype_decorator(filtersets.L2VPNFilterSet) -class L2VPNFilter(BaseFilterMixin): - pass +class L2VPNFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + type: Annotated['L2VPNTypeEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() + identifier: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + import_targets: Annotated['RouteTargetFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + export_targets: Annotated['RouteTargetFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.L2VPNTermination, lookups=True) -@autotype_decorator(filtersets.L2VPNTerminationFilterSet) -class L2VPNTerminationFilter(BaseFilterMixin): - pass +class L2VPNTerminationFilter(NetBoxModelFilterMixin): + l2vpn: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = strawberry_django.filter_field() + l2vpn_id: ID | None = strawberry_django.filter_field() + assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + assigned_object_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/vpn/graphql/types.py b/netbox/vpn/graphql/types.py index 7940bd326..cc133ee6f 100644 --- a/netbox/vpn/graphql/types.py +++ b/netbox/vpn/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, List, Union +from typing import Annotated, List, TYPE_CHECKING, Union import strawberry import strawberry_django @@ -8,6 +8,13 @@ from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObj from vpn import models from .filters import * +if TYPE_CHECKING: + from dcim.graphql.types import InterfaceType + from ipam.graphql.types import IPAddressType, RouteTargetType, VLANType + from netbox.graphql.types import ContentTypeType + from tenancy.graphql.types import TenantType + from virtualization.graphql.types import VMInterfaceType + __all__ = ( 'IKEPolicyType', 'IKEProposalType', @@ -125,7 +132,7 @@ class L2VPNType(ContactsMixin, NetBoxObjectType): @strawberry_django.type( models.L2VPNTermination, - exclude=('assigned_object_type', 'assigned_object_id'), + exclude=['assigned_object_type', 'assigned_object_id'], filters=L2VPNTerminationFilter ) class L2VPNTerminationType(NetBoxObjectType): diff --git a/netbox/wireless/graphql/enums.py b/netbox/wireless/graphql/enums.py new file mode 100644 index 000000000..d3c6ad21a --- /dev/null +++ b/netbox/wireless/graphql/enums.py @@ -0,0 +1,17 @@ +import strawberry + +from wireless.choices import * + +__all__ = ( + 'WirelessAuthCipherEnum', + 'WirelessAuthTypeEnum', + 'WirelessChannelEnum', + 'WirelessLANStatusEnum', + 'WirelessRoleEnum', +) + +WirelessAuthCipherEnum = strawberry.enum(WirelessAuthCipherChoices.as_enum()) +WirelessAuthTypeEnum = strawberry.enum(WirelessAuthTypeChoices.as_enum()) +WirelessChannelEnum = strawberry.enum(WirelessChannelChoices.as_enum()) +WirelessLANStatusEnum = strawberry.enum(WirelessLANStatusChoices.as_enum()) +WirelessRoleEnum = strawberry.enum(WirelessRoleChoices.as_enum()) diff --git a/netbox/wireless/graphql/filter_mixins.py b/netbox/wireless/graphql/filter_mixins.py new file mode 100644 index 000000000..636bc8a52 --- /dev/null +++ b/netbox/wireless/graphql/filter_mixins.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins import BaseFilterMixin + +if TYPE_CHECKING: + from .enums import * + +__all__ = ( + 'WirelessAuthenticationBaseFilterMixin', +) + + +@dataclass +class WirelessAuthenticationBaseFilterMixin(BaseFilterMixin): + auth_type: Annotated['WirelessAuthTypeEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + auth_cipher: Annotated['WirelessAuthCipherEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + auth_psk: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/wireless/graphql/filters.py b/netbox/wireless/graphql/filters.py index 47d04bedc..d71af7ae2 100644 --- a/netbox/wireless/graphql/filters.py +++ b/netbox/wireless/graphql/filters.py @@ -1,7 +1,20 @@ -import strawberry_django +from typing import Annotated, TYPE_CHECKING -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin -from wireless import filtersets, models +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup + +from dcim.graphql.filter_mixins import ScopedFilterMixin +from netbox.graphql.filter_mixins import DistanceFilterMixin, PrimaryModelFilterMixin, NestedGroupModelFilterMixin +from tenancy.graphql.filter_mixins import TenancyFilterMixin +from wireless import models +from .filter_mixins import WirelessAuthenticationBaseFilterMixin + +if TYPE_CHECKING: + from dcim.graphql.filters import InterfaceFilter + from ipam.graphql.filters import VLANFilter + from .enums import * __all__ = ( 'WirelessLANGroupFilter', @@ -11,18 +24,45 @@ __all__ = ( @strawberry_django.filter(models.WirelessLANGroup, lookups=True) -@autotype_decorator(filtersets.WirelessLANGroupFilterSet) -class WirelessLANGroupFilter(BaseFilterMixin): +class WirelessLANGroupFilter(NestedGroupModelFilterMixin): pass @strawberry_django.filter(models.WirelessLAN, lookups=True) -@autotype_decorator(filtersets.WirelessLANFilterSet) -class WirelessLANFilter(BaseFilterMixin): - pass +class WirelessLANFilter( + WirelessAuthenticationBaseFilterMixin, + ScopedFilterMixin, + TenancyFilterMixin, + PrimaryModelFilterMixin +): + ssid: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['WirelessLANStatusEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + group: Annotated['WirelessLANGroupFilter', strawberry.lazy('wireless.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + group_id: ID | None = strawberry_django.filter_field() + vlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + vlan_id: ID | None = strawberry_django.filter_field() @strawberry_django.filter(models.WirelessLink, lookups=True) -@autotype_decorator(filtersets.WirelessLinkFilterSet) -class WirelessLinkFilter(BaseFilterMixin): - pass +class WirelessLinkFilter( + WirelessAuthenticationBaseFilterMixin, + DistanceFilterMixin, + TenancyFilterMixin, + PrimaryModelFilterMixin +): + interface_a: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + interface_a_id: ID | None = strawberry_django.filter_field() + interface_b: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + interface_b_id: ID | None = strawberry_django.filter_field() + ssid: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['WirelessLANStatusEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py index aa44e9b9f..7c31dfec8 100644 --- a/netbox/wireless/graphql/types.py +++ b/netbox/wireless/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, List, Union +from typing import Annotated, List, TYPE_CHECKING, Union import strawberry import strawberry_django @@ -7,6 +7,11 @@ from netbox.graphql.types import OrganizationalObjectType, NetBoxObjectType from wireless import models from .filters import * +if TYPE_CHECKING: + from dcim.graphql.types import DeviceType, InterfaceType, LocationType, RegionType, SiteGroupType, SiteType + from ipam.graphql.types import VLANType + from tenancy.graphql.types import TenantType + __all__ = ( 'WirelessLANType', 'WirelessLANGroupType', @@ -28,7 +33,7 @@ class WirelessLANGroupType(OrganizationalObjectType): @strawberry_django.type( models.WirelessLAN, - exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'), + exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'], filters=WirelessLANFilter ) class WirelessLANType(NetBoxObjectType): From b5d970f7bbee10c34c6086b41708f1b379f683bd Mon Sep 17 00:00:00 2001 From: bctiemann Date: Mon, 10 Mar 2025 10:51:41 -0400 Subject: [PATCH 032/103] Closes: #18535 - Skip incompatible plugins during startup (#18537) * Skip incompatible plugins during startup and remove from PLUGINS * Handle exceptions on request processors in incompatible plugins, and display status in Plugins page * Revert "Handle exceptions on request processors in incompatible plugins, and display status in Plugins page" This reverts commit d97bf2ab146114cc13d751878a17a383de0fd5f8. * Resolve merge conflicts * Skip incompatible plugins during startup and remove from PLUGINS * Rename Installed column to Active, and add custom PluginActiveColumn with tooltip * Fix is_installed * Simplify plugin_config.validate syntax Co-authored-by: Jeremy Stretch * Merge feature * Revert "Merge feature" This reverts commit d1ea60f08270b9e79d30b9fa9859049aa371f4c6. * Undo simplification * Add failed_to_load logic * Use a TemplateColumn for is_installed * Remove custom column class * Remove merge vestige * Simplify plugin attributes for is_installed column * Use placeholders for false values to increase legibility of the plugins table --------- Co-authored-by: Jeremy Stretch --- netbox/core/exceptions.py | 7 +++++++ netbox/core/plugins.py | 16 ++++++++++------ netbox/core/tables/plugins.py | 8 ++++++-- netbox/core/tables/template_code.py | 12 ++++++++++++ netbox/netbox/middleware.py | 6 +++++- netbox/netbox/plugins/__init__.py | 5 +++-- netbox/netbox/settings.py | 16 +++++++++++----- 7 files changed, 54 insertions(+), 16 deletions(-) diff --git a/netbox/core/exceptions.py b/netbox/core/exceptions.py index 8412b0378..5790704c2 100644 --- a/netbox/core/exceptions.py +++ b/netbox/core/exceptions.py @@ -1,2 +1,9 @@ +from django.core.exceptions import ImproperlyConfigured + + class SyncError(Exception): pass + + +class IncompatiblePluginError(ImproperlyConfigured): + pass diff --git a/netbox/core/plugins.py b/netbox/core/plugins.py index d31a699e4..e4d14b810 100644 --- a/netbox/core/plugins.py +++ b/netbox/core/plugins.py @@ -65,9 +65,11 @@ class Plugin: is_certified: bool = False release_latest: PluginVersion = field(default_factory=PluginVersion) release_recent_history: list[PluginVersion] = field(default_factory=list) - is_local: bool = False # extra field for locally installed plugins - is_installed: bool = False + is_local: bool = False # Indicates that the plugin is listed in settings.PLUGINS (i.e. installed) + is_loaded: bool = False # Indicates whether the plugin successfully loaded at launch installed_version: str = '' + netbox_min_version: str = '' + netbox_max_version: str = '' def get_local_plugins(plugins=None): @@ -78,7 +80,7 @@ def get_local_plugins(plugins=None): local_plugins = {} # Gather all locally-installed plugins - for plugin_name in registry['plugins']['installed']: + for plugin_name in settings.PLUGINS: plugin = importlib.import_module(plugin_name) plugin_config: PluginConfig = plugin.config installed_version = plugin_config.version @@ -92,15 +94,17 @@ def get_local_plugins(plugins=None): tag_line=plugin_config.description, description_short=plugin_config.description, is_local=True, - is_installed=True, + is_loaded=plugin_name in registry['plugins']['installed'], installed_version=installed_version, + netbox_min_version=plugin_config.min_version, + netbox_max_version=plugin_config.max_version, ) # Update catalog entries for local plugins, or add them to the list if not listed for k, v in local_plugins.items(): if k in plugins: - plugins[k].is_local = True - plugins[k].is_installed = True + plugins[k].is_local = v.is_local + plugins[k].is_loaded = v.is_loaded plugins[k].installed_version = v.installed_version else: plugins[k] = v diff --git a/netbox/core/tables/plugins.py b/netbox/core/tables/plugins.py index 96c612366..a7773b4de 100644 --- a/netbox/core/tables/plugins.py +++ b/netbox/core/tables/plugins.py @@ -2,6 +2,7 @@ import django_tables2 as tables from django.utils.translation import gettext_lazy as _ from netbox.tables import BaseTable, columns +from .template_code import PLUGIN_IS_INSTALLED __all__ = ( 'CatalogPluginTable', @@ -48,12 +49,15 @@ class CatalogPluginTable(BaseTable): verbose_name=_('Author') ) is_local = columns.BooleanColumn( + false_mark=None, verbose_name=_('Local') ) - is_installed = columns.BooleanColumn( - verbose_name=_('Installed') + is_installed = columns.TemplateColumn( + verbose_name=_('Active'), + template_code=PLUGIN_IS_INSTALLED ) is_certified = columns.BooleanColumn( + false_mark=None, verbose_name=_('Certified') ) created_at = columns.DateTimeColumn( diff --git a/netbox/core/tables/template_code.py b/netbox/core/tables/template_code.py index c8f0058e7..9fc652c4c 100644 --- a/netbox/core/tables/template_code.py +++ b/netbox/core/tables/template_code.py @@ -14,3 +14,15 @@ OBJECTCHANGE_OBJECT = """ OBJECTCHANGE_REQUEST_ID = """ {{ value }} """ + +PLUGIN_IS_INSTALLED = """ +{% if record.is_local %} + {% if record.is_loaded %} + + {% else %} + + {% endif %} +{% else %} + +{% endif %} +""" diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index b9424bd7c..4f9721430 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -2,6 +2,7 @@ from contextlib import ExitStack import logging import uuid +import warnings from django.conf import settings from django.contrib import auth, messages @@ -37,7 +38,10 @@ class CoreMiddleware: # Apply all registered request processors with ExitStack() as stack: for request_processor in registry['request_processors']: - stack.enter_context(request_processor(request)) + try: + stack.enter_context(request_processor(request)) + except Exception as e: + warnings.warn(f'Failed to initialize request processor {request_processor}: {e}') response = self.get_response(request) # Check if language cookie should be renewed diff --git a/netbox/netbox/plugins/__init__.py b/netbox/netbox/plugins/__init__.py index bb3280ac4..b7bb0ef9f 100644 --- a/netbox/netbox/plugins/__init__.py +++ b/netbox/netbox/plugins/__init__.py @@ -6,6 +6,7 @@ from django.core.exceptions import ImproperlyConfigured from django.utils.module_loading import import_string from packaging import version +from core.exceptions import IncompatiblePluginError from netbox.registry import registry from netbox.search import register_search from netbox.utils import register_data_backend @@ -140,14 +141,14 @@ class PluginConfig(AppConfig): if cls.min_version is not None: min_version = version.parse(cls.min_version) if current_version < min_version: - raise ImproperlyConfigured( + raise IncompatiblePluginError( f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version} (current: " f"{netbox_version})." ) if cls.max_version is not None: max_version = version.parse(cls.max_version) if current_version > max_version: - raise ImproperlyConfigured( + raise IncompatiblePluginError( f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version} (current: " f"{netbox_version})." ) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index bb187ac73..57b143f5f 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,6 +12,7 @@ from django.core.validators import URLValidator from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ +from core.exceptions import IncompatiblePluginError from netbox.config import PARAMS as CONFIG_PARAMS from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW from netbox.plugins import PluginConfig @@ -821,6 +822,15 @@ for plugin_name in PLUGINS: f"__init__.py file and point to the PluginConfig subclass." ) + # Validate version compatibility and user-provided configuration settings and assign defaults + if plugin_name not in PLUGINS_CONFIG: + PLUGINS_CONFIG[plugin_name] = {} + try: + plugin_config.validate(PLUGINS_CONFIG[plugin_name], RELEASE.version) + except IncompatiblePluginError as e: + warnings.warn(f'Unable to load plugin {plugin_name}: {e}') + continue + # Register the plugin as installed successfully registry['plugins']['installed'].append(plugin_name) @@ -853,11 +863,6 @@ for plugin_name in PLUGINS: sorted_apps = reversed(list(dict.fromkeys(reversed(INSTALLED_APPS)))) INSTALLED_APPS = list(sorted_apps) - # Validate user-provided configuration settings and assign defaults - if plugin_name not in PLUGINS_CONFIG: - PLUGINS_CONFIG[plugin_name] = {} - plugin_config.validate(PLUGINS_CONFIG[plugin_name], RELEASE.version) - # Add middleware plugin_middleware = plugin_config.middleware if plugin_middleware and type(plugin_middleware) in (list, tuple): @@ -879,6 +884,7 @@ for plugin_name in PLUGINS: else: raise ImproperlyConfigured(f"events_pipline in plugin: {plugin_name} must be a list or tuple") + # UNSUPPORTED FUNCTIONALITY: Import any local overrides. try: from .local_settings import * From 962d660c2c825d801579de62fed4f0723d7838a0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Mar 2025 10:36:55 -0500 Subject: [PATCH 033/103] Closes #18743: Upgrade to Django 5.2 --- base_requirements.txt | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/base_requirements.txt b/base_requirements.txt index 75ee4bbfd..f76019c27 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -1,6 +1,6 @@ # The Python web framework on which NetBox is built # https://docs.djangoproject.com/en/stable/releases/ -Django<5.2 +Django==5.2.* # Django middleware which permits cross-domain API requests # https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst diff --git a/requirements.txt b/requirements.txt index cb62f6e6f..3792db31d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==5.1.5 +Django==5.2b1 django-cors-headers==4.6.0 django-debug-toolbar==5.0.1 django-filter==24.3 From 3dda4716e7b3ee522fd2cc9d2f9261bff4f3d681 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Mar 2025 16:07:56 -0500 Subject: [PATCH 034/103] Adapt RemoteUserMiddleware for Django 5.2 --- netbox/netbox/middleware.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index 4f9721430..714c20e56 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -98,18 +98,23 @@ class RemoteUserMiddleware(RemoteUserMiddleware_): """ Custom implementation of Django's RemoteUserMiddleware which allows for a user-configurable HTTP header name. """ + async_capable = False force_logout_if_no_header = False + def __init__(self, get_response): + if get_response is None: + raise ValueError("get_response must be provided.") + self.get_response = get_response + @property def header(self): return settings.REMOTE_AUTH_HEADER - def process_request(self, request): - logger = logging.getLogger( - 'netbox.authentication.RemoteUserMiddleware') + def __call__(self, request): + logger = logging.getLogger('netbox.authentication.RemoteUserMiddleware') # Bypass middleware if remote authentication is not enabled if not settings.REMOTE_AUTH_ENABLED: - return + return self.get_response(request) # AuthenticationMiddleware is required so that request.user exists. if not hasattr(request, 'user'): raise ImproperlyConfigured( @@ -126,13 +131,13 @@ class RemoteUserMiddleware(RemoteUserMiddleware_): # AnonymousUser by the AuthenticationMiddleware). if self.force_logout_if_no_header and request.user.is_authenticated: self._remove_invalid_user(request) - return + return self.get_response(request) # If the user is already authenticated and that user is the user we are # getting passed in the headers, then the correct user is already # persisted in the session and we don't need to continue. if request.user.is_authenticated: if request.user.get_username() == self.clean_username(username, request): - return + return self.get_response(request) else: # An authenticated user is associated with the request, but # it does not match the authorized user in the header. @@ -162,6 +167,8 @@ class RemoteUserMiddleware(RemoteUserMiddleware_): request.user = user auth.login(request, user) + return self.get_response(request) + def _get_groups(self, request): logger = logging.getLogger( 'netbox.authentication.RemoteUserMiddleware') From 19703f7d69f286110f73b6566cca530de5511ed5 Mon Sep 17 00:00:00 2001 From: Tobias Genannt Date: Fri, 7 Mar 2025 21:43:33 +0100 Subject: [PATCH 035/103] Fixes: #18568 Update mkdocstrings and adapt config --- mkdocs.yml | 7 +------ requirements.txt | 4 ++-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index a5b2d5355..f0bd9af7a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -28,12 +28,7 @@ plugins: - mkdocstrings: handlers: python: - setup_commands: - - import os - - import django - - os.chdir('netbox/') - - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") - - django.setup() + paths: ["netbox"] options: heading_level: 3 members_order: source diff --git a/requirements.txt b/requirements.txt index 3792db31d..9e0e73d5b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,8 +20,8 @@ feedparser==6.0.11 gunicorn==23.0.0 Jinja2==3.1.5 Markdown==3.7 -mkdocs-material==9.6.2 -mkdocstrings[python-legacy]==0.27.0 +mkdocs-material==9.6.7 +mkdocstrings[python]==0.28.2 netaddr==1.3.0 nh3==0.2.20 Pillow==11.1.0 From ae7a47ca60a275ff8574da2f2b50cc055c1091e9 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Mon, 10 Mar 2025 11:52:13 -0500 Subject: [PATCH 036/103] Adds comments field to abstract NestedGroupModel and associated migrations Models affected: - dcim: `Location`, `Region`, `SiteGroup` - tenancy`: `ContactGroup`, `TenantGroup` - wireless: `WirelessLANGroup` --- ...ents_region_comments_sitegroup_comments.py | 28 +++++++++++++++++++ netbox/netbox/models/__init__.py | 4 +++ ...tactgroup_comments_tenantgroup_comments.py | 23 +++++++++++++++ .../0014_wirelesslangroup_comments.py | 18 ++++++++++++ 4 files changed, 73 insertions(+) create mode 100644 netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py create mode 100644 netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py create mode 100644 netbox/wireless/migrations/0014_wirelesslangroup_comments.py diff --git a/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py b/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py new file mode 100644 index 000000000..51031de53 --- /dev/null +++ b/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.7 on 2025-03-10 16:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0201_add_power_outlet_status'), + ] + + operations = [ + migrations.AddField( + model_name='location', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='region', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='sitegroup', + name='comments', + field=models.TextField(blank=True), + ), + ] diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index b1f7cfd48..3ad0ac556 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -150,6 +150,10 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel): max_length=200, blank=True ) + comments = models.TextField( + verbose_name=_('comments'), + blank=True + ) objects = TreeManager() diff --git a/netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py b/netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py new file mode 100644 index 000000000..3481baeec --- /dev/null +++ b/netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.7 on 2025-03-10 16:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0017_natural_ordering'), + ] + + operations = [ + migrations.AddField( + model_name='contactgroup', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='tenantgroup', + name='comments', + field=models.TextField(blank=True), + ), + ] diff --git a/netbox/wireless/migrations/0014_wirelesslangroup_comments.py b/netbox/wireless/migrations/0014_wirelesslangroup_comments.py new file mode 100644 index 000000000..3e3cab270 --- /dev/null +++ b/netbox/wireless/migrations/0014_wirelesslangroup_comments.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.7 on 2025-03-10 16:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0013_natural_ordering'), + ] + + operations = [ + migrations.AddField( + model_name='wirelesslangroup', + name='comments', + field=models.TextField(blank=True), + ), + ] From 44efd5e833212e3e12c3f7ab75fc2d1cf3a4b314 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Mon, 10 Mar 2025 14:53:23 -0500 Subject: [PATCH 037/103] Adds Location.comments field in the required locations - [x] 1. Add the field to the model class - [x] 2. Generate and run database migrations - [NA] 3. Add validation logic to clean() - [NA] 4. Update relevant querysets - [x] 5. Update API serializer - [x] 6. Add fields to forms - [x] dcim.forms.model_forms.LocationForm, create/edit (e.g. model_forms.py) - [x] dcim.forms.buld_edit.LocationBulkEditForm, bulk edit - [x] dcim.dorms.bulk_import.LocationImportForm, CSV import - [x] filter (UI and API) - [NA] UI - Note: could not find any comments related things in filtersets - [x] API - [x] 7. Extend object filter set - [x] 8. Add column to object table - [x] 9. Update the SearchIndex - [x] 10. Update the UI templates - [x] 11. Create/extend test cases - [NA] models - [x] views - [NA] forms - [x] filtersets - [x] api - [NA] 12. Update the model's documentation --- netbox/dcim/api/serializers_/sites.py | 2 +- netbox/dcim/filtersets.py | 3 ++- netbox/dcim/forms/bulk_edit.py | 3 ++- netbox/dcim/forms/bulk_import.py | 5 ++++- netbox/dcim/forms/model_forms.py | 4 +++- netbox/dcim/search.py | 1 + netbox/dcim/tables/sites.py | 2 +- netbox/dcim/tests/test_api.py | 5 +++++ netbox/dcim/tests/test_filtersets.py | 10 ++++++++++ netbox/dcim/tests/test_views.py | 21 +++++++++++++-------- 10 files changed, 42 insertions(+), 14 deletions(-) diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py index b818cd954..1b95af70e 100644 --- a/netbox/dcim/api/serializers_/sites.py +++ b/netbox/dcim/api/serializers_/sites.py @@ -93,6 +93,6 @@ class LocationSerializer(NestedGroupModelSerializer): fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'facility', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', - 'prefix_count', '_depth', + 'prefix_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth') diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index e46730da8..bd7713289 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -280,7 +280,8 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM return queryset.filter( Q(name__icontains=value) | Q(facility__icontains=value) | - Q(description__icontains=value) + Q(description__icontains=value) | + Q(comments__icontains=value) ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 3b9a183cd..696474617 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -197,12 +197,13 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = Location fieldsets = ( FieldSet('site', 'parent', 'status', 'tenant', 'description'), ) - nullable_fields = ('parent', 'tenant', 'description') + nullable_fields = ('parent', 'tenant', 'description', 'comments') class RackRoleBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 92f7220da..31a6d93a4 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -160,7 +160,10 @@ class LocationImportForm(NetBoxModelImportForm): class Meta: model = Location - fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'facility', 'description', 'tags') + fields = ( + 'site', 'parent', 'name', 'slug', 'status', 'tenant', 'facility', 'description', + 'tags', 'comments', + ) def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 91e23e8b1..e1535fe0c 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -179,6 +179,7 @@ class LocationForm(TenancyForm, NetBoxModelForm): } ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('site', 'parent', 'name', 'slug', 'status', 'facility', 'description', 'tags', name=_('Location')), @@ -188,7 +189,8 @@ class LocationForm(TenancyForm, NetBoxModelForm): class Meta: model = Location fields = ( - 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', 'facility', 'tags', + 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', + 'facility', 'tags', 'comments', ) diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index 5dea2a09b..b7299c111 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -144,6 +144,7 @@ class LocationIndex(SearchIndex): ('facility', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('site', 'status', 'tenant', 'facility', 'description') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 77844f086..0209a1742 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -158,7 +158,7 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): model = Location fields = ( 'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count', - 'description', 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated', + 'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'description' diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 08f93f6ea..1eacd7ea7 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -212,12 +212,14 @@ class LocationTest(APIViewTestCases.APIViewTestCase): name='Parent Location 1', slug='parent-location-1', status=LocationStatusChoices.STATUS_ACTIVE, + comments='First!' ), Location.objects.create( site=sites[1], name='Parent Location 2', slug='parent-location-2', status=LocationStatusChoices.STATUS_ACTIVE, + comments='Second!' ), ) @@ -227,6 +229,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase): slug='location-1', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE, + comments='Third!' ) Location.objects.create( site=sites[0], @@ -250,6 +253,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase): 'site': sites[1].pk, 'parent': parent_locations[1].pk, 'status': LocationStatusChoices.STATUS_PLANNED, + 'comments': '', }, { 'name': 'Test Location 5', @@ -257,6 +261,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase): 'site': sites[1].pk, 'parent': parent_locations[1].pk, 'status': LocationStatusChoices.STATUS_PLANNED, + 'comments': 'Somebody should check on this location', }, { 'name': 'Test Location 6', diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 7c9b8adc6..d8526062b 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -401,6 +401,7 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): status=LocationStatusChoices.STATUS_PLANNED, facility='Facility 1', description='foobar1', + comments='', ), Location( name='Location 2A', @@ -410,6 +411,7 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): status=LocationStatusChoices.STATUS_STAGING, facility='Facility 2', description='foobar2', + comments='First comment!', ), Location( name='Location 3A', @@ -419,6 +421,7 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): status=LocationStatusChoices.STATUS_DECOMMISSIONING, facility='Facility 3', description='foobar3', + comments='_This_ is a **bold comment**', ), ) for location in locations: @@ -436,6 +439,13 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_q_comments(self): + params = {'q': 'this'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'q': 'comment'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_name(self): params = {'name': ['Location 1', 'Location 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 4dea94c7d..4feffcf1f 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -202,6 +202,7 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant, + comments='', ), Location( name='Location 2', @@ -209,6 +210,7 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant, + comments='First comment!', ), Location( name='Location 3', @@ -216,6 +218,7 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant, + comments='_This_ is a **bold comment**', ), ) for location in locations: @@ -232,24 +235,26 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'tenant': tenant.pk, 'description': 'A new location', 'tags': [t.pk for t in tags], + 'comments': 'This comment is really boring', } cls.csv_data = ( - "site,tenant,name,slug,status,description", - "Site 1,Tenant 1,Location 4,location-4,planned,Fourth location", - "Site 1,Tenant 1,Location 5,location-5,planned,Fifth location", - "Site 1,Tenant 1,Location 6,location-6,planned,Sixth location", + "site,tenant,name,slug,status,description,comments", + "Site 1,Tenant 1,Location 4,location-4,planned,Fourth location,", + "Site 1,Tenant 1,Location 5,location-5,planned,Fifth location,", + "Site 1,Tenant 1,Location 6,location-6,planned,Sixth location,hi!", ) cls.csv_update_data = ( - "id,name,description", - f"{locations[0].pk},Location 7,Fourth location7", - f"{locations[1].pk},Location 8,Fifth location8", - f"{locations[2].pk},Location 0,Sixth location9", + "id,name,description,comments", + f"{locations[0].pk},Location 7,Fourth location7,Useful comment", + f"{locations[1].pk},Location 8,Fifth location8,unuseful comment", + f"{locations[2].pk},Location 0,Sixth location9,", ) cls.bulk_edit_data = { 'description': 'New description', + 'comments': 'This comment is also really boring', } From 2e2c815c9140528f0ae537b8327921e9c5d9ffe2 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Mon, 10 Mar 2025 17:05:26 -0500 Subject: [PATCH 038/103] Update Location detail UI template --- netbox/templates/dcim/location.html | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index 97dcc20f0..02e02a1ed 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -62,6 +62,7 @@ {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
From 9a9d6cdedb6f06687b7c7a350b9ae7fcfd6a4e37 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Mon, 10 Mar 2025 17:07:59 -0500 Subject: [PATCH 039/103] Adds Region.comments field in the required locations - [x] 1. Add the field to the model class - [x] 2. Generate and run database migrations - [NA] 3. Add validation logic to clean() - [NA] 4. Update relevant querysets - [x] 5. Update API serializer - [ ] 6. Add fields to forms - [x] dcim.forms.model_forms.RegionForm, create/edit (e.g. model_forms.py) - [x] dcim.forms.buld_edit.RegionBulkEditForm, bulk edit - [x] dcim.dorms.bulk_import.RegionImportForm, CSV import - [NA] filter (UI and API) - [x] 7. Extend object filter set - [x] 8. Add column to object table - [x] 9. Update the SearchIndex - [x] 10. Update the UI templates - [x] 11. Create/extend test cases - [NA] models - [x] views - [NA] forms - [x] filtersets - [x] api - [NA] 12. Update the model's documentation --- netbox/dcim/api/serializers_/sites.py | 2 +- netbox/dcim/filtersets.py | 9 +++++++++ netbox/dcim/forms/bulk_edit.py | 3 ++- netbox/dcim/forms/bulk_import.py | 2 +- netbox/dcim/forms/model_forms.py | 3 ++- netbox/dcim/search.py | 1 + netbox/dcim/tables/sites.py | 4 ++-- netbox/dcim/tests/test_api.py | 4 +++- netbox/dcim/tests/test_filtersets.py | 19 ++++++++++++++++--- netbox/dcim/tests/test_views.py | 16 ++++++++++------ netbox/templates/dcim/region.html | 1 + 11 files changed, 48 insertions(+), 16 deletions(-) diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py index 1b95af70e..70924de5d 100644 --- a/netbox/dcim/api/serializers_/sites.py +++ b/netbox/dcim/api/serializers_/sites.py @@ -27,7 +27,7 @@ class RegionSerializer(NestedGroupModelSerializer): model = Region fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'site_count', 'prefix_count', '_depth', + 'created', 'last_updated', 'site_count', 'prefix_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth') diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index bd7713289..6517d277a 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -110,6 +110,15 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): model = Region fields = ('id', 'name', 'slug', 'description') + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) | + Q(comments__icontains=value) + ).distinct() + class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 696474617..dd78c0b23 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -78,12 +78,13 @@ class RegionBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = Region fieldsets = ( FieldSet('parent', 'description'), ) - nullable_fields = ('parent', 'description') + nullable_fields = ('parent', 'description', 'comments') class SiteGroupBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 31a6d93a4..cf9726360 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -68,7 +68,7 @@ class RegionImportForm(NetBoxModelImportForm): class Meta: model = Region - fields = ('name', 'slug', 'parent', 'description', 'tags') + fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments') class SiteGroupImportForm(NetBoxModelImportForm): diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index e1535fe0c..639eebe13 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -78,6 +78,7 @@ class RegionForm(NetBoxModelForm): required=False ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('parent', 'name', 'slug', 'description', 'tags'), @@ -86,7 +87,7 @@ class RegionForm(NetBoxModelForm): class Meta: model = Region fields = ( - 'parent', 'name', 'slug', 'description', 'tags', + 'parent', 'name', 'slug', 'description', 'tags', 'comments', ) diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index b7299c111..e13c97ba7 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -318,6 +318,7 @@ class RegionIndex(SearchIndex): ('name', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('parent', 'description') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 0209a1742..51e67f2f3 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -36,8 +36,8 @@ class RegionTable(ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Region fields = ( - 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'created', 'last_updated', - 'actions', + 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags', + 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'site_count', 'description') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 1eacd7ea7..68cf34fe4 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -74,6 +74,7 @@ class RegionTest(APIViewTestCases.APIViewTestCase): { 'name': 'Region 4', 'slug': 'region-4', + 'comments': 'this is region 4, not region 5', }, { 'name': 'Region 5', @@ -86,13 +87,14 @@ class RegionTest(APIViewTestCases.APIViewTestCase): ] bulk_update_data = { 'description': 'New description', + 'comments': 'New comments', } @classmethod def setUpTestData(cls): Region.objects.create(name='Region 1', slug='region-1') - Region.objects.create(name='Region 2', slug='region-2') + Region.objects.create(name='Region 2', slug='region-2', comments='what in the world is happening?') Region.objects.create(name='Region 3', slug='region-3') diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index d8526062b..ebc9f3fba 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -67,9 +67,15 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): def setUpTestData(cls): parent_regions = ( - Region(name='Region 1', slug='region-1', description='foobar1'), - Region(name='Region 2', slug='region-2', description='foobar2'), - Region(name='Region 3', slug='region-3', description='foobar3'), + Region( + name='Region 1', slug='region-1', description='foobar1', comments="There's nothing that", + ), + Region( + name='Region 2', slug='region-2', description='foobar2', comments='a hundred men or more', + ), + Region( + name='Region 3', slug='region-3', description='foobar3', comments='could ever do' + ), ) for region in parent_regions: region.save() @@ -100,6 +106,13 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_q_comments(self): + params = {'q': 'there'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'q': 'hundred men could'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) + def test_name(self): params = {'name': ['Region 1', 'Region 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 4feffcf1f..24fa4ae55 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -25,8 +25,10 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): # Create three Regions regions = ( - Region(name='Region 1', slug='region-1'), - Region(name='Region 2', slug='region-2'), + Region(name='Region 1', slug='region-1', comments=''), + Region( + name='Region 2', slug='region-2', comments="It's going to take a lot to drag me away from you" + ), Region(name='Region 3', slug='region-3'), ) for region in regions: @@ -40,13 +42,14 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'parent': regions[2].pk, 'description': 'A new region', 'tags': [t.pk for t in tags], + 'comments': 'This comment is really exciting!', } cls.csv_data = ( - "name,slug,description", - "Region 4,region-4,Fourth region", - "Region 5,region-5,Fifth region", - "Region 6,region-6,Sixth region", + "name,slug,description,comments", + "Region 4,region-4,Fourth region,", + "Region 5,region-5,Fifth region,hi guys", + "Region 6,region-6,Sixth region,bye guys", ) cls.csv_update_data = ( @@ -58,6 +61,7 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): cls.bulk_edit_data = { 'description': 'New description', + 'comments': 'This comment is super exciting!!!', } diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index 1e1b75cd5..c6acbb9ea 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -41,6 +41,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
From ed98756f3eb329a2343a91eb8853b9e186ec0c8a Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Tue, 11 Mar 2025 10:04:50 -0500 Subject: [PATCH 040/103] Adds SiteGroup.comments in the required locations - [x] 1. Add the field to the model class - [x] 2. Generate and run database migrations - [NA] 3. Add validation logic to clean() - [NA] 4. Update relevant querysets - [x] 5. Update API serializer - [x] 6. Add fields to forms - [x] dcim.forms.model_forms.LocationForm, create/edit (e.g. model_forms.py) - [x] dcim.forms.buld_edit.LocationBulkEditForm, bulk edit - [x] dcim.dorms.bulk_import.LocationImportForm, CSV import - [x] filter (UI and API) - [x] 7. Extend object filter set - [x] 8. Add column to object table - [x] 9. Update the SearchIndex - [x] 10. Update the UI templates - [x] 11. Create/extend test cases - [NA] models - [x] views - [NA] forms - [x] filtersets - [x] api - [x] 12. Update the model's documentation --- netbox/dcim/api/serializers_/sites.py | 2 +- netbox/dcim/filtersets.py | 9 +++++++++ netbox/dcim/forms/bulk_edit.py | 3 ++- netbox/dcim/forms/bulk_import.py | 2 +- netbox/dcim/forms/model_forms.py | 3 ++- netbox/dcim/search.py | 1 + netbox/dcim/tables/sites.py | 4 ++-- netbox/dcim/tests/test_api.py | 8 ++++++-- netbox/dcim/tests/test_filtersets.py | 15 +++++++++++++-- netbox/dcim/tests/test_views.py | 20 +++++++++++--------- netbox/templates/dcim/sitegroup.html | 1 + 11 files changed, 49 insertions(+), 19 deletions(-) diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py index 70924de5d..90f7b5d35 100644 --- a/netbox/dcim/api/serializers_/sites.py +++ b/netbox/dcim/api/serializers_/sites.py @@ -41,7 +41,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer): model = SiteGroup fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'site_count', 'prefix_count', '_depth', + 'created', 'last_updated', 'site_count', 'prefix_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth') diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 6517d277a..6fb6bf980 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -149,6 +149,15 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): model = SiteGroup fields = ('id', 'name', 'slug', 'description') + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) | + Q(comments__icontains=value) + ).distinct() + class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): status = django_filters.MultipleChoiceFilter( diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index dd78c0b23..c1da9c8d1 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -98,12 +98,13 @@ class SiteGroupBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = SiteGroup fieldsets = ( FieldSet('parent', 'description'), ) - nullable_fields = ('parent', 'description') + nullable_fields = ('parent', 'description', 'comments') class SiteBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index cf9726360..469e40217 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -82,7 +82,7 @@ class SiteGroupImportForm(NetBoxModelImportForm): class Meta: model = SiteGroup - fields = ('name', 'slug', 'parent', 'description') + fields = ('name', 'slug', 'parent', 'description', 'comments', 'tags') class SiteImportForm(NetBoxModelImportForm): diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 639eebe13..dea031b64 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -98,6 +98,7 @@ class SiteGroupForm(NetBoxModelForm): required=False ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('parent', 'name', 'slug', 'description', 'tags'), @@ -106,7 +107,7 @@ class SiteGroupForm(NetBoxModelForm): class Meta: model = SiteGroup fields = ( - 'parent', 'name', 'slug', 'description', 'tags', + 'parent', 'name', 'slug', 'description', 'comments', 'tags', ) diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index e13c97ba7..a85005679 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -345,6 +345,7 @@ class SiteGroupIndex(SearchIndex): ('name', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('parent', 'description') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 51e67f2f3..cc4e00e7e 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -63,8 +63,8 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = SiteGroup fields = ( - 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'created', 'last_updated', - 'actions', + 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags', + 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'site_count', 'description') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 68cf34fe4..807ac77d4 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -105,26 +105,30 @@ class SiteGroupTest(APIViewTestCases.APIViewTestCase): { 'name': 'Site Group 4', 'slug': 'site-group-4', + 'comments': '', }, { 'name': 'Site Group 5', 'slug': 'site-group-5', + 'comments': 'not actually empty', }, { 'name': 'Site Group 6', 'slug': 'site-group-6', + 'comments': 'Do I really exist?', }, ] bulk_update_data = { 'description': 'New description', + 'comments': 'I do exist!', } @classmethod def setUpTestData(cls): SiteGroup.objects.create(name='Site Group 1', slug='site-group-1') - SiteGroup.objects.create(name='Site Group 2', slug='site-group-2') - SiteGroup.objects.create(name='Site Group 3', slug='site-group-3') + SiteGroup.objects.create(name='Site Group 2', slug='site-group-2', comments='') + SiteGroup.objects.create(name='Site Group 3', slug='site-group-3', comments='Hi!') class SiteTest(APIViewTestCases.APIViewTestCase): diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index ebc9f3fba..0c4bbbaff 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -161,13 +161,17 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests): SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=parent_groups[1]), SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=parent_groups[1]), SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=parent_groups[2]), - SiteGroup(name='Site Group 3B', slug='site-group-3b', parent=parent_groups[2]), + SiteGroup( + name='Site Group 3B', slug='site-group-3b', parent=parent_groups[2], comments='this is a parent group', + ), ) for site_group in groups: site_group.save() child_groups = ( - SiteGroup(name='Site Group 1A1', slug='site-group-1a1', parent=groups[0]), + SiteGroup( + name='Site Group 1A1', slug='site-group-1a1', parent=groups[0], comments='this is a child group', + ), SiteGroup(name='Site Group 1B1', slug='site-group-1b1', parent=groups[1]), SiteGroup(name='Site Group 2A1', slug='site-group-2a1', parent=groups[2]), SiteGroup(name='Site Group 2B1', slug='site-group-2b1', parent=groups[3]), @@ -181,6 +185,13 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_q_comments(self): + params = {'q': 'this'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + params = {'q': 'child'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_name(self): params = {'name': ['Site Group 1', 'Site Group 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 24fa4ae55..83effa188 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -73,7 +73,7 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): # Create three SiteGroups sitegroups = ( - SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 1', slug='site-group-1', comments='Still here'), SiteGroup(name='Site Group 2', slug='site-group-2'), SiteGroup(name='Site Group 3', slug='site-group-3'), ) @@ -88,24 +88,26 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'parent': sitegroups[2].pk, 'description': 'A new site group', 'tags': [t.pk for t in tags], + 'comments': 'still here', } cls.csv_data = ( - "name,slug,description", - "Site Group 4,site-group-4,Fourth site group", - "Site Group 5,site-group-5,Fifth site group", - "Site Group 6,site-group-6,Sixth site group", + "name,slug,description,comments", + "Site Group 4,site-group-4,Fourth site group,", + "Site Group 5,site-group-5,Fifth site group,still hear", + "Site Group 6,site-group-6,Sixth site group," ) cls.csv_update_data = ( - "id,name,description", - f"{sitegroups[0].pk},Site Group 7,Fourth site group7", - f"{sitegroups[1].pk},Site Group 8,Fifth site group8", - f"{sitegroups[2].pk},Site Group 0,Sixth site group9", + "id,name,description,comments", + f"{sitegroups[0].pk},Site Group 7,Fourth site group7,", + f"{sitegroups[1].pk},Site Group 8,Fifth site group8,when will it end", + f"{sitegroups[2].pk},Site Group 0,Sixth site group9,", ) cls.bulk_edit_data = { 'description': 'New description', + 'comments': 'the end', } diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index 3ae43f210..9beb7c505 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -41,6 +41,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
From b8352260ee7dcf8be80281d7f45b5dc87096d3ca Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Tue, 11 Mar 2025 10:42:59 -0500 Subject: [PATCH 041/103] Adds ContactGroup.comments in the required locations - [x] 1. Add the field to the model class - [x] 2. Generate and run database migrations - [NA] 3. Add validation logic to clean() - [NA] 4. Update relevant querysets - [x] 5. Update API serializer - [x] 6. Add fields to forms - [x] tenancy.forms.model_forms, create/edit (e.g. model_forms.py) - [x] tenancy.forms.buld_edit, bulk edit - [x] tenancy.dorms.bulk_import, CSV import - [NA] filter (UI and API) - [x] 7. Extend object filter set - [x] 8. Add column to object table - [x] 9. Update the SearchIndex - [x] 10. Update the UI templates - [x] 11. Create/extend test cases - [NA] models - [x] views - [NA] forms - [x] filtersets - [x] api - [NA] 12. Update the model's documentation --- netbox/templates/tenancy/contactgroup.html | 1 + netbox/tenancy/api/serializers_/contacts.py | 2 +- netbox/tenancy/filtersets.py | 10 ++++++++++ netbox/tenancy/forms/bulk_edit.py | 3 ++- netbox/tenancy/forms/bulk_import.py | 2 +- netbox/tenancy/forms/model_forms.py | 3 ++- netbox/tenancy/search.py | 1 + netbox/tenancy/tables/contacts.py | 3 ++- netbox/tenancy/tests/test_api.py | 11 +++++++++-- netbox/tenancy/tests/test_filtersets.py | 17 ++++++++++++++--- netbox/tenancy/tests/test_views.py | 20 +++++++++++--------- 11 files changed, 54 insertions(+), 19 deletions(-) diff --git a/netbox/templates/tenancy/contactgroup.html b/netbox/templates/tenancy/contactgroup.html index bf6928c15..25b1da440 100644 --- a/netbox/templates/tenancy/contactgroup.html +++ b/netbox/templates/tenancy/contactgroup.html @@ -32,6 +32,7 @@
{% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/tenancy/api/serializers_/contacts.py b/netbox/tenancy/api/serializers_/contacts.py index 8c24df734..846e618b4 100644 --- a/netbox/tenancy/api/serializers_/contacts.py +++ b/netbox/tenancy/api/serializers_/contacts.py @@ -26,7 +26,7 @@ class ContactGroupSerializer(NestedGroupModelSerializer): model = ContactGroup fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'contact_count', '_depth', + 'created', 'last_updated', 'contact_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'contact_count', '_depth') diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index e2de18231..ff5563f1a 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -51,6 +51,16 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): model = ContactGroup fields = ('id', 'name', 'slug', 'description') + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(slug__icontains=value) | + Q(description__icontains=value) | + Q(comments__icontains=value) + ) + class ContactRoleFilterSet(OrganizationalModelFilterSet): diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index 5af3f22ac..a8528aec8 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -67,12 +67,13 @@ class ContactGroupBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = ContactGroup fieldsets = ( FieldSet('parent', 'description'), ) - nullable_fields = ('parent', 'description') + nullable_fields = ('parent', 'description', 'comments') class ContactRoleBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index f37317549..d227cef14 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -65,7 +65,7 @@ class ContactGroupImportForm(NetBoxModelImportForm): class Meta: model = ContactGroup - fields = ('name', 'slug', 'parent', 'description', 'tags') + fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments') class ContactRoleImportForm(NetBoxModelImportForm): diff --git a/netbox/tenancy/forms/model_forms.py b/netbox/tenancy/forms/model_forms.py index bc18deed6..d65d47f1f 100644 --- a/netbox/tenancy/forms/model_forms.py +++ b/netbox/tenancy/forms/model_forms.py @@ -70,6 +70,7 @@ class ContactGroupForm(NetBoxModelForm): required=False ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Contact Group')), @@ -77,7 +78,7 @@ class ContactGroupForm(NetBoxModelForm): class Meta: model = ContactGroup - fields = ('parent', 'name', 'slug', 'description', 'tags') + fields = ('parent', 'name', 'slug', 'description', 'tags', 'comments') class ContactRoleForm(NetBoxModelForm): diff --git a/netbox/tenancy/search.py b/netbox/tenancy/search.py index 56903d6b1..5050114a6 100644 --- a/netbox/tenancy/search.py +++ b/netbox/tenancy/search.py @@ -25,6 +25,7 @@ class ContactGroupIndex(SearchIndex): ('name', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('description',) diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index c4e35ab1b..e8761720e 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -31,7 +31,8 @@ class ContactGroupTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = ContactGroup fields = ( - 'pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + 'pk', 'name', 'contact_count', 'description', 'comments', 'slug', 'tags', 'created', + 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'contact_count', 'description') diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index c32ad3826..4dc33f943 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -107,13 +107,18 @@ class ContactGroupTest(APIViewTestCases.APIViewTestCase): def setUpTestData(cls): parent_contact_groups = ( - ContactGroup.objects.create(name='Parent Contact Group 1', slug='parent-contact-group-1'), + ContactGroup.objects.create( + name='Parent Contact Group 1', slug='parent-contact-group-1', comments='Parent 1 comment' + ), ContactGroup.objects.create(name='Parent Contact Group 2', slug='parent-contact-group-2'), ) ContactGroup.objects.create(name='Contact Group 1', slug='contact-group-1', parent=parent_contact_groups[0]) ContactGroup.objects.create(name='Contact Group 2', slug='contact-group-2', parent=parent_contact_groups[0]) - ContactGroup.objects.create(name='Contact Group 3', slug='contact-group-3', parent=parent_contact_groups[0]) + ContactGroup.objects.create( + name='Contact Group 3', slug='contact-group-3', parent=parent_contact_groups[0], + comments='Child Group 3 comment', + ) cls.create_data = [ { @@ -125,11 +130,13 @@ class ContactGroupTest(APIViewTestCases.APIViewTestCase): 'name': 'Contact Group 5', 'slug': 'contact-group-5', 'parent': parent_contact_groups[1].pk, + 'comments': '', }, { 'name': 'Contact Group 6', 'slug': 'contact-group-6', 'parent': parent_contact_groups[1].pk, + 'comments': 'Child Group 6 comment', }, ] diff --git a/netbox/tenancy/tests/test_filtersets.py b/netbox/tenancy/tests/test_filtersets.py index f6890a3d4..97005dd1e 100644 --- a/netbox/tenancy/tests/test_filtersets.py +++ b/netbox/tenancy/tests/test_filtersets.py @@ -139,7 +139,7 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests): parent_contact_groups = ( ContactGroup(name='Contact Group 1', slug='contact-group-1'), - ContactGroup(name='Contact Group 2', slug='contact-group-2'), + ContactGroup(name='Contact Group 2', slug='contact-group-2', comments='Parent group 2'), ContactGroup(name='Contact Group 3', slug='contact-group-3'), ) for contact_group in parent_contact_groups: @@ -162,14 +162,18 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests): name='Contact Group 3A', slug='contact-group-3a', parent=parent_contact_groups[2], - description='foobar3' + description='foobar3', + comments='Contact Group 3A comment, not a parent', ), ) for contact_group in contact_groups: contact_group.save() child_contact_groups = ( - ContactGroup(name='Contact Group 1A1', slug='contact-group-1a1', parent=contact_groups[0]), + ContactGroup( + name='Contact Group 1A1', slug='contact-group-1a1', parent=contact_groups[0], + comments='Contact Group 1A1 comment', + ), ContactGroup(name='Contact Group 2A1', slug='contact-group-2a1', parent=contact_groups[1]), ContactGroup(name='Contact Group 3A1', slug='contact-group-3a1', parent=contact_groups[2]), ) @@ -180,6 +184,13 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_q_comments(self): + params = {'q': 'parent'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + params = {'q': '1A1'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_name(self): params = {'name': ['Contact Group 1', 'Contact Group 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index cbdecc0d0..b67ea428b 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -106,7 +106,7 @@ class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): def setUpTestData(cls): contact_groups = ( - ContactGroup(name='Contact Group 1', slug='contact-group-1'), + ContactGroup(name='Contact Group 1', slug='contact-group-1', comments='Comment 1'), ContactGroup(name='Contact Group 2', slug='contact-group-2'), ContactGroup(name='Contact Group 3', slug='contact-group-3'), ) @@ -120,24 +120,26 @@ class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'slug': 'contact-group-x', 'description': 'A new contact group', 'tags': [t.pk for t in tags], + 'comments': 'Form data comment', } cls.csv_data = ( - "name,slug,description", - "Contact Group 4,contact-group-4,Fourth contact group", - "Contact Group 5,contact-group-5,Fifth contact group", - "Contact Group 6,contact-group-6,Sixth contact group", + "name,slug,description,comments", + "Contact Group 4,contact-group-4,Fourth contact group,", + "Contact Group 5,contact-group-5,Fifth contact group,Fifth comment", + "Contact Group 6,contact-group-6,Sixth contact group,", ) cls.csv_update_data = ( - "id,name,description", - f"{contact_groups[0].pk},Contact Group 7,Fourth contact group7", - f"{contact_groups[1].pk},Contact Group 8,Fifth contact group8", - f"{contact_groups[2].pk},Contact Group 0,Sixth contact group9", + "id,name,description,comments", + f"{contact_groups[0].pk},Contact Group 7,Fourth contact group7,", + f"{contact_groups[1].pk},Contact Group 8,Fifth contact group8,Group 8 comment", + f"{contact_groups[2].pk},Contact Group 0,Sixth contact group9,", ) cls.bulk_edit_data = { 'description': 'New description', + 'comments': 'Bulk update comment', } From 157df20ad4354428d81da32ab8a36321b5e0318a Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Tue, 11 Mar 2025 11:07:05 -0500 Subject: [PATCH 042/103] Adds TenantGroup.comments to the required locations - [x] 1. Add the field to the model class - [x] 2. Generate and run database migrations - [NA] 3. Add validation logic to clean() - [NA] 4. Update relevant querysets - [x] 5. Update API serializer - [x] 6. Add fields to forms - [x] tenancy.forms.model_forms, create/edit (e.g. model_forms.py) - [x] tenancy.forms.bulk_edit, bulk edit - [x] tenancy.forms.bulk_import, CSV import - [NA] filter (UI and API) - [x] 7. Extend object filter set - [x] 8. Add column to object table - [x] 9. Update the SearchIndex - [x] 10. Update the UI templates - [x] 11. Create/extend test cases - [NA] models - [x] views - [NA] forms - [x] filtersets - [x] api - [NA] 12. Update the model's documentation --- netbox/templates/tenancy/tenantgroup.html | 1 + netbox/tenancy/api/serializers_/tenants.py | 2 +- netbox/tenancy/filtersets.py | 10 ++++++++++ netbox/tenancy/forms/bulk_edit.py | 3 ++- netbox/tenancy/forms/bulk_import.py | 2 +- netbox/tenancy/forms/model_forms.py | 3 ++- netbox/tenancy/search.py | 1 + netbox/tenancy/tables/tenants.py | 3 ++- netbox/tenancy/tests/test_api.py | 11 +++++++++-- netbox/tenancy/tests/test_filtersets.py | 17 ++++++++++++++--- netbox/tenancy/tests/test_views.py | 20 +++++++++++--------- 11 files changed, 54 insertions(+), 19 deletions(-) diff --git a/netbox/templates/tenancy/tenantgroup.html b/netbox/templates/tenancy/tenantgroup.html index 0567f2ab3..ecf95a024 100644 --- a/netbox/templates/tenancy/tenantgroup.html +++ b/netbox/templates/tenancy/tenantgroup.html @@ -40,6 +40,7 @@
{% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/tenancy/api/serializers_/tenants.py b/netbox/tenancy/api/serializers_/tenants.py index 54e906f1d..189397c70 100644 --- a/netbox/tenancy/api/serializers_/tenants.py +++ b/netbox/tenancy/api/serializers_/tenants.py @@ -19,7 +19,7 @@ class TenantGroupSerializer(NestedGroupModelSerializer): model = TenantGroup fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'tenant_count', '_depth', + 'created', 'last_updated', 'tenant_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'tenant_count', '_depth') diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index ff5563f1a..c70b381ee 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -202,6 +202,16 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet): model = TenantGroup fields = ('id', 'name', 'slug', 'description') + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(slug__icontains=value) | + Q(description__icontains=value) | + Q(comments__icontains=value) + ) + class TenantFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): group_id = TreeNodeMultipleChoiceFilter( diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index a8528aec8..3f72a30c1 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -33,9 +33,10 @@ class TenantGroupBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = TenantGroup - nullable_fields = ('parent', 'description') + nullable_fields = ('parent', 'description', 'comments') class TenantBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index d227cef14..61c56a70f 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -31,7 +31,7 @@ class TenantGroupImportForm(NetBoxModelImportForm): class Meta: model = TenantGroup - fields = ('name', 'slug', 'parent', 'description', 'tags') + fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments') class TenantImportForm(NetBoxModelImportForm): diff --git a/netbox/tenancy/forms/model_forms.py b/netbox/tenancy/forms/model_forms.py index d65d47f1f..e31a28416 100644 --- a/netbox/tenancy/forms/model_forms.py +++ b/netbox/tenancy/forms/model_forms.py @@ -27,6 +27,7 @@ class TenantGroupForm(NetBoxModelForm): required=False ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Tenant Group')), @@ -35,7 +36,7 @@ class TenantGroupForm(NetBoxModelForm): class Meta: model = TenantGroup fields = [ - 'parent', 'name', 'slug', 'description', 'tags', + 'parent', 'name', 'slug', 'description', 'tags', 'comments' ] diff --git a/netbox/tenancy/search.py b/netbox/tenancy/search.py index 5050114a6..f9441c974 100644 --- a/netbox/tenancy/search.py +++ b/netbox/tenancy/search.py @@ -60,5 +60,6 @@ class TenantGroupIndex(SearchIndex): ('name', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('description',) diff --git a/netbox/tenancy/tables/tenants.py b/netbox/tenancy/tables/tenants.py index a10133a64..8c73fb5a6 100644 --- a/netbox/tenancy/tables/tenants.py +++ b/netbox/tenancy/tables/tenants.py @@ -28,7 +28,8 @@ class TenantGroupTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = TenantGroup fields = ( - 'pk', 'id', 'name', 'tenant_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + 'pk', 'id', 'name', 'tenant_count', 'description', 'comments', 'slug', 'tags', 'created', + 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'tenant_count', 'description') diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index 4dc33f943..1804e0a8e 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -21,6 +21,7 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase): brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'slug', 'tenant_count', 'url'] bulk_update_data = { 'description': 'New description', + 'comments': 'New Comment', } @classmethod @@ -28,12 +29,17 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase): parent_tenant_groups = ( TenantGroup.objects.create(name='Parent Tenant Group 1', slug='parent-tenant-group-1'), - TenantGroup.objects.create(name='Parent Tenant Group 2', slug='parent-tenant-group-2'), + TenantGroup.objects.create( + name='Parent Tenant Group 2', slug='parent-tenant-group-2', comments='Parent Group 2 comment', + ), ) TenantGroup.objects.create(name='Tenant Group 1', slug='tenant-group-1', parent=parent_tenant_groups[0]) TenantGroup.objects.create(name='Tenant Group 2', slug='tenant-group-2', parent=parent_tenant_groups[0]) - TenantGroup.objects.create(name='Tenant Group 3', slug='tenant-group-3', parent=parent_tenant_groups[0]) + TenantGroup.objects.create( + name='Tenant Group 3', slug='tenant-group-3', parent=parent_tenant_groups[0], + comments='Tenant Group 3 comment' + ) cls.create_data = [ { @@ -50,6 +56,7 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase): 'name': 'Tenant Group 6', 'slug': 'tenant-group-6', 'parent': parent_tenant_groups[1].pk, + 'comments': 'Tenant Group 6 comment', }, ] diff --git a/netbox/tenancy/tests/test_filtersets.py b/netbox/tenancy/tests/test_filtersets.py index 97005dd1e..7d44ee45d 100644 --- a/netbox/tenancy/tests/test_filtersets.py +++ b/netbox/tenancy/tests/test_filtersets.py @@ -16,7 +16,7 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests): parent_tenant_groups = ( TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), - TenantGroup(name='Tenant Group 2', slug='tenant-group-2'), + TenantGroup(name='Tenant Group 2', slug='tenant-group-2', comments='Parent group 2 comment'), TenantGroup(name='Tenant Group 3', slug='tenant-group-3'), ) for tenant_group in parent_tenant_groups: @@ -27,7 +27,8 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests): name='Tenant Group 1A', slug='tenant-group-1a', parent=parent_tenant_groups[0], - description='foobar1' + description='foobar1', + comments='Tenant Group 1A comment', ), TenantGroup( name='Tenant Group 2A', @@ -48,7 +49,10 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests): child_tenant_groups = ( TenantGroup(name='Tenant Group 1A1', slug='tenant-group-1a1', parent=tenant_groups[0]), TenantGroup(name='Tenant Group 2A1', slug='tenant-group-2a1', parent=tenant_groups[1]), - TenantGroup(name='Tenant Group 3A1', slug='tenant-group-3a1', parent=tenant_groups[2]), + TenantGroup( + name='Tenant Group 3A1', slug='tenant-group-3a1', parent=tenant_groups[2], + comments='Tenant Group 3A1 comment', + ), ) for tenant_group in child_tenant_groups: tenant_group.save() @@ -57,6 +61,13 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_q_comments(self): + params = {'q': 'parent'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'q': 'comment'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_name(self): params = {'name': ['Tenant Group 1', 'Tenant Group 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index b67ea428b..726c9ad97 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -15,7 +15,7 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): tenant_groups = ( TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), - TenantGroup(name='Tenant Group 2', slug='tenant-group-2'), + TenantGroup(name='Tenant Group 2', slug='tenant-group-2', comments='Tenant Group 2 comment'), TenantGroup(name='Tenant Group 3', slug='tenant-group-3'), ) for tenanantgroup in tenant_groups: @@ -28,24 +28,26 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'slug': 'tenant-group-x', 'description': 'A new tenant group', 'tags': [t.pk for t in tags], + 'comments': 'Tenant Group X comment', } cls.csv_data = ( - "name,slug,description", - "Tenant Group 4,tenant-group-4,Fourth tenant group", - "Tenant Group 5,tenant-group-5,Fifth tenant group", - "Tenant Group 6,tenant-group-6,Sixth tenant group", + "name,slug,description,comments", + "Tenant Group 4,tenant-group-4,Fourth tenant group,", + "Tenant Group 5,tenant-group-5,Fifth tenant group,", + "Tenant Group 6,tenant-group-6,Sixth tenant group,Sixth tenant group comment", ) cls.csv_update_data = ( - "id,name,description", - f"{tenant_groups[0].pk},Tenant Group 7,Fourth tenant group7", - f"{tenant_groups[1].pk},Tenant Group 8,Fifth tenant group8", - f"{tenant_groups[2].pk},Tenant Group 0,Sixth tenant group9", + "id,name,description,comments", + f"{tenant_groups[0].pk},Tenant Group 7,Fourth tenant group7,Group 7 comment", + f"{tenant_groups[1].pk},Tenant Group 8,Fifth tenant group8,", + f"{tenant_groups[2].pk},Tenant Group 0,Sixth tenant group9,", ) cls.bulk_edit_data = { 'description': 'New description', + 'comments': 'New comment', } From c0b019b735b612d3b2c9c123eb35cdb6a724ffa3 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Tue, 11 Mar 2025 11:51:25 -0500 Subject: [PATCH 043/103] Adds WirelessLANGroup.comments to all the required places - [x] 1. Add the field to the model class - [x] 2. Generate and run database migrations - [NA] 3. Add validation logic to clean() - [NA] 4. Update relevant querysets - [x] 5. Update API serializer - [x] 6. Add fields to forms - [x] wireless.forms.model_forms, create/edit (e.g. model_forms.py) - [x] wireless.forms.bulk_edit, bulk edit - [x] wireless.forms.bulk_import, CSV import - [NA] filter (UI and API) - [x] 7. Extend object filter set - [NA] 8. Add column to object table (Note: was already present) - [x] 9. Update the SearchIndex - [x] 10. Update the UI templates - [x] 11. Create/extend test cases - [NA] models - [x] views - [NA] forms - [x] filtersets - [x] api - [NA] 12. Update the model's documentation --- .../templates/wireless/wirelesslangroup.html | 1 + .../wireless/api/serializers_/wirelesslans.py | 2 +- netbox/wireless/filtersets.py | 10 +++++++++ netbox/wireless/forms/bulk_edit.py | 3 ++- netbox/wireless/forms/bulk_import.py | 2 +- netbox/wireless/forms/model_forms.py | 3 ++- netbox/wireless/search.py | 1 + netbox/wireless/tests/test_api.py | 3 +++ netbox/wireless/tests/test_filtersets.py | 19 ++++++++++++++-- netbox/wireless/tests/test_views.py | 22 +++++++++++-------- 10 files changed, 51 insertions(+), 15 deletions(-) diff --git a/netbox/templates/wireless/wirelesslangroup.html b/netbox/templates/wireless/wirelesslangroup.html index cb08b1b52..913e9da4c 100644 --- a/netbox/templates/wireless/wirelesslangroup.html +++ b/netbox/templates/wireless/wirelesslangroup.html @@ -40,6 +40,7 @@
{% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/wireless/api/serializers_/wirelesslans.py b/netbox/wireless/api/serializers_/wirelesslans.py index 68f79daf6..97d57f9f5 100644 --- a/netbox/wireless/api/serializers_/wirelesslans.py +++ b/netbox/wireless/api/serializers_/wirelesslans.py @@ -26,7 +26,7 @@ class WirelessLANGroupSerializer(NestedGroupModelSerializer): model = WirelessLANGroup fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'wirelesslan_count', '_depth', + 'created', 'last_updated', 'wirelesslan_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'wirelesslan_count', '_depth') diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index cc5aefbd8..17ef66c0a 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -43,6 +43,16 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): model = WirelessLANGroup fields = ('id', 'name', 'slug', 'description') + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(slug__icontains=value) | + Q(description__icontains=value) | + Q(comments__icontains=value) + ) + class WirelessLANFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet): group_id = TreeNodeMultipleChoiceFilter( diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 5cd3a157a..1a75512e1 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -32,12 +32,13 @@ class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = WirelessLANGroup fieldsets = ( FieldSet('parent', 'description'), ) - nullable_fields = ('parent', 'description') + nullable_fields = ('parent', 'description', 'comments') class WirelessLANBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 1fece7e46..389dcf25d 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -30,7 +30,7 @@ class WirelessLANGroupImportForm(NetBoxModelImportForm): class Meta: model = WirelessLANGroup - fields = ('name', 'slug', 'parent', 'description', 'tags') + fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments') class WirelessLANImportForm(ScopedImportForm, NetBoxModelImportForm): diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py index 9cfcca7ba..56422ab57 100644 --- a/netbox/wireless/forms/model_forms.py +++ b/netbox/wireless/forms/model_forms.py @@ -24,6 +24,7 @@ class WirelessLANGroupForm(NetBoxModelForm): required=False ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Wireless LAN Group')), @@ -32,7 +33,7 @@ class WirelessLANGroupForm(NetBoxModelForm): class Meta: model = WirelessLANGroup fields = [ - 'parent', 'name', 'slug', 'description', 'tags', + 'parent', 'name', 'slug', 'description', 'tags', 'comments', ] diff --git a/netbox/wireless/search.py b/netbox/wireless/search.py index e1be53c09..3c1565cb7 100644 --- a/netbox/wireless/search.py +++ b/netbox/wireless/search.py @@ -21,6 +21,7 @@ class WirelessLANGroupIndex(SearchIndex): ('name', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('description',) diff --git a/netbox/wireless/tests/test_api.py b/netbox/wireless/tests/test_api.py index f768eafaf..0fe5e45f6 100644 --- a/netbox/wireless/tests/test_api.py +++ b/netbox/wireless/tests/test_api.py @@ -24,10 +24,12 @@ class WirelessLANGroupTest(APIViewTestCases.APIViewTestCase): { 'name': 'Wireless LAN Group 4', 'slug': 'wireless-lan-group-4', + 'comments': '', }, { 'name': 'Wireless LAN Group 5', 'slug': 'wireless-lan-group-5', + 'comments': 'LAN Group 5 comment', }, { 'name': 'Wireless LAN Group 6', @@ -36,6 +38,7 @@ class WirelessLANGroupTest(APIViewTestCases.APIViewTestCase): ] bulk_update_data = { 'description': 'New description', + 'comments': 'New comment', } @classmethod diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py index 27aab83d8..9e8905d4a 100644 --- a/netbox/wireless/tests/test_filtersets.py +++ b/netbox/wireless/tests/test_filtersets.py @@ -21,7 +21,10 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): parent_groups = ( WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1', description='A'), WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2', description='B'), - WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3', description='C'), + WirelessLANGroup( + name='Wireless LAN Group 3', slug='wireless-lan-group-3', description='C', + comments='Parent Group 3 comment', + ), ) for group in parent_groups: group.save() @@ -38,10 +41,15 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): slug='wireless-lan-group-1b', parent=parent_groups[0], description='foobar2', + comments='Child Group 1B comment', ), WirelessLANGroup(name='Wireless LAN Group 2A', slug='wireless-lan-group-2a', parent=parent_groups[1]), WirelessLANGroup(name='Wireless LAN Group 2B', slug='wireless-lan-group-2b', parent=parent_groups[1]), - WirelessLANGroup(name='Wireless LAN Group 3A', slug='wireless-lan-group-3a', parent=parent_groups[2]), + WirelessLANGroup( + name='Wireless LAN Group 3A', slug='wireless-lan-group-3a', parent=parent_groups[2], + comments='Wireless LAN Group 3A comment', + + ), WirelessLANGroup(name='Wireless LAN Group 3B', slug='wireless-lan-group-3b', parent=parent_groups[2]), ) for group in groups: @@ -62,6 +70,13 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_q_comments(self): + params = {'q': 'parent'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'q': 'comment'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_name(self): params = {'name': ['Wireless LAN Group 1', 'Wireless LAN Group 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/wireless/tests/test_views.py b/netbox/wireless/tests/test_views.py index 51af37364..975f18c0d 100644 --- a/netbox/wireless/tests/test_views.py +++ b/netbox/wireless/tests/test_views.py @@ -16,7 +16,9 @@ class WirelessLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): groups = ( WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'), - WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'), + WirelessLANGroup( + name='Wireless LAN Group 2', slug='wireless-lan-group-2', comments='LAN Group 2 comment', + ), WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3'), ) for group in groups: @@ -30,24 +32,26 @@ class WirelessLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'parent': groups[2].pk, 'description': 'A new wireless LAN group', 'tags': [t.pk for t in tags], + 'comments': 'LAN Group X comment', } cls.csv_data = ( - "name,slug,description", - "Wireless LAN Group 4,wireless-lan-group-4,Fourth wireless LAN group", - "Wireless LAN Group 5,wireless-lan-group-5,Fifth wireless LAN group", - "Wireless LAN Group 6,wireless-lan-group-6,Sixth wireless LAN group", + "name,slug,description,comments", + "Wireless LAN Group 4,wireless-lan-group-4,Fourth wireless LAN group,", + "Wireless LAN Group 5,wireless-lan-group-5,Fifth wireless LAN group,", + "Wireless LAN Group 6,wireless-lan-group-6,Sixth wireless LAN group,LAN Group 6 comment", ) cls.csv_update_data = ( - "id,name,description", - f"{groups[0].pk},Wireless LAN Group 7,Fourth wireless LAN group7", - f"{groups[1].pk},Wireless LAN Group 8,Fifth wireless LAN group8", - f"{groups[2].pk},Wireless LAN Group 0,Sixth wireless LAN group9", + "id,name,description,comments", + f"{groups[0].pk},Wireless LAN Group 7,Fourth wireless LAN group7,Group 7 comment", + f"{groups[1].pk},Wireless LAN Group 8,Fifth wireless LAN group8,", + f"{groups[2].pk},Wireless LAN Group 0,Sixth wireless LAN group9,", ) cls.bulk_edit_data = { 'description': 'New description', + 'comments': 'New Comments', } From 1ea6f6e2ce14407226596cb2b596578314f143eb Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Tue, 11 Mar 2025 11:57:48 -0500 Subject: [PATCH 044/103] Ensures that all new comments fields render Markdown in tables --- netbox/dcim/tables/sites.py | 9 +++++++++ netbox/tenancy/tables/contacts.py | 3 +++ netbox/tenancy/tables/tenants.py | 3 +++ 3 files changed, 15 insertions(+) diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index cc4e00e7e..7d2f0e0cc 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -32,6 +32,9 @@ class RegionTable(ContactsColumnMixin, NetBoxTable): tags = columns.TagColumn( url_name='dcim:region_list' ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) class Meta(NetBoxTable.Meta): model = Region @@ -59,6 +62,9 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable): tags = columns.TagColumn( url_name='dcim:sitegroup_list' ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) class Meta(NetBoxTable.Meta): model = SiteGroup @@ -153,6 +159,9 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): actions = columns.ActionsColumn( extra_buttons=LOCATION_BUTTONS ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) class Meta(NetBoxTable.Meta): model = Location diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index e8761720e..ded6315ea 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -27,6 +27,9 @@ class ContactGroupTable(NetBoxTable): tags = columns.TagColumn( url_name='tenancy:contactgroup_list' ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) class Meta(NetBoxTable.Meta): model = ContactGroup diff --git a/netbox/tenancy/tables/tenants.py b/netbox/tenancy/tables/tenants.py index 8c73fb5a6..70f263dbe 100644 --- a/netbox/tenancy/tables/tenants.py +++ b/netbox/tenancy/tables/tenants.py @@ -24,6 +24,9 @@ class TenantGroupTable(NetBoxTable): tags = columns.TagColumn( url_name='tenancy:tenantgroup_list' ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) class Meta(NetBoxTable.Meta): model = TenantGroup From 2df68e29c9b9944a8ed48ae04671d9391081a3c2 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Tue, 11 Mar 2025 12:00:45 -0500 Subject: [PATCH 045/103] Ensures overridden filterset search() methods include fields from OrganizationalModelFilterSet --- netbox/dcim/filtersets.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 6fb6bf980..443a2dc36 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -115,6 +115,7 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): return queryset return queryset.filter( Q(name__icontains=value) | + Q(slug__icontains=value) | Q(description__icontains=value) | Q(comments__icontains=value) ).distinct() @@ -154,6 +155,7 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): return queryset return queryset.filter( Q(name__icontains=value) | + Q(slug__icontains=value) | Q(description__icontains=value) | Q(comments__icontains=value) ).distinct() @@ -297,6 +299,7 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM return queryset return queryset.filter( Q(name__icontains=value) | + Q(slug__icontains=value) | Q(facility__icontains=value) | Q(description__icontains=value) | Q(comments__icontains=value) From 06a206ee33853ae07a6f29067f0cd74ce13efe62 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Thu, 13 Mar 2025 15:36:55 -0500 Subject: [PATCH 046/103] Extract base NestedGroupModelFilterSet with base search behavior This can easily be extended (as in the case of LocationFilterSet) by calling super() and ORing a filter to the queryset that is returned. See: https://docs.djangoproject.com/en/5.1/ref/models/querysets/#or --- netbox/dcim/filtersets.py | 45 ++++++++++------------------------- netbox/netbox/filtersets.py | 16 +++++++++++++ netbox/tenancy/filtersets.py | 26 +++----------------- netbox/wireless/filtersets.py | 14 ++--------- 4 files changed, 33 insertions(+), 68 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 443a2dc36..6f9f481c3 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -11,7 +11,8 @@ from ipam.filtersets import PrimaryIPFilterSet from ipam.models import ASN, IPAddress, VLANTranslationPolicy, VRF from netbox.choices import ColorChoices from netbox.filtersets import ( - BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, + BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet, + OrganizationalModelFilterSet, ) from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from tenancy.models import * @@ -81,7 +82,7 @@ __all__ = ( ) -class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): +class RegionFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=Region.objects.all(), label=_('Parent region (ID)'), @@ -110,18 +111,8 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): model = Region fields = ('id', 'name', 'slug', 'description') - def search(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter( - Q(name__icontains=value) | - Q(slug__icontains=value) | - Q(description__icontains=value) | - Q(comments__icontains=value) - ).distinct() - -class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): +class SiteGroupFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=SiteGroup.objects.all(), label=_('Parent site group (ID)'), @@ -150,16 +141,6 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): model = SiteGroup fields = ('id', 'name', 'slug', 'description') - def search(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter( - Q(name__icontains=value) | - Q(slug__icontains=value) | - Q(description__icontains=value) | - Q(comments__icontains=value) - ).distinct() - class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): status = django_filters.MultipleChoiceFilter( @@ -225,7 +206,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe return queryset.filter(qs_filter).distinct() -class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet): +class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupModelFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', @@ -295,15 +276,13 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM fields = ('id', 'name', 'slug', 'facility', 'description') def search(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter( - Q(name__icontains=value) | - Q(slug__icontains=value) | - Q(facility__icontains=value) | - Q(description__icontains=value) | - Q(comments__icontains=value) - ) + # extended in order to include querying on Location.facility + queryset = super().search(queryset, name, value) + + if value.strip(): + queryset = queryset | queryset.model.objects.filter(facility__icontains=value) + + return queryset class RackRoleFilterSet(OrganizationalModelFilterSet): diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index b8fbe7ad5..d80b07e90 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -329,3 +329,19 @@ class OrganizationalModelFilterSet(NetBoxModelFilterSet): models.Q(slug__icontains=value) | models.Q(description__icontains=value) ) + + +class NestedGroupModelFilterSet(NetBoxModelFilterSet): + """ + A base FilterSet for models that inherit from NestedGroupModel + """ + def search(self, queryset, name, value): + if value.strip(): + queryset = queryset.filter( + models.Q(name__icontains=value) | + models.Q(slug__icontains=value) | + models.Q(description__icontains=value) | + models.Q(comments__icontains=value) + ) + + return queryset diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index c70b381ee..db7236abe 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -2,7 +2,7 @@ import django_filters from django.db.models import Q from django.utils.translation import gettext as _ -from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet +from netbox.filtersets import NestedGroupModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter from .models import * @@ -22,7 +22,7 @@ __all__ = ( # Contacts # -class ContactGroupFilterSet(OrganizationalModelFilterSet): +class ContactGroupFilterSet(NestedGroupModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=ContactGroup.objects.all(), label=_('Parent contact group (ID)'), @@ -51,16 +51,6 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): model = ContactGroup fields = ('id', 'name', 'slug', 'description') - def search(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter( - Q(name__icontains=value) | - Q(slug__icontains=value) | - Q(description__icontains=value) | - Q(comments__icontains=value) - ) - class ContactRoleFilterSet(OrganizationalModelFilterSet): @@ -173,7 +163,7 @@ class ContactModelFilterSet(django_filters.FilterSet): # Tenancy # -class TenantGroupFilterSet(OrganizationalModelFilterSet): +class TenantGroupFilterSet(NestedGroupModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=TenantGroup.objects.all(), label=_('Parent tenant group (ID)'), @@ -202,16 +192,6 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet): model = TenantGroup fields = ('id', 'name', 'slug', 'description') - def search(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter( - Q(name__icontains=value) | - Q(slug__icontains=value) | - Q(description__icontains=value) | - Q(comments__icontains=value) - ) - class TenantFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): group_id = TreeNodeMultipleChoiceFilter( diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 17ef66c0a..bd96865ad 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -5,7 +5,7 @@ from dcim.choices import LinkStatusChoices from dcim.base_filtersets import ScopedFilterSet from dcim.models import Interface from ipam.models import VLAN -from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet +from netbox.filtersets import NestedGroupModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import TreeNodeMultipleChoiceFilter from .choices import * @@ -18,7 +18,7 @@ __all__ = ( ) -class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): +class WirelessLANGroupFilterSet(NestedGroupModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=WirelessLANGroup.objects.all() ) @@ -43,16 +43,6 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): model = WirelessLANGroup fields = ('id', 'name', 'slug', 'description') - def search(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter( - Q(name__icontains=value) | - Q(slug__icontains=value) | - Q(description__icontains=value) | - Q(comments__icontains=value) - ) - class WirelessLANFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet): group_id = TreeNodeMultipleChoiceFilter( From b45e256f27670e6abae516f72bce35720d92995f Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Thu, 13 Mar 2025 15:43:32 -0500 Subject: [PATCH 047/103] Removes banner from new migrations --- ...0202_location_comments_region_comments_sitegroup_comments.py | 2 -- .../0018_contactgroup_comments_tenantgroup_comments.py | 2 -- netbox/wireless/migrations/0014_wirelesslangroup_comments.py | 2 -- 3 files changed, 6 deletions(-) diff --git a/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py b/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py index 51031de53..ffdc5ba8a 100644 --- a/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py +++ b/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py @@ -1,5 +1,3 @@ -# Generated by Django 5.1.7 on 2025-03-10 16:37 - from django.db import migrations, models diff --git a/netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py b/netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py index 3481baeec..5f6a95149 100644 --- a/netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py +++ b/netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py @@ -1,5 +1,3 @@ -# Generated by Django 5.1.7 on 2025-03-10 16:37 - from django.db import migrations, models diff --git a/netbox/wireless/migrations/0014_wirelesslangroup_comments.py b/netbox/wireless/migrations/0014_wirelesslangroup_comments.py index 3e3cab270..9fc1a99d6 100644 --- a/netbox/wireless/migrations/0014_wirelesslangroup_comments.py +++ b/netbox/wireless/migrations/0014_wirelesslangroup_comments.py @@ -1,5 +1,3 @@ -# Generated by Django 5.1.7 on 2025-03-10 16:37 - from django.db import migrations, models From ffe035567ad0805b5e180e6ffbbc2701a99536c1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Mar 2025 14:45:44 -0400 Subject: [PATCH 048/103] Closes #18820: Bump minimum PostgreSQL version to 14 (#18909) --- docs/configuration/required-parameters.md | 2 +- docs/installation/1-postgresql.md | 6 +++--- docs/installation/index.md | 2 +- docs/installation/upgrading.md | 2 +- docs/introduction.md | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md index f7e5d71ce..999fc8bb5 100644 --- a/docs/configuration/required-parameters.md +++ b/docs/configuration/required-parameters.md @@ -25,7 +25,7 @@ ALLOWED_HOSTS = ['*'] ## DATABASE -NetBox requires access to a PostgreSQL 13 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: +NetBox requires access to a PostgreSQL 14 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: * `NAME` - Database name * `USER` - PostgreSQL username diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index 8ba302909..536ecea64 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -2,8 +2,8 @@ This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md). -!!! warning "PostgreSQL 13 or later required" - NetBox requires PostgreSQL 13 or later. Please note that MySQL and other relational databases are **not** supported. +!!! warning "PostgreSQL 14 or later required" + NetBox requires PostgreSQL 14 or later. Please note that MySQL and other relational databases are **not** supported. ## Installation @@ -34,7 +34,7 @@ This section entails the installation and configuration of a local PostgreSQL da sudo systemctl enable --now postgresql ``` -Before continuing, verify that you have installed PostgreSQL 13 or later: +Before continuing, verify that you have installed PostgreSQL 14 or later: ```no-highlight psql -V diff --git a/docs/installation/index.md b/docs/installation/index.md index 33888e274..24e966805 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -21,7 +21,7 @@ The following sections detail how to set up a new instance of NetBox: | Dependency | Supported Versions | |------------|--------------------| | Python | 3.10, 3.11, 3.12 | -| PostgreSQL | 13+ | +| PostgreSQL | 14+ | | Redis | 4.0+ | Below is a simplified overview of the NetBox application stack for reference: diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index e6d05738f..07250e780 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -20,7 +20,7 @@ NetBox requires the following dependencies: | Dependency | Supported Versions | |------------|--------------------| | Python | 3.10, 3.11, 3.12 | -| PostgreSQL | 13+ | +| PostgreSQL | 14+ | | Redis | 4.0+ | ## 3. Install the Latest Release diff --git a/docs/introduction.md b/docs/introduction.md index 75701c119..c8e5ee8ac 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -79,5 +79,5 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and | HTTP service | nginx or Apache | | WSGI service | gunicorn or uWSGI | | Application | Django/Python | -| Database | PostgreSQL 13+ | +| Database | PostgreSQL 14+ | | Task queuing | Redis/django-rq | From 1b4e00aedaa52ee02e34d364851fd221d2fb5541 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Mon, 17 Mar 2025 07:36:34 -0700 Subject: [PATCH 049/103] 18896 Replace STORAGE_BACKEND with STORAGES and support Script running from S3 (#18680) --- base_requirements.txt | 4 + docs/administration/replicating-netbox.md | 2 +- docs/configuration/system.md | 41 ++++++++--- docs/customization/custom-scripts.md | 2 + docs/installation/3-netbox.md | 2 +- netbox/core/models/data.py | 11 --- netbox/core/models/files.py | 36 +++++++-- netbox/extras/forms/scripts.py | 30 ++++++++ netbox/extras/models/mixins.py | 36 ++++++++- netbox/extras/scripts.py | 52 ++++++++++++- netbox/extras/storage.py | 14 ++++ netbox/extras/views.py | 3 +- netbox/netbox/settings.py | 89 ++++++++++++----------- requirements.txt | 1 + 14 files changed, 246 insertions(+), 77 deletions(-) create mode 100644 netbox/extras/storage.py diff --git a/base_requirements.txt b/base_requirements.txt index f76019c27..6921f2d49 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -42,6 +42,10 @@ django-rich # https://github.com/rq/django-rq/blob/master/CHANGELOG.md django-rq +# Provides a variety of storage backends +# https://github.com/jschneier/django-storages/blob/master/CHANGELOG.rst +django-storages + # Abstraction models for rendering and paginating HTML tables # https://github.com/jieter/django-tables2/blob/master/CHANGELOG.md django-tables2 diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md index 7cc4d3832..f702c3ffd 100644 --- a/docs/administration/replicating-netbox.md +++ b/docs/administration/replicating-netbox.md @@ -54,7 +54,7 @@ pg_dump --username netbox --password --host localhost -s netbox > netbox_schema. By default, NetBox stores uploaded files (such as image attachments) in its media directory. To fully replicate an instance of NetBox, you'll need to copy both the database and the media files. !!! note - These operations are not necessary if your installation is utilizing a [remote storage backend](../configuration/system.md#storage_backend). + These operations are not necessary if your installation is utilizing a [remote storage backend](../configuration/system.md#storages). ### Archive the Media Directory diff --git a/docs/configuration/system.md b/docs/configuration/system.md index 81c1a6a94..aca59e4bb 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -196,23 +196,46 @@ The dotted path to the desired search backend class. `CachedValueSearchBackend` --- -## STORAGE_BACKEND +## STORAGES -Default: None (local storage) +The backend storage engine for handling uploaded files such as [image attachments](../models/extras/imageattachment.md) and [custom scripts](../customization/custom-scripts.md). NetBox integrates with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) and [`django-storage-swift`](https://github.com/dennisv/django-storage-swift) libraries, which provide backends for several popular file storage services. If not configured, local filesystem storage will be used. -The backend storage engine for handling uploaded files (e.g. image attachments). NetBox supports integration with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) and [`django-storage-swift`](https://github.com/dennisv/django-storage-swift) packages, which provide backends for several popular file storage services. If not configured, local filesystem storage will be used. +By default, the following configuration is used: -The configuration parameters for the specified storage backend are defined under the `STORAGE_CONFIG` setting. +```python +STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, + "scripts": { + "BACKEND": "extras.storage.ScriptFileSystemStorage", + }, +} +``` ---- +Within the `STORAGES` dictionary, `"default"` is used for image uploads, "staticfiles" is for static files and `"scripts"` is used for custom scripts. -## STORAGE_CONFIG +If using a remote storage like S3, define the config as `STORAGES[key]["OPTIONS"]` for each storage item as needed. For example: -Default: Empty +```python +STORAGES = { + "scripts": { + "BACKEND": "storages.backends.s3boto3.S3Boto3Storage", + "OPTIONS": { + 'access_key': 'access key', + 'secret_key': 'secret key', + } + }, +} +``` -A dictionary of configuration parameters for the storage backend configured as `STORAGE_BACKEND`. The specific parameters to be used here are specific to each backend; see the documentation for your selected backend ([`django-storages`](https://django-storages.readthedocs.io/en/stable/) or [`django-storage-swift`](https://github.com/dennisv/django-storage-swift)) for more detail. +The specific configuration settings for each storage backend can be found in the [django-storages documentation](https://django-storages.readthedocs.io/en/latest/index.html). -If `STORAGE_BACKEND` is not defined, this setting will be ignored. +!!! note + Any keys defined in the `STORAGES` configuration parameter replace those in the default configuration. It is only necessary to define keys within the `STORAGES` for the specific backend(s) you wish to configure. --- diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 1051b31f6..56dd08a76 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -140,6 +140,8 @@ The Script class provides two convenience methods for reading data from files: These two methods will load data in YAML or JSON format, respectively, from files within the local path (i.e. `SCRIPTS_ROOT`). +**Note:** These convenience methods are deprecated and will be removed in NetBox v4.4. These only work if running scripts within the local path, they will not work if using a storage other than ScriptFileSystemStorage. + ## Logging The Script object provides a set of convenient functions for recording messages at different severity levels: diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 60d60d4f0..d29fa994a 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -207,7 +207,7 @@ All Python packages required by NetBox are listed in `requirements.txt` and will ### Remote File Storage -By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/system.md#storage_backend) in `configuration.py`. +By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/system.md#storages) in `configuration.py`. ```no-highlight sudo sh -c "echo 'django-storages' >> /opt/netbox/local_requirements.txt" diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 3f97fb003..9a5da333a 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -357,17 +357,6 @@ class DataFile(models.Model): return is_modified - def write_to_disk(self, path, overwrite=False): - """ - Write the object's data to disk at the specified path - """ - # Check whether file already exists - if os.path.isfile(path) and not overwrite: - raise FileExistsError() - - with open(path, 'wb+') as new_file: - new_file.write(self.data) - class AutoSyncRecord(models.Model): """ diff --git a/netbox/core/models/files.py b/netbox/core/models/files.py index cc446bac7..ade13627f 100644 --- a/netbox/core/models/files.py +++ b/netbox/core/models/files.py @@ -1,13 +1,16 @@ import logging import os +from functools import cached_property from django.conf import settings from django.core.exceptions import ValidationError from django.db import models +from django.core.files.storage import storages from django.urls import reverse from django.utils.translation import gettext as _ from ..choices import ManagedFileRootPathChoices +from extras.storage import ScriptFileSystemStorage from netbox.models.features import SyncedDataMixin from utilities.querysets import RestrictedQuerySet @@ -76,15 +79,35 @@ class ManagedFile(SyncedDataMixin, models.Model): return os.path.join(self._resolve_root_path(), self.file_path) def _resolve_root_path(self): - return { - 'scripts': settings.SCRIPTS_ROOT, - 'reports': settings.REPORTS_ROOT, - }[self.file_root] + storage = self.storage + if isinstance(storage, ScriptFileSystemStorage): + return { + 'scripts': settings.SCRIPTS_ROOT, + 'reports': settings.REPORTS_ROOT, + }[self.file_root] + else: + return "" def sync_data(self): if self.data_file: self.file_path = os.path.basename(self.data_path) - self.data_file.write_to_disk(self.full_path, overwrite=True) + self._write_to_disk(self.full_path, overwrite=True) + + def _write_to_disk(self, path, overwrite=False): + """ + Write the object's data to disk at the specified path + """ + # Check whether file already exists + storage = self.storage + if storage.exists(path) and not overwrite: + raise FileExistsError() + + with storage.open(path, 'wb+') as new_file: + new_file.write(self.data) + + @cached_property + def storage(self): + return storages.create_storage(storages.backends["scripts"]) def clean(self): super().clean() @@ -104,8 +127,9 @@ class ManagedFile(SyncedDataMixin, models.Model): def delete(self, *args, **kwargs): # Delete file from disk + storage = self.storage try: - os.remove(self.full_path) + storage.delete(self.full_path) except FileNotFoundError: pass diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py index 331f7f01f..764246a2d 100644 --- a/netbox/extras/forms/scripts.py +++ b/netbox/extras/forms/scripts.py @@ -1,11 +1,18 @@ +import os + from django import forms +from django.conf import settings +from django.core.files.storage import storages from django.utils.translation import gettext_lazy as _ +from core.forms import ManagedFileForm from extras.choices import DurationChoices +from extras.storage import ScriptFileSystemStorage from utilities.forms.widgets import DateTimePicker, NumberWithOptions from utilities.datetime import local_now __all__ = ( + 'ScriptFileForm', 'ScriptForm', ) @@ -55,3 +62,26 @@ class ScriptForm(forms.Form): self.cleaned_data['_schedule_at'] = local_now() return self.cleaned_data + + +class ScriptFileForm(ManagedFileForm): + """ + ManagedFileForm with a custom save method to use django-storages. + """ + def save(self, *args, **kwargs): + # If a file was uploaded, save it to disk + if self.cleaned_data['upload_file']: + storage = storages.create_storage(storages.backends["scripts"]) + + filename = self.cleaned_data['upload_file'].name + if isinstance(storage, ScriptFileSystemStorage): + full_path = os.path.join(settings.SCRIPTS_ROOT, filename) + else: + full_path = filename + + self.instance.file_path = full_path + data = self.cleaned_data['upload_file'] + storage.save(filename, data) + + # need to skip ManagedFileForm save method + return super(ManagedFileForm, self).save(*args, **kwargs) diff --git a/netbox/extras/models/mixins.py b/netbox/extras/models/mixins.py index 0950324c8..f22b32004 100644 --- a/netbox/extras/models/mixins.py +++ b/netbox/extras/models/mixins.py @@ -1,11 +1,31 @@ +import importlib.abc +import importlib.util import os -from importlib.machinery import SourceFileLoader +import sys +from django.core.files.storage import storages __all__ = ( 'PythonModuleMixin', ) +class CustomStoragesLoader(importlib.abc.Loader): + """ + Custom loader for exec_module to use django-storages instead of the file system. + """ + def __init__(self, filename): + self.filename = filename + + def create_module(self, spec): + return None # Use default module creation + + def exec_module(self, module): + storage = storages.create_storage(storages.backends["scripts"]) + with storage.open(self.filename, 'rb') as f: + code = f.read() + exec(code, module.__dict__) + + class PythonModuleMixin: def get_jobs(self, name): @@ -33,6 +53,16 @@ class PythonModuleMixin: return name def get_module(self): - loader = SourceFileLoader(self.python_name, self.full_path) - module = loader.load_module() + """ + Load the module using importlib, but use a custom loader to use django-storages + instead of the file system. + """ + spec = importlib.util.spec_from_file_location(self.python_name, self.name) + if spec is None: + raise ModuleNotFoundError(f"Could not find module: {self.python_name}") + loader = CustomStoragesLoader(self.name) + module = importlib.util.module_from_spec(spec) + sys.modules[self.python_name] = module + loader.exec_module(module) + return module diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index f2bd75a1d..83195402d 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -2,10 +2,12 @@ import inspect import json import logging import os +import re import yaml from django import forms from django.conf import settings +from django.core.files.storage import storages from django.core.validators import RegexValidator from django.utils import timezone from django.utils.functional import classproperty @@ -367,9 +369,46 @@ class BaseScript: def filename(self): return inspect.getfile(self.__class__) + def findsource(self, object): + storage = storages.create_storage(storages.backends["scripts"]) + with storage.open(os.path.basename(self.filename), 'r') as f: + data = f.read() + + # Break the source code into lines + lines = [line + '\n' for line in data.splitlines()] + + # Find the class definition + name = object.__name__ + pat = re.compile(r'^(\s*)class\s*' + name + r'\b') + # use the class definition with the least indentation + candidates = [] + for i in range(len(lines)): + match = pat.match(lines[i]) + if match: + if lines[i][0] == 'c': + return lines, i + + candidates.append((match.group(1), i)) + if not candidates: + raise OSError('could not find class definition') + + # Sort the candidates by whitespace, and by line number + candidates.sort() + return lines, candidates[0][1] + @property def source(self): - return inspect.getsource(self.__class__) + # Can't use inspect.getsource() as it uses os to get the file + # inspect uses ast, but that is overkill for this as we only do + # classes. + object = self.__class__ + + try: + lines, lnum = self.findsource(object) + lines = inspect.getblock(lines[lnum:]) + return ''.join(lines) + except OSError: + return '' @classmethod def _get_vars(cls): @@ -524,7 +563,12 @@ class BaseScript: def load_yaml(self, filename): """ Return data from a YAML file + TODO: DEPRECATED: Remove this method in v4.4 """ + self._log( + _("load_yaml is deprecated and will be removed in v4.4"), + level=LogLevelChoices.LOG_WARNING + ) try: from yaml import CLoader as Loader except ImportError: @@ -539,7 +583,12 @@ class BaseScript: def load_json(self, filename): """ Return data from a JSON file + TODO: DEPRECATED: Remove this method in v4.4 """ + self._log( + _("load_json is deprecated and will be removed in v4.4"), + level=LogLevelChoices.LOG_WARNING + ) file_path = os.path.join(settings.SCRIPTS_ROOT, filename) with open(file_path, 'r') as datafile: data = json.load(datafile) @@ -555,7 +604,6 @@ class BaseScript: Run the report and save its results. Each test method will be executed in order. """ self.logger.info("Running report") - try: for test_name in self.tests: self._current_test = test_name diff --git a/netbox/extras/storage.py b/netbox/extras/storage.py new file mode 100644 index 000000000..ede4fac7f --- /dev/null +++ b/netbox/extras/storage.py @@ -0,0 +1,14 @@ +from django.conf import settings +from django.core.files.storage import FileSystemStorage +from django.utils.functional import cached_property + + +class ScriptFileSystemStorage(FileSystemStorage): + """ + Custom storage for scripts - for django-storages as the default one will + go off media-root and raise security errors as the scripts can be outside + the media-root directory. + """ + @cached_property + def base_location(self): + return settings.SCRIPTS_ROOT diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 9cb9dd54a..2833cec0d 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -12,7 +12,6 @@ from django.utils.translation import gettext as _ from django.views.generic import View from core.choices import ManagedFileRootPathChoices -from core.forms import ManagedFileForm from core.models import Job from core.tables import JobTable from dcim.models import Device, DeviceRole, Platform @@ -1163,7 +1162,7 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View): @register_model_view(ScriptModule, 'edit') class ScriptModuleCreateView(generic.ObjectEditView): queryset = ScriptModule.objects.all() - form = ManagedFileForm + form = forms.ScriptFileForm def alter_object(self, obj, *args, **kwargs): obj.file_root = ManagedFileRootPathChoices.SCRIPTS diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 57b143f5f..defc5d99b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -17,6 +17,7 @@ from netbox.config import PARAMS as CONFIG_PARAMS from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW from netbox.plugins import PluginConfig from netbox.registry import registry +import storages.utils # type: ignore from utilities.release import load_release_data from utilities.string import trailing_slash @@ -177,7 +178,8 @@ SESSION_COOKIE_PATH = CSRF_COOKIE_PATH SESSION_COOKIE_SECURE = getattr(configuration, 'SESSION_COOKIE_SECURE', False) SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None) -STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {}) +STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', None) +STORAGES = getattr(configuration, 'STORAGES', {}) TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') TRANSLATION_ENABLED = getattr(configuration, 'TRANSLATION_ENABLED', True) @@ -234,61 +236,64 @@ DATABASES = { # Storage backend # +if STORAGE_BACKEND is not None: + if not STORAGES: + raise ImproperlyConfigured( + "STORAGE_BACKEND and STORAGES are both set, remove the deprecated STORAGE_BACKEND setting." + ) + else: + warnings.warn( + "STORAGE_BACKEND is deprecated, use the new STORAGES setting instead." + ) + +if STORAGE_CONFIG is not None: + warnings.warn( + "STORAGE_CONFIG is deprecated, use the new STORAGES setting instead." + ) + # Default STORAGES for Django -STORAGES = { +DEFAULT_STORAGES = { "default": { "BACKEND": "django.core.files.storage.FileSystemStorage", }, "staticfiles": { "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", }, + "scripts": { + "BACKEND": "extras.storage.ScriptFileSystemStorage", + }, } +STORAGES = DEFAULT_STORAGES | STORAGES +# TODO: This code is deprecated and needs to be removed in the future if STORAGE_BACKEND is not None: STORAGES['default']['BACKEND'] = STORAGE_BACKEND - # django-storages - if STORAGE_BACKEND.startswith('storages.'): - try: - import storages.utils # type: ignore - except ModuleNotFoundError as e: - if getattr(e, 'name') == 'storages': - raise ImproperlyConfigured( - f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storages is not present. It can be " - f"installed by running 'pip install django-storages'." - ) - raise e +# Monkey-patch django-storages to fetch settings from STORAGE_CONFIG +if STORAGE_CONFIG is not None: + def _setting(name, default=None): + if name in STORAGE_CONFIG: + return STORAGE_CONFIG[name] + return globals().get(name, default) + storages.utils.setting = _setting - # Monkey-patch django-storages to fetch settings from STORAGE_CONFIG - def _setting(name, default=None): - if name in STORAGE_CONFIG: - return STORAGE_CONFIG[name] - return globals().get(name, default) - storages.utils.setting = _setting - - # django-storage-swift - elif STORAGE_BACKEND == 'swift.storage.SwiftStorage': - try: - import swift.utils # noqa: F401 - except ModuleNotFoundError as e: - if getattr(e, 'name') == 'swift': - raise ImproperlyConfigured( - f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storage-swift is not present. " - "It can be installed by running 'pip install django-storage-swift'." - ) - raise e - - # Load all SWIFT_* settings from the user configuration - for param, value in STORAGE_CONFIG.items(): - if param.startswith('SWIFT_'): - globals()[param] = value - -if STORAGE_CONFIG and STORAGE_BACKEND is None: - warnings.warn( - "STORAGE_CONFIG has been set in configuration.py but STORAGE_BACKEND is not defined. STORAGE_CONFIG will be " - "ignored." - ) +# django-storage-swift +if STORAGE_BACKEND == 'swift.storage.SwiftStorage': + try: + import swift.utils # noqa: F401 + except ModuleNotFoundError as e: + if getattr(e, 'name') == 'swift': + raise ImproperlyConfigured( + f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storage-swift is not present. " + "It can be installed by running 'pip install django-storage-swift'." + ) + raise e + # Load all SWIFT_* settings from the user configuration + for param, value in STORAGE_CONFIG.items(): + if param.startswith('SWIFT_'): + globals()[param] = value +# TODO: End of deprecated code # # Redis diff --git a/requirements.txt b/requirements.txt index 9e0e73d5b..210a0b1d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ django-prometheus==2.3.1 django-redis==5.4.0 django-rich==1.13.0 django-rq==3.0 +django-storages==1.14.4 django-taggit==6.1.0 django-tables2==2.7.5 django-timezone-field==7.1 From f69de12c6d9922ee15138cab326f3cc7ab09dab8 Mon Sep 17 00:00:00 2001 From: bctiemann Date: Mon, 17 Mar 2025 13:02:18 -0400 Subject: [PATCH 050/103] Closes: #15842 - Option to hide local login form if SSO is in use (#18924) Closes: #15842 Branched from #18145 by @tobiasge Provides a new LOGIN_FORM_HIDDEN setting which allows the administrator to hide the local login form, intended only to be used when SSO is used exclusively for authentication. Note that this means local login will be impossible in the event of SSO provider issues, and can be remedied only through a change to the application config and a restart of the service. * #15842 - Hide login form This doesn't implement the full solution proposed in #15842 but enables administrators to hide the login form when users should only login with a SSO provider. To prevent a complete lockout when the SSO provider is having issues the GET parameter `skipsso` can be added to the login URL to show the form regardless. * Remove skipsso backdoor * Add warning --------- Co-authored-by: Tobias Genannt --- docs/configuration/security.md | 11 ++++ netbox/account/views.py | 2 + netbox/netbox/configuration_example.py | 3 ++ netbox/netbox/settings.py | 1 + netbox/templates/login.html | 73 ++++++++++++++------------ 5 files changed, 57 insertions(+), 33 deletions(-) diff --git a/docs/configuration/security.md b/docs/configuration/security.md index b97f31432..172034b4f 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -186,6 +186,17 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u --- +## LOGIN_FORM_HIDDEN + +Default: False + +Option to hide the login form when only SSO authentication is in use. + +!!! warning + If the SSO provider is unreachable, login to NetBox will be impossible if this option is enabled. The only recourse is to disable it in the local configuration and restart the NetBox service. + +--- + ## LOGOUT_REDIRECT_URL Default: `'home'` diff --git a/netbox/account/views.py b/netbox/account/views.py index 05f40df3f..3a2dc6b32 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -89,10 +89,12 @@ class LoginView(View): if request.user.is_authenticated: logger = logging.getLogger('netbox.auth.login') return self.redirect_to_next(request, logger) + login_form_hidden = settings.LOGIN_FORM_HIDDEN return render(request, self.template_name, { 'form': form, 'auth_backends': self.get_auth_backends(request), + 'login_form_hidden': login_form_hidden, }) def post(self, request): diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py index 84ead5339..a07661208 100644 --- a/netbox/netbox/configuration_example.py +++ b/netbox/netbox/configuration_example.py @@ -164,6 +164,9 @@ LOGIN_REQUIRED = True # re-authenticate. (Default: 1209600 [14 days]) LOGIN_TIMEOUT = None +# Hide the login form. Useful when only allowing SSO authentication. +LOGIN_FORM_HIDDEN = False + # The view name or URL to which users are redirected after logging out. LOGOUT_REDIRECT_URL = 'home' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index defc5d99b..0c8f7ca70 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -129,6 +129,7 @@ LOGGING = getattr(configuration, 'LOGGING', {}) LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', True) LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) +LOGIN_FORM_HIDDEN = getattr(configuration, 'LOGIN_FORM_HIDDEN', False) LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home') MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/') METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False) diff --git a/netbox/templates/login.html b/netbox/templates/login.html index e50303911..079d66a67 100644 --- a/netbox/templates/login.html +++ b/netbox/templates/login.html @@ -34,48 +34,55 @@ {% endif %}
-
-

{% trans "Log In" %}

+ {% if not login_form_hidden %} +
+

{% trans "Log In" %}

- {# Login form #} -
- {% csrf_token %} + {# Login form #} + + {% csrf_token %} - {# Set post-login URL #} - {% if 'next' in request.GET %} - - {% elif 'next' in request.POST %} - - {% endif %} + {# Set post-login URL #} + {% if 'next' in request.GET %} + + {% elif 'next' in request.POST %} + + {% endif %} -
- - {{ form.username }} - {% for error in form.username.errors %} -
{{ error }}
- {% endfor %} -
+
+ + {{ form.username }} + {% for error in form.username.errors %} +
{{ error }}
+ {% endfor %} +
-
- - {{ form.password }} - {% for error in form.password.errors %} -
{{ error }}
- {% endfor %} -
+
+ + {{ form.password }} + {% for error in form.password.errors %} +
{{ error }}
+ {% endfor %} +
- -
-
+ + +
+ {% endif %} {# SSO login #} {% if auth_backends %} -
{% trans "Or" context "Denotes an alternative option" %}
+ {% if not login_form_hidden %} +
{% trans "Or" context "Denotes an alternative option" %}
+ {% endif %}
+ {% if login_form_hidden %} +

{% trans "Log In" %}

+ {% endif %}
{% for backend in auth_backends %}
From d4f8cb72aa6ef010b3746c29973ecf276f392bf9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Mar 2025 13:23:37 -0400 Subject: [PATCH 051/103] Closes #18780: External database configuration (#18912) --- docs/configuration/required-parameters.md | 45 ++++++++++++++++++----- docs/configuration/system.md | 8 ++++ docs/development/getting-started.md | 2 +- docs/installation/3-netbox.md | 24 +++++++----- netbox/netbox/configuration_example.py | 18 +++++---- netbox/netbox/configuration_testing.py | 16 ++++---- netbox/netbox/settings.py | 30 ++++++++------- 7 files changed, 94 insertions(+), 49 deletions(-) diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md index 999fc8bb5..4a18e8a6c 100644 --- a/docs/configuration/required-parameters.md +++ b/docs/configuration/required-parameters.md @@ -25,7 +25,30 @@ ALLOWED_HOSTS = ['*'] ## DATABASE -NetBox requires access to a PostgreSQL 14 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: +!!! warning "Legacy Configuration Parameter" + The `DATABASE` configuration parameter is deprecated and will be removed in a future release. Users are advised to adopt the new `DATABASES` (plural) parameter, which allows for the configuration of multiple databases. + +See the [`DATABASES`](#databases) configuration below for usage. + +--- + +## DATABASES + +!!! info "This parameter was introduced in NetBox v4.3." + +NetBox requires access to a PostgreSQL 14 or later database service to store data. This service can run locally on the NetBox server or on a remote system. Databases are defined as named dictionaries: + +```python +DATABASES = { + 'default': {...}, + 'external1': {...}, + 'external2': {...}, +} +``` + +NetBox itself requires only that a `default` database is defined. However, certain plugins may require the configuration of additional databases. (Consider also configuring the [`DATABASE_ROUTERS`](./system.md#database_routers) parameter when multiple databases are in use.) + +The following parameters must be defined for each database: * `NAME` - Database name * `USER` - PostgreSQL username @@ -38,14 +61,16 @@ NetBox requires access to a PostgreSQL 14 or later database service to store dat Example: ```python -DATABASE = { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': 'netbox', # Database name - 'USER': 'netbox', # PostgreSQL username - 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password - 'HOST': 'localhost', # Database server - 'PORT': '', # Database port (leave blank for default) - 'CONN_MAX_AGE': 300, # Max database connection age +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'netbox', # Database name + 'USER': 'netbox', # PostgreSQL username + 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password + 'HOST': 'localhost', # Database server + 'PORT': '', # Database port (leave blank for default) + 'CONN_MAX_AGE': 300, # Max database connection age + } } ``` @@ -53,7 +78,7 @@ DATABASE = { NetBox supports all PostgreSQL database options supported by the underlying Django framework. For a complete list of available parameters, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#databases). !!! warning - Make sure to use a PostgreSQL-compatible backend for the ENGINE setting. If you don't specify an ENGINE, the default will be django.db.backends.postgresql. + The `ENGINE` parameter must specify a PostgreSQL-compatible database backend. If not defined, the default engine `django.db.backends.postgresql` will be used. --- diff --git a/docs/configuration/system.md b/docs/configuration/system.md index aca59e4bb..11db09370 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -12,6 +12,14 @@ BASE_PATH = 'netbox/' --- +## DATABASE_ROUTERS + +Default: `[]` (empty list) + +An iterable of [database routers](https://docs.djangoproject.com/en/stable/topics/db/multi-db/) to use for automatically selecting the appropriate database(s) for a query. This is useful only when [multiple databases](./required-parameters.md#databases) have been configured. + +--- + ## DEFAULT_LANGUAGE Default: `en-us` (US English) diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md index 0b77bfd4d..129bf2d4b 100644 --- a/docs/development/getting-started.md +++ b/docs/development/getting-started.md @@ -115,7 +115,7 @@ You may also need to set up the yarn packages as shown in the [Web UI Developmen Within the `netbox/netbox/` directory, copy `configuration_example.py` to `configuration.py` and update the following parameters: * `ALLOWED_HOSTS`: This can be set to `['*']` for development purposes -* `DATABASE`: PostgreSQL database connection parameters +* `DATABASES`: PostgreSQL database connection parameters * `REDIS`: Redis configuration (if different from the defaults) * `SECRET_KEY`: Set to a random string (use `generate_secret_key.py` in the parent directory to generate a suitable key) * `DEBUG`: Set to `True` diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index d29fa994a..33eef6057 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -128,7 +128,7 @@ sudo cp configuration_example.py configuration.py Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations: * `ALLOWED_HOSTS` -* `DATABASE` +* `DATABASES` (or `DATABASE`) * `REDIS` * `SECRET_KEY` @@ -146,18 +146,22 @@ If you are not yet sure what the domain name and/or IP address of the NetBox ins ALLOWED_HOSTS = ['*'] ``` -### DATABASE +### DATABASES -This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](../configuration/required-parameters.md#database) for more detail on individual parameters. +This parameter holds the PostgreSQL database configuration details. The default database must be defined; additional databases may be defined as needed e.g. by plugins. + +A username and password must be defined for the default database. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](../configuration/required-parameters.md#databases) for more detail on individual parameters. ```python -DATABASE = { - 'NAME': 'netbox', # Database name - 'USER': 'netbox', # PostgreSQL username - 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password - 'HOST': 'localhost', # Database server - 'PORT': '', # Database port (leave blank for default) - 'CONN_MAX_AGE': 300, # Max database connection age (seconds) +DATABASES = { + 'default': { + 'NAME': 'netbox', # Database name + 'USER': 'netbox', # PostgreSQL username + 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password + 'HOST': 'localhost', # Database server + 'PORT': '', # Database port (leave blank for default) + 'CONN_MAX_AGE': 300, # Max database connection age (seconds) + } } ``` diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py index a07661208..27d44a2ff 100644 --- a/netbox/netbox/configuration_example.py +++ b/netbox/netbox/configuration_example.py @@ -12,14 +12,16 @@ ALLOWED_HOSTS = [] # PostgreSQL database configuration. See the Django documentation for a complete list of available parameters: # https://docs.djangoproject.com/en/stable/ref/settings/#databases -DATABASE = { - 'ENGINE': 'django.db.backends.postgresql', # Database engine - 'NAME': 'netbox', # Database name - 'USER': '', # PostgreSQL username - 'PASSWORD': '', # PostgreSQL password - 'HOST': 'localhost', # Database server - 'PORT': '', # Database port (leave blank for default) - 'CONN_MAX_AGE': 300, # Max database connection age +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', # Database engine + 'NAME': 'netbox', # Database name + 'USER': '', # PostgreSQL username + 'PASSWORD': '', # PostgreSQL password + 'HOST': 'localhost', # Database server + 'PORT': '', # Database port (leave blank for default) + 'CONN_MAX_AGE': 300, # Max database connection age + } } # Redis database settings. Redis is used for caching and for queuing background tasks such as webhook events. A separate diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index cec05cabb..142b50bb0 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -5,13 +5,15 @@ ALLOWED_HOSTS = ['*'] -DATABASE = { - 'NAME': 'netbox', - 'USER': 'netbox', - 'PASSWORD': 'netbox', - 'HOST': 'localhost', - 'PORT': '', - 'CONN_MAX_AGE': 300, +DATABASES = { + 'default': { + 'NAME': 'netbox', + 'USER': 'netbox', + 'PASSWORD': 'netbox', + 'HOST': 'localhost', + 'PORT': '', + 'CONN_MAX_AGE': 300, + } } PLUGINS = [ diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 0c8f7ca70..3b47ab541 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -53,10 +53,14 @@ except ModuleNotFoundError as e: ) raise -# Check for missing required configuration parameters -for parameter in ('ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY', 'REDIS'): +# Check for missing/conflicting required configuration parameters +for parameter in ('ALLOWED_HOSTS', 'SECRET_KEY', 'REDIS'): if not hasattr(configuration, parameter): raise ImproperlyConfigured(f"Required parameter {parameter} is missing from configuration.") +if not hasattr(configuration, 'DATABASE') and not hasattr(configuration, 'DATABASES'): + raise ImproperlyConfigured("The database configuration must be defined using DATABASE or DATABASES.") +elif hasattr(configuration, 'DATABASE') and hasattr(configuration, 'DATABASES'): + raise ImproperlyConfigured("DATABASE and DATABASES may not be set together. The use of DATABASES is encouraged.") # Set static config parameters ADMINS = getattr(configuration, 'ADMINS', []) @@ -84,7 +88,9 @@ CSRF_COOKIE_PATH = f'/{BASE_PATH.rstrip("/")}' CSRF_COOKIE_SECURE = getattr(configuration, 'CSRF_COOKIE_SECURE', False) CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', []) DATA_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'DATA_UPLOAD_MAX_MEMORY_SIZE', 2621440) -DATABASE = getattr(configuration, 'DATABASE') # Required +DATABASE = getattr(configuration, 'DATABASE', None) # Legacy DB definition +DATABASE_ROUTERS = getattr(configuration, 'DATABASE_ROUTERS', []) +DATABASES = getattr(configuration, 'DATABASES', {'default': DATABASE}) DEBUG = getattr(configuration, 'DEBUG', False) DEFAULT_DASHBOARD = getattr(configuration, 'DEFAULT_DASHBOARD', None) DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', { @@ -220,17 +226,15 @@ for path in PROXY_ROUTERS: # Database # -# Set the database engine -if 'ENGINE' not in DATABASE: - if METRICS_ENABLED: - DATABASE.update({'ENGINE': 'django_prometheus.db.backends.postgresql'}) - else: - DATABASE.update({'ENGINE': 'django.db.backends.postgresql'}) +# Verify that a default database has been configured +if 'default' not in DATABASES: + raise ImproperlyConfigured("No default database has been configured.") -# Define the DATABASES setting for Django -DATABASES = { - 'default': DATABASE, -} +# Set the database engine +if 'ENGINE' not in DATABASES['default']: + DATABASES['default'].update({ + 'ENGINE': 'django_prometheus.db.backends.postgresql' if METRICS_ENABLED else 'django.db.backends.postgresql' + }) # From af5ec19430f3103b79ce414ee203398d46f4c643 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Tue, 18 Mar 2025 11:05:02 -0700 Subject: [PATCH 052/103] 17170 Add ability to add contacts to multiple contact groups (#18885) * 17170 Allow multiple Group assignments for Contacts * 17170 update docs * 17170 update api, detail view, graphql * 17170 fixes * 17170 fixes * 17170 fixes * 17170 fixes * 17170 fixes * 17170 fixes * 17170 fix bulk import * 17170 test fixes * 17170 test fixes * 17170 test fixes * 17178 review changes * 17178 review changes * 17178 review changes * 17178 review changes * 17178 review changes * 17178 review changes * 17170 update migration * 17170 bulk edit form --- docs/models/tenancy/contact.md | 6 +- netbox/templates/tenancy/contact.html | 14 +++- netbox/tenancy/api/serializers_/contacts.py | 11 ++- netbox/tenancy/api/views.py | 2 +- netbox/tenancy/filtersets.py | 17 +++-- netbox/tenancy/forms/bulk_edit.py | 19 ++++-- netbox/tenancy/forms/bulk_import.py | 9 ++- netbox/tenancy/forms/filtersets.py | 2 +- netbox/tenancy/forms/model_forms.py | 12 ++-- netbox/tenancy/graphql/types.py | 2 +- .../tenancy/migrations/0018_contact_groups.py | 68 +++++++++++++++++++ netbox/tenancy/models/contacts.py | 29 +++++--- netbox/tenancy/tables/contacts.py | 10 +-- netbox/tenancy/tests/test_api.py | 14 ++-- netbox/tenancy/tests/test_filtersets.py | 19 ++++-- netbox/tenancy/tests/test_views.py | 29 ++++---- netbox/tenancy/views.py | 15 +++- netbox/utilities/testing/filtersets.py | 4 +- 18 files changed, 204 insertions(+), 78 deletions(-) create mode 100644 netbox/tenancy/migrations/0018_contact_groups.py diff --git a/docs/models/tenancy/contact.md b/docs/models/tenancy/contact.md index eac630180..f277ab499 100644 --- a/docs/models/tenancy/contact.md +++ b/docs/models/tenancy/contact.md @@ -4,9 +4,11 @@ A contact represents an individual or group that has been associated with an obj ## Fields -### Group +### Groups -The [contact group](./contactgroup.md) to which this contact is assigned (if any). +The [contact groups](./contactgroup.md) to which this contact is assigned (if any). + +!!! info "This field was renamed from `group` to `groups` in NetBox v4.3, and now supports the assignment of a contact to more than one group." ### Name diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index b2d1a4078..f34a3573f 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -18,8 +18,18 @@

{% trans "Contact" %}

- - + + diff --git a/netbox/tenancy/api/serializers_/contacts.py b/netbox/tenancy/api/serializers_/contacts.py index 8c24df734..a5c4dd741 100644 --- a/netbox/tenancy/api/serializers_/contacts.py +++ b/netbox/tenancy/api/serializers_/contacts.py @@ -3,7 +3,7 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer from tenancy.choices import ContactPriorityChoices from tenancy.models import ContactAssignment, Contact, ContactGroup, ContactRole @@ -43,12 +43,17 @@ class ContactRoleSerializer(NetBoxModelSerializer): class ContactSerializer(NetBoxModelSerializer): - group = ContactGroupSerializer(nested=True, required=False, allow_null=True, default=None) + groups = SerializedPKRelatedField( + queryset=ContactGroup.objects.all(), + serializer=ContactGroupSerializer, + required=False, + many=True + ) class Meta: model = Contact fields = [ - 'id', 'url', 'display_url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'link', + 'id', 'url', 'display_url', 'display', 'groups', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 70330ddb8..371b8ec24 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -44,7 +44,7 @@ class ContactGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, - 'group', + 'groups', 'contact_count', cumulative=True ) diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index e2de18231..0042ca427 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -46,6 +46,11 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label=_('Contact group (slug)'), ) + contact_id = django_filters.ModelMultipleChoiceFilter( + field_name='contact', + queryset=Contact.objects.all(), + label=_('Contact (ID)'), + ) class Meta: model = ContactGroup @@ -62,15 +67,15 @@ class ContactRoleFilterSet(OrganizationalModelFilterSet): class ContactFilterSet(NetBoxModelFilterSet): group_id = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - field_name='group', + field_name='groups', lookup_expr='in', label=_('Contact group (ID)'), ) group = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - field_name='group', - lookup_expr='in', + field_name='groups', to_field_name='slug', + lookup_expr='in', label=_('Contact group (slug)'), ) @@ -105,13 +110,13 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet): ) group_id = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - field_name='contact__group', + field_name='contact__groups', lookup_expr='in', label=_('Contact group (ID)'), ) group = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - field_name='contact__group', + field_name='contact__groups', lookup_expr='in', to_field_name='slug', label=_('Contact group (slug)'), @@ -153,7 +158,7 @@ class ContactModelFilterSet(django_filters.FilterSet): ) contact_group = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - field_name='contacts__contact__group', + field_name='contacts__contact__groups', lookup_expr='in', label=_('Contact group'), ) diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index 5af3f22ac..63eaaad9d 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -5,7 +5,7 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.choices import ContactPriorityChoices from tenancy.models import * from utilities.forms import add_blank_choice -from utilities.forms.fields import CommentField, DynamicModelChoiceField +from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.rendering import FieldSet __all__ = ( @@ -90,8 +90,13 @@ class ContactRoleBulkEditForm(NetBoxModelBulkEditForm): class ContactBulkEditForm(NetBoxModelBulkEditForm): - group = DynamicModelChoiceField( - label=_('Group'), + add_groups = DynamicModelMultipleChoiceField( + label=_('Add groups'), + queryset=ContactGroup.objects.all(), + required=False + ) + remove_groups = DynamicModelMultipleChoiceField( + label=_('Remove groups'), queryset=ContactGroup.objects.all(), required=False ) @@ -127,9 +132,13 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm): model = Contact fieldsets = ( - FieldSet('group', 'title', 'phone', 'email', 'address', 'link', 'description'), + FieldSet('title', 'phone', 'email', 'address', 'link', 'description'), + FieldSet('add_groups', 'remove_groups', name=_('Groups')), + ) + + nullable_fields = ( + 'add_groups', 'remove_groups', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments' ) - nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments') class ContactAssignmentBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index f37317549..c4f48347a 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelImportForm from tenancy.models import * -from utilities.forms.fields import CSVContentTypeField, CSVModelChoiceField, SlugField +from utilities.forms.fields import CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField __all__ = ( 'ContactAssignmentImportForm', @@ -77,17 +77,16 @@ class ContactRoleImportForm(NetBoxModelImportForm): class ContactImportForm(NetBoxModelImportForm): - group = CSVModelChoiceField( - label=_('Group'), + groups = CSVModelMultipleChoiceField( queryset=ContactGroup.objects.all(), required=False, to_field_name='name', - help_text=_('Assigned group') + help_text=_('Group names separated by commas, encased with double quotes (e.g. "Group 1,Group 2")') ) class Meta: model = Contact - fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'description', 'comments', 'tags') + fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'groups', 'description', 'comments', 'tags') class ContactAssignmentImportForm(NetBoxModelImportForm): diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 960ca45b1..6541d9693 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -75,7 +75,7 @@ class ContactFilterForm(NetBoxModelFilterSetForm): queryset=ContactGroup.objects.all(), required=False, null_option='None', - label=_('Group') + label=_('Groups') ) tag = TagFilterField(model) diff --git a/netbox/tenancy/forms/model_forms.py b/netbox/tenancy/forms/model_forms.py index bc18deed6..65beebca9 100644 --- a/netbox/tenancy/forms/model_forms.py +++ b/netbox/tenancy/forms/model_forms.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelForm from tenancy.models import * -from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField +from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField from utilities.forms.rendering import FieldSet, ObjectAttribute __all__ = ( @@ -93,8 +93,8 @@ class ContactRoleForm(NetBoxModelForm): class ContactForm(NetBoxModelForm): - group = DynamicModelChoiceField( - label=_('Group'), + groups = DynamicModelMultipleChoiceField( + label=_('Groups'), queryset=ContactGroup.objects.all(), required=False ) @@ -102,7 +102,7 @@ class ContactForm(NetBoxModelForm): fieldsets = ( FieldSet( - 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags', + 'groups', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags', name=_('Contact') ), ) @@ -110,7 +110,7 @@ class ContactForm(NetBoxModelForm): class Meta: model = Contact fields = ( - 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags', + 'groups', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags', ) widgets = { 'address': forms.Textarea(attrs={'rows': 3}), @@ -123,7 +123,7 @@ class ContactAssignmentForm(NetBoxModelForm): queryset=ContactGroup.objects.all(), required=False, initial_params={ - 'contacts': '$contact' + 'contact': '$contact' } ) contact = DynamicModelChoiceField( diff --git a/netbox/tenancy/graphql/types.py b/netbox/tenancy/graphql/types.py index c340cdf7c..b47ac2da3 100644 --- a/netbox/tenancy/graphql/types.py +++ b/netbox/tenancy/graphql/types.py @@ -97,7 +97,7 @@ class TenantGroupType(OrganizationalObjectType): @strawberry_django.type(models.Contact, fields='__all__', filters=ContactFilter) class ContactType(ContactAssignmentsMixin, NetBoxObjectType): - group: Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')] | None + groups: List[Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')]] @strawberry_django.type(models.ContactRole, fields='__all__', filters=ContactRoleFilter) diff --git a/netbox/tenancy/migrations/0018_contact_groups.py b/netbox/tenancy/migrations/0018_contact_groups.py new file mode 100644 index 000000000..11030eb49 --- /dev/null +++ b/netbox/tenancy/migrations/0018_contact_groups.py @@ -0,0 +1,68 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def migrate_contact_groups(apps, schema_editor): + Contacts = apps.get_model('tenancy', 'Contact') + + qs = Contacts.objects.filter(group__isnull=False) + for contact in qs: + contact.groups.add(contact.group) + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0017_natural_ordering'), + ] + + operations = [ + migrations.CreateModel( + name='ContactGroupMembership', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ], + options={ + 'verbose_name': 'contact group membership', + 'verbose_name_plural': 'contact group memberships', + }, + ), + migrations.RemoveConstraint( + model_name='contact', + name='tenancy_contact_unique_group_name', + ), + migrations.AddField( + model_name='contactgroupmembership', + name='contact', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name='+', to='tenancy.contact' + ), + ), + migrations.AddField( + model_name='contactgroupmembership', + name='group', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name='+', to='tenancy.contactgroup' + ), + ), + migrations.AddField( + model_name='contact', + name='groups', + field=models.ManyToManyField( + blank=True, + related_name='contacts', + related_query_name='contact', + through='tenancy.ContactGroupMembership', + to='tenancy.contactgroup', + ), + ), + migrations.AddConstraint( + model_name='contactgroupmembership', + constraint=models.UniqueConstraint(fields=('group', 'contact'), name='unique_group_name'), + ), + migrations.RunPython(code=migrate_contact_groups, reverse_code=migrations.RunPython.noop), + migrations.RemoveField( + model_name='contact', + name='group', + ), + ] diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 3969c8317..5f39fe0db 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -13,6 +13,7 @@ __all__ = ( 'ContactAssignment', 'Contact', 'ContactGroup', + 'ContactGroupMembership', 'ContactRole', ) @@ -47,12 +48,12 @@ class Contact(PrimaryModel): """ Contact information for a particular object(s) in NetBox. """ - group = models.ForeignKey( + groups = models.ManyToManyField( to='tenancy.ContactGroup', - on_delete=models.SET_NULL, related_name='contacts', - blank=True, - null=True + through='tenancy.ContactGroupMembership', + related_query_name='contact', + blank=True ) name = models.CharField( verbose_name=_('name'), @@ -84,17 +85,11 @@ class Contact(PrimaryModel): ) clone_fields = ( - 'group', 'name', 'title', 'phone', 'email', 'address', 'link', + 'groups', 'name', 'title', 'phone', 'email', 'address', 'link', ) class Meta: ordering = ['name'] - constraints = ( - models.UniqueConstraint( - fields=('group', 'name'), - name='%(app_label)s_%(class)s_unique_group_name' - ), - ) verbose_name = _('contact') verbose_name_plural = _('contacts') @@ -102,6 +97,18 @@ class Contact(PrimaryModel): return self.name +class ContactGroupMembership(models.Model): + group = models.ForeignKey(ContactGroup, related_name="+", on_delete=models.CASCADE) + contact = models.ForeignKey(Contact, related_name="+", on_delete=models.CASCADE) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=['group', 'contact'], name='unique_group_name') + ] + verbose_name = _('contact group membership') + verbose_name_plural = _('contact group memberships') + + class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel): object_type = models.ForeignKey( to='contenttypes.ContentType', diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index c4e35ab1b..07af8382e 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -56,9 +56,9 @@ class ContactTable(NetBoxTable): verbose_name=_('Name'), linkify=True ) - group = tables.Column( - verbose_name=_('Group'), - linkify=True + groups = columns.ManyToManyColumn( + verbose_name=_('Groups'), + linkify_item=('tenancy:contactgroup', {'pk': tables.A('pk')}) ) phone = tables.Column( verbose_name=_('Phone'), @@ -79,10 +79,10 @@ class ContactTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Contact fields = ( - 'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', + 'pk', 'name', 'groups', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'assignment_count', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email') + default_columns = ('pk', 'name', 'groups', 'assignment_count', 'title', 'phone', 'email') class ContactAssignmentTable(NetBoxTable): diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index c32ad3826..3bacb8fea 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -170,7 +170,7 @@ class ContactTest(APIViewTestCases.APIViewTestCase): model = Contact brief_fields = ['description', 'display', 'id', 'name', 'url'] bulk_update_data = { - 'group': None, + 'groups': [], 'comments': 'New comments', } @@ -183,20 +183,22 @@ class ContactTest(APIViewTestCases.APIViewTestCase): ) contacts = ( - Contact(name='Contact 1', group=contact_groups[0]), - Contact(name='Contact 2', group=contact_groups[0]), - Contact(name='Contact 3', group=contact_groups[0]), + Contact(name='Contact 1'), + Contact(name='Contact 2'), + Contact(name='Contact 3'), ) Contact.objects.bulk_create(contacts) + contacts[0].groups.add(contact_groups[0]) + contacts[1].groups.add(contact_groups[0]) + contacts[2].groups.add(contact_groups[0]) cls.create_data = [ { 'name': 'Contact 4', - 'group': contact_groups[1].pk, + 'groups': [contact_groups[1].pk], }, { 'name': 'Contact 5', - 'group': contact_groups[1].pk, }, { 'name': 'Contact 6', diff --git a/netbox/tenancy/tests/test_filtersets.py b/netbox/tenancy/tests/test_filtersets.py index f6890a3d4..d44d78ec4 100644 --- a/netbox/tenancy/tests/test_filtersets.py +++ b/netbox/tenancy/tests/test_filtersets.py @@ -241,6 +241,7 @@ class ContactRoleTestCase(TestCase, ChangeLoggedFilterSetTests): class ContactTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Contact.objects.all() filterset = ContactFilterSet + ignore_fields = ('groups',) @classmethod def setUpTestData(cls): @@ -254,11 +255,14 @@ class ContactTestCase(TestCase, ChangeLoggedFilterSetTests): contactgroup.save() contacts = ( - Contact(name='Contact 1', group=contact_groups[0], description='foobar1'), - Contact(name='Contact 2', group=contact_groups[1], description='foobar2'), - Contact(name='Contact 3', group=contact_groups[2], description='foobar3'), + Contact(name='Contact 1', description='foobar1'), + Contact(name='Contact 2', description='foobar2'), + Contact(name='Contact 3', description='foobar3'), ) Contact.objects.bulk_create(contacts) + contacts[0].groups.add(contact_groups[0]) + contacts[1].groups.add(contact_groups[1]) + contacts[2].groups.add(contact_groups[2]) def test_q(self): params = {'q': 'foobar1'} @@ -311,11 +315,14 @@ class ContactAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests): ContactRole.objects.bulk_create(contact_roles) contacts = ( - Contact(name='Contact 1', group=contact_groups[0]), - Contact(name='Contact 2', group=contact_groups[1]), - Contact(name='Contact 3', group=contact_groups[2]), + Contact(name='Contact 1'), + Contact(name='Contact 2'), + Contact(name='Contact 3'), ) Contact.objects.bulk_create(contacts) + contacts[0].groups.add(contact_groups[0]) + contacts[1].groups.add(contact_groups[1]) + contacts[2].groups.add(contact_groups[2]) assignments = ( ContactAssignment(object=sites[0], contact=contacts[0], role=contact_roles[0]), diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index cbdecc0d0..ec962e6e6 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -196,37 +196,40 @@ class ContactTestCase(ViewTestCases.PrimaryObjectViewTestCase): contactgroup.save() contacts = ( - Contact(name='Contact 1', group=contact_groups[0]), - Contact(name='Contact 2', group=contact_groups[0]), - Contact(name='Contact 3', group=contact_groups[0]), + Contact(name='Contact 1'), + Contact(name='Contact 2'), + Contact(name='Contact 3'), ) Contact.objects.bulk_create(contacts) + contacts[0].groups.add(contact_groups[0]) + contacts[1].groups.add(contact_groups[1]) tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'name': 'Contact X', - 'group': contact_groups[1].pk, + 'groups': [contact_groups[1].pk], 'comments': 'Some comments', 'tags': [t.pk for t in tags], } cls.csv_data = ( - "group,name", - "Contact Group 1,Contact 4", - "Contact Group 1,Contact 5", - "Contact Group 1,Contact 6", + "name", + "groups", + "Contact 4", + "Contact 5", + "Contact 6", ) cls.csv_update_data = ( - "id,name,comments", - f"{contacts[0].pk},Contact Group 7,New comments 7", - f"{contacts[1].pk},Contact Group 8,New comments 8", - f"{contacts[2].pk},Contact Group 9,New comments 9", + "id,name,groups,comments", + f'{contacts[0].pk},Contact 7,"Contact Group 1,Contact Group 2",New comments 7', + f'{contacts[1].pk},Contact 8,"Contact Group 1",New comments 8', + f'{contacts[2].pk},Contact 9,"Contact Group 1",New comments 9', ) cls.bulk_edit_data = { - 'group': contact_groups[1].pk, + 'description': "New description", } diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 3b5029bd7..d0c80b76f 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -170,7 +170,7 @@ class ContactGroupListView(generic.ObjectListView): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, - 'group', + 'groups', 'contact_count', cumulative=True ) @@ -214,7 +214,7 @@ class ContactGroupBulkEditView(generic.BulkEditView): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, - 'group', + 'groups', 'contact_count', cumulative=True ) @@ -228,7 +228,7 @@ class ContactGroupBulkDeleteView(generic.BulkDeleteView): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, - 'group', + 'groups', 'contact_count', cumulative=True ) @@ -337,6 +337,15 @@ class ContactBulkEditView(generic.BulkEditView): table = tables.ContactTable form = forms.ContactBulkEditForm + def post_save_operations(self, form, obj): + super().post_save_operations(form, obj) + + # Add/remove groups + if form.cleaned_data.get('add_groups', None): + obj.groups.add(*form.cleaned_data['add_groups']) + if form.cleaned_data.get('remove_groups', None): + obj.groups.remove(*form.cleaned_data['remove_groups']) + @register_model_view(Contact, 'bulk_delete', path='delete', detail=False) class ContactBulkDeleteView(generic.BulkDeleteView): diff --git a/netbox/utilities/testing/filtersets.py b/netbox/utilities/testing/filtersets.py index e58123f03..0b3d4b198 100644 --- a/netbox/utilities/testing/filtersets.py +++ b/netbox/utilities/testing/filtersets.py @@ -144,8 +144,8 @@ class BaseFilterSetTests: # Check that the filter class is correct filter = filters[filter_name] if filter_class is not None: - self.assertIs( - type(filter), + self.assertIsInstance( + filter, filter_class, f"Invalid filter class {type(filter)} for {filter_name} (should be {filter_class})!" ) From 958dcca8d6fb0bc7ca241f8f39b9adf7fef2e4dd Mon Sep 17 00:00:00 2001 From: bctiemann Date: Wed, 19 Mar 2025 10:38:10 -0400 Subject: [PATCH 053/103] Fix migration conflict in tenancy (#18957) --- ...ts.py => 0019_contactgroup_comments_tenantgroup_comments.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename netbox/tenancy/migrations/{0018_contactgroup_comments_tenantgroup_comments.py => 0019_contactgroup_comments_tenantgroup_comments.py} (90%) diff --git a/netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py b/netbox/tenancy/migrations/0019_contactgroup_comments_tenantgroup_comments.py similarity index 90% rename from netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py rename to netbox/tenancy/migrations/0019_contactgroup_comments_tenantgroup_comments.py index 5f6a95149..eee2dc351 100644 --- a/netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py +++ b/netbox/tenancy/migrations/0019_contactgroup_comments_tenantgroup_comments.py @@ -4,7 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('tenancy', '0017_natural_ordering'), + ('tenancy', '0018_contact_groups'), ] operations = [ From d25605c261a75e4da6c07f0b9f5e5ebba84e3f71 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 19 Mar 2025 10:40:54 -0400 Subject: [PATCH 054/103] Closes #18751: Set the default value of `ALLOW_TOKEN_RETRIEVAL` to False (#18943) * Closes #18751: Set the default value of ALLOW_TOKEN_RETRIEVAL to False * Enable token retrieval during testing --- docs/configuration/security.md | 5 ++++- netbox/netbox/configuration_testing.py | 2 ++ netbox/netbox/settings.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/configuration/security.md b/docs/configuration/security.md index 172034b4f..950d2df34 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -2,7 +2,10 @@ ## ALLOW_TOKEN_RETRIEVAL -Default: True +Default: False + +!!! note + The default value of this parameter changed from true to false in NetBox v4.3.0. If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token prior to its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions. diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index 142b50bb0..52973e94d 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -43,6 +43,8 @@ SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' DEFAULT_PERMISSIONS = {} +ALLOW_TOKEN_RETRIEVAL = True + LOGGING = { 'version': 1, 'disable_existing_loggers': True diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3b47ab541..9f9b25689 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -64,7 +64,7 @@ elif hasattr(configuration, 'DATABASE') and hasattr(configuration, 'DATABASES'): # Set static config parameters ADMINS = getattr(configuration, 'ADMINS', []) -ALLOW_TOKEN_RETRIEVAL = getattr(configuration, 'ALLOW_TOKEN_RETRIEVAL', True) +ALLOW_TOKEN_RETRIEVAL = getattr(configuration, 'ALLOW_TOKEN_RETRIEVAL', False) ALLOWED_HOSTS = getattr(configuration, 'ALLOWED_HOSTS') # Required AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', [ { From 6b7d23d6847dd9bd1bbc055249b0918a3f64b03d Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Wed, 19 Mar 2025 12:17:35 -0500 Subject: [PATCH 055/103] Closes #17841 Allows Tags to be displayed in specified order (#18930) --- docs/models/extras/tag.md | 6 ++++ netbox/extras/api/serializers_/tags.py | 4 +-- netbox/extras/filtersets.py | 2 +- netbox/extras/forms/bulk_edit.py | 4 +++ netbox/extras/forms/bulk_import.py | 6 +++- netbox/extras/forms/model_forms.py | 8 +++-- .../0124_alter_tag_options_tag_weight.py | 22 ++++++++++++ netbox/extras/models/tags.py | 6 +++- netbox/extras/tables/tables.py | 4 +-- netbox/extras/tests/test_api.py | 3 +- netbox/extras/tests/test_filtersets.py | 9 ++++- netbox/extras/tests/test_models.py | 34 +++++++++++++++++++ netbox/extras/tests/test_views.py | 13 +++---- netbox/netbox/models/features.py | 3 +- netbox/templates/extras/tag.html | 4 +++ 15 files changed, 110 insertions(+), 18 deletions(-) create mode 100644 netbox/extras/migrations/0124_alter_tag_options_tag_weight.py diff --git a/docs/models/extras/tag.md b/docs/models/extras/tag.md index 39de48261..c4bc91b5a 100644 --- a/docs/models/extras/tag.md +++ b/docs/models/extras/tag.md @@ -16,6 +16,12 @@ A unique URL-friendly identifier. (This value will be used for filtering.) This The color to use when displaying the tag in the NetBox UI. +### Weight + +A numeric weight employed to influence the ordering of tags. Tags with a lower weight will be listed before those with higher weights. Values must be within the range **0** to **32767**. + +!!! info "This field was introduced in NetBox v4.3." + ### Object Types The assignment of a tag may be limited to a prescribed set of objects. For example, it may be desirable to limit the application of a specific tag to only devices and virtual machines. diff --git a/netbox/extras/api/serializers_/tags.py b/netbox/extras/api/serializers_/tags.py index ea964ff52..5dc39584f 100644 --- a/netbox/extras/api/serializers_/tags.py +++ b/netbox/extras/api/serializers_/tags.py @@ -27,8 +27,8 @@ class TagSerializer(ValidatedModelSerializer): class Meta: model = Tag fields = [ - 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'object_types', - 'tagged_items', 'created', 'last_updated', + 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'weight', + 'object_types', 'tagged_items', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description') diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 98302d0f4..635102be2 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -450,7 +450,7 @@ class TagFilterSet(ChangeLoggedModelFilterSet): class Meta: model = Tag - fields = ('id', 'name', 'slug', 'color', 'description', 'object_types') + fields = ('id', 'name', 'slug', 'color', 'weight', 'description', 'object_types') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 30d06683b..7adc303f5 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -275,6 +275,10 @@ class TagBulkEditForm(BulkEditForm): max_length=200, required=False ) + weight = forms.IntegerField( + label=_('Weight'), + required=False + ) nullable_fields = ('description',) diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 655a5d6ca..b680382f6 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -232,10 +232,14 @@ class EventRuleImportForm(NetBoxModelImportForm): class TagImportForm(CSVModelForm): slug = SlugField() + weight = forms.IntegerField( + label=_('Weight'), + required=False + ) class Meta: model = Tag - fields = ('name', 'slug', 'color', 'description') + fields = ('name', 'slug', 'color', 'weight', 'description') class JournalEntryImportForm(NetBoxModelImportForm): diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index a45daaf70..5591de754 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -490,15 +490,19 @@ class TagForm(forms.ModelForm): queryset=ObjectType.objects.with_feature('tags'), required=False ) + weight = forms.IntegerField( + label=_('Weight'), + required=False + ) fieldsets = ( - FieldSet('name', 'slug', 'color', 'description', 'object_types', name=_('Tag')), + FieldSet('name', 'slug', 'color', 'weight', 'description', 'object_types', name=_('Tag')), ) class Meta: model = Tag fields = [ - 'name', 'slug', 'color', 'description', 'object_types', + 'name', 'slug', 'color', 'weight', 'description', 'object_types', ] diff --git a/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py b/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py new file mode 100644 index 000000000..86fc71fd5 --- /dev/null +++ b/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2b1 on 2025-03-17 14:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0123_remove_staging'), + ] + + operations = [ + migrations.AlterModelOptions( + name='tag', + options={'ordering': ('weight', 'name')}, + ), + migrations.AddField( + model_name='tag', + name='weight', + field=models.PositiveSmallIntegerField(default=0), + ), + ] diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index baf72baa1..4c6396172 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -40,13 +40,17 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase): blank=True, help_text=_("The object type(s) to which this tag can be applied.") ) + weight = models.PositiveSmallIntegerField( + verbose_name=_('weight'), + default=0, + ) clone_fields = ( 'color', 'description', 'object_types', ) class Meta: - ordering = ['name'] + ordering = ('weight', 'name') verbose_name = _('tag') verbose_name_plural = _('tags') diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index e538c488e..a14356c1c 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -449,8 +449,8 @@ class TagTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Tag fields = ( - 'pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'object_types', 'created', 'last_updated', - 'actions', + 'pk', 'id', 'name', 'items', 'slug', 'color', 'weight', 'description', 'object_types', + 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description') diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 17f03350d..1d6dfac6d 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -513,6 +513,7 @@ class TagTest(APIViewTestCases.APIViewTestCase): { 'name': 'Tag 4', 'slug': 'tag-4', + 'weight': 1000, }, { 'name': 'Tag 5', @@ -533,7 +534,7 @@ class TagTest(APIViewTestCases.APIViewTestCase): tags = ( Tag(name='Tag 1', slug='tag-1'), Tag(name='Tag 2', slug='tag-2'), - Tag(name='Tag 3', slug='tag-3'), + Tag(name='Tag 3', slug='tag-3', weight=26), ) Tag.objects.bulk_create(tags) diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 9684b3dbe..c6c53bfcb 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -1196,7 +1196,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): tags = ( Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'), Tag(name='Tag 2', slug='tag-2', color='00ff00', description='foobar2'), - Tag(name='Tag 3', slug='tag-3', color='0000ff'), + Tag(name='Tag 3', slug='tag-3', color='0000ff', weight=1000), ) Tag.objects.bulk_create(tags) tags[0].object_types.add(object_types['site']) @@ -1249,6 +1249,13 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): ['Tag 2', 'Tag 3'] ) + def test_weight(self): + params = {'weight': [1000]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'weight': [0]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + class TaggedItemFilterSetTestCase(TestCase): queryset = TaggedItem.objects.all() diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index c90390dd1..bf05a8c18 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -10,6 +10,40 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMac class TagTest(TestCase): + def test_default_ordering_weight_then_name_is_set(self): + Tag.objects.create(name='Tag 1', slug='tag-1', weight=100) + Tag.objects.create(name='Tag 2', slug='tag-2') + Tag.objects.create(name='Tag 3', slug='tag-3', weight=10) + Tag.objects.create(name='Tag 4', slug='tag-4', weight=10) + + tags = Tag.objects.all() + + self.assertEqual(tags[0].slug, 'tag-2') + self.assertEqual(tags[1].slug, 'tag-3') + self.assertEqual(tags[2].slug, 'tag-4') + self.assertEqual(tags[3].slug, 'tag-1') + + def test_tag_related_manager_ordering_weight_then_name(self): + tags = [ + Tag.objects.create(name='Tag 1', slug='tag-1', weight=100), + Tag.objects.create(name='Tag 2', slug='tag-2'), + Tag.objects.create(name='Tag 3', slug='tag-3', weight=10), + Tag.objects.create(name='Tag 4', slug='tag-4', weight=10), + ] + + site = Site.objects.create(name='Site 1') + for tag in tags: + site.tags.add(tag) + site.save() + + site = Site.objects.first() + tags = site.tags.all() + + self.assertEqual(tags[0].slug, 'tag-2') + self.assertEqual(tags[1].slug, 'tag-3') + self.assertEqual(tags[2].slug, 'tag-4') + self.assertEqual(tags[3].slug, 'tag-1') + def test_create_tag_unicode(self): tag = Tag(name='Testing Unicode: 台灣') tag.save() diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 5d82fae4c..be8d80b2b 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -441,8 +441,8 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase): tags = ( Tag(name='Tag 1', slug='tag-1'), - Tag(name='Tag 2', slug='tag-2'), - Tag(name='Tag 3', slug='tag-3'), + Tag(name='Tag 2', slug='tag-2', weight=1), + Tag(name='Tag 3', slug='tag-3', weight=32767), ) Tag.objects.bulk_create(tags) @@ -451,13 +451,14 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'slug': 'tag-x', 'color': 'c0c0c0', 'comments': 'Some comments', + 'weight': 11, } cls.csv_data = ( - "name,slug,color,description", - "Tag 4,tag-4,ff0000,Fourth tag", - "Tag 5,tag-5,00ff00,Fifth tag", - "Tag 6,tag-6,0000ff,Sixth tag", + "name,slug,color,description,weight", + "Tag 4,tag-4,ff0000,Fourth tag,0", + "Tag 5,tag-5,00ff00,Fifth tag,1111", + "Tag 6,tag-6,0000ff,Sixth tag,0", ) cls.csv_update_data = ( diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index e58037b85..a2fb8d615 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -455,7 +455,8 @@ class TagsMixin(models.Model): which is a `TaggableManager` instance. """ tags = TaggableManager( - through='extras.TaggedItem' + through='extras.TaggedItem', + ordering=('weight', 'name'), ) class Meta: diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index 4e1379fed..8c5eb13cd 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -28,6 +28,10 @@   + + + + + + + + From fe7cc8cae9b1729169c20014922b38873cbb3722 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 20 Mar 2025 13:00:14 -0700 Subject: [PATCH 057/103] Closes #16224 GraphQL Pagination (#18903) * 16244 add pagination * 16244 add pagination * 16244 fix order_by pagination * 16224 document pagination * 16224 remove extraneous code * 16224 missing core types * 16224 review changes * 16224 review changes * 16224 review changes --- docs/integrations/graphql-api.md | 12 +++ netbox/circuits/graphql/types.py | 33 ++++--- netbox/core/graphql/types.py | 15 ++- netbox/dcim/graphql/types.py | 129 ++++++++++++++++--------- netbox/extras/graphql/types.py | 41 +++++--- netbox/ipam/graphql/types.py | 54 +++++++---- netbox/netbox/graphql/schema.py | 2 +- netbox/netbox/graphql/types.py | 2 + netbox/netbox/settings.py | 1 + netbox/tenancy/graphql/types.py | 42 ++++++-- netbox/users/graphql/types.py | 6 +- netbox/virtualization/graphql/types.py | 18 ++-- netbox/vpn/graphql/types.py | 30 ++++-- netbox/wireless/graphql/types.py | 9 +- 14 files changed, 277 insertions(+), 117 deletions(-) diff --git a/docs/integrations/graphql-api.md b/docs/integrations/graphql-api.md index 23824ad2b..cae046b6c 100644 --- a/docs/integrations/graphql-api.md +++ b/docs/integrations/graphql-api.md @@ -131,6 +131,18 @@ Certain queries can return multiple types of objects, for example cable terminat ``` The field "class_type" is an easy way to distinguish what type of object it is when viewing the returned data, or when filtering. It contains the class name, for example "CircuitTermination" or "ConsoleServerPort". +## Pagination + +Queries can be paginated by specifying pagination in the query and supplying an offset and optionaly a limit in the query. If no limit is given, a default of 100 is used. Queries are not paginated unless requested in the query. An example paginated query is shown below: + +``` +query { + device_list(pagination: { offset: 0, limit: 20 }) { + id + } +} +``` + ## Authentication NetBox's GraphQL API uses the same API authentication tokens as its REST API. Authentication tokens are included with requests by attaching an `Authorization` HTTP header in the following form: diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index 860c19852..cdd02c891 100644 --- a/netbox/circuits/graphql/types.py +++ b/netbox/circuits/graphql/types.py @@ -32,7 +32,8 @@ __all__ = ( @strawberry_django.type( models.Provider, fields='__all__', - filters=ProviderFilter + filters=ProviderFilter, + pagination=True ) class ProviderType(NetBoxObjectType, ContactsMixin): @@ -45,7 +46,8 @@ class ProviderType(NetBoxObjectType, ContactsMixin): @strawberry_django.type( models.ProviderAccount, fields='__all__', - filters=ProviderAccountFilter + filters=ProviderAccountFilter, + pagination=True ) class ProviderAccountType(NetBoxObjectType): provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')] @@ -56,7 +58,8 @@ class ProviderAccountType(NetBoxObjectType): @strawberry_django.type( models.ProviderNetwork, fields='__all__', - filters=ProviderNetworkFilter + filters=ProviderNetworkFilter, + pagination=True ) class ProviderNetworkType(NetBoxObjectType): provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')] @@ -67,7 +70,8 @@ class ProviderNetworkType(NetBoxObjectType): @strawberry_django.type( models.CircuitTermination, exclude=['termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'], - filters=CircuitTerminationFilter + filters=CircuitTerminationFilter, + pagination=True ) class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType): circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')] @@ -86,7 +90,8 @@ class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, Ob @strawberry_django.type( models.CircuitType, fields='__all__', - filters=CircuitTypeFilter + filters=CircuitTypeFilter, + pagination=True ) class CircuitTypeType(OrganizationalObjectType): color: str @@ -97,7 +102,8 @@ class CircuitTypeType(OrganizationalObjectType): @strawberry_django.type( models.Circuit, fields='__all__', - filters=CircuitFilter + filters=CircuitFilter, + pagination=True ) class CircuitType(NetBoxObjectType, ContactsMixin): provider: ProviderType @@ -113,7 +119,8 @@ class CircuitType(NetBoxObjectType, ContactsMixin): @strawberry_django.type( models.CircuitGroup, fields='__all__', - filters=CircuitGroupFilter + filters=CircuitGroupFilter, + pagination=True ) class CircuitGroupType(OrganizationalObjectType): tenant: TenantType | None @@ -122,7 +129,8 @@ class CircuitGroupType(OrganizationalObjectType): @strawberry_django.type( models.CircuitGroupAssignment, exclude=['member_type', 'member_id'], - filters=CircuitGroupAssignmentFilter + filters=CircuitGroupAssignmentFilter, + pagination=True ) class CircuitGroupAssignmentType(TagsMixin, BaseObjectType): group: Annotated["CircuitGroupType", strawberry.lazy('circuits.graphql.types')] @@ -138,7 +146,8 @@ class CircuitGroupAssignmentType(TagsMixin, BaseObjectType): @strawberry_django.type( models.VirtualCircuitType, fields='__all__', - filters=VirtualCircuitTypeFilter + filters=VirtualCircuitTypeFilter, + pagination=True ) class VirtualCircuitTypeType(OrganizationalObjectType): color: str @@ -149,7 +158,8 @@ class VirtualCircuitTypeType(OrganizationalObjectType): @strawberry_django.type( models.VirtualCircuitTermination, fields='__all__', - filters=VirtualCircuitTerminationFilter + filters=VirtualCircuitTerminationFilter, + pagination=True ) class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType): virtual_circuit: Annotated[ @@ -165,7 +175,8 @@ class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType): @strawberry_django.type( models.VirtualCircuit, fields='__all__', - filters=VirtualCircuitFilter + filters=VirtualCircuitFilter, + pagination=True ) class VirtualCircuitType(NetBoxObjectType): provider_network: ProviderNetworkType = strawberry_django.field(select_related=["provider_network"]) diff --git a/netbox/core/graphql/types.py b/netbox/core/graphql/types.py index 1c57a2dbc..ffaa24411 100644 --- a/netbox/core/graphql/types.py +++ b/netbox/core/graphql/types.py @@ -19,7 +19,8 @@ __all__ = ( @strawberry_django.type( models.DataFile, exclude=['data',], - filters=DataFileFilter + filters=DataFileFilter, + pagination=True ) class DataFileType(BaseObjectType): source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] @@ -28,7 +29,8 @@ class DataFileType(BaseObjectType): @strawberry_django.type( models.DataSource, fields='__all__', - filters=DataSourceFilter + filters=DataSourceFilter, + pagination=True ) class DataSourceType(NetBoxObjectType): @@ -38,12 +40,17 @@ class DataSourceType(NetBoxObjectType): @strawberry_django.type( models.ObjectChange, fields='__all__', - filters=ObjectChangeFilter + filters=ObjectChangeFilter, + pagination=True ) class ObjectChangeType(BaseObjectType): pass -@strawberry_django.type(DjangoContentType, fields='__all__') +@strawberry_django.type( + DjangoContentType, + fields='__all__', + pagination=True +) class ContentType: pass diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 6cd072479..9554a9f60 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -133,7 +133,8 @@ class ModularComponentTemplateType(ComponentTemplateType): @strawberry_django.type( models.CableTermination, exclude=['termination_type', 'termination_id', '_device', '_rack', '_location', '_site'], - filters=CableTerminationFilter + filters=CableTerminationFilter, + pagination=True ) class CableTerminationType(NetBoxObjectType): cable: Annotated["CableType", strawberry.lazy('dcim.graphql.types')] | None @@ -153,7 +154,8 @@ class CableTerminationType(NetBoxObjectType): @strawberry_django.type( models.Cable, fields='__all__', - filters=CableFilter + filters=CableFilter, + pagination=True ) class CableType(NetBoxObjectType): color: str @@ -189,7 +191,8 @@ class CableType(NetBoxObjectType): @strawberry_django.type( models.ConsolePort, exclude=['_path'], - filters=ConsolePortFilter + filters=ConsolePortFilter, + pagination=True ) class ConsolePortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): pass @@ -198,7 +201,8 @@ class ConsolePortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin @strawberry_django.type( models.ConsolePortTemplate, fields='__all__', - filters=ConsolePortTemplateFilter + filters=ConsolePortTemplateFilter, + pagination=True ) class ConsolePortTemplateType(ModularComponentTemplateType): pass @@ -207,7 +211,8 @@ class ConsolePortTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.ConsoleServerPort, exclude=['_path'], - filters=ConsoleServerPortFilter + filters=ConsoleServerPortFilter, + pagination=True ) class ConsoleServerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): pass @@ -216,7 +221,8 @@ class ConsoleServerPortType(ModularComponentType, CabledObjectMixin, PathEndpoin @strawberry_django.type( models.ConsoleServerPortTemplate, fields='__all__', - filters=ConsoleServerPortTemplateFilter + filters=ConsoleServerPortTemplateFilter, + pagination=True ) class ConsoleServerPortTemplateType(ModularComponentTemplateType): pass @@ -225,7 +231,8 @@ class ConsoleServerPortTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.Device, fields='__all__', - filters=DeviceFilter + filters=DeviceFilter, + pagination=True ) class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): console_port_count: BigInt @@ -280,7 +287,8 @@ class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBo @strawberry_django.type( models.DeviceBay, fields='__all__', - filters=DeviceBayFilter + filters=DeviceBayFilter, + pagination=True ) class DeviceBayType(ComponentType): installed_device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None @@ -289,7 +297,8 @@ class DeviceBayType(ComponentType): @strawberry_django.type( models.DeviceBayTemplate, fields='__all__', - filters=DeviceBayTemplateFilter + filters=DeviceBayTemplateFilter, + pagination=True ) class DeviceBayTemplateType(ComponentTemplateType): pass @@ -298,7 +307,8 @@ class DeviceBayTemplateType(ComponentTemplateType): @strawberry_django.type( models.InventoryItemTemplate, exclude=['component_type', 'component_id', 'parent'], - filters=InventoryItemTemplateFilter + filters=InventoryItemTemplateFilter, + pagination=True ) class InventoryItemTemplateType(ComponentTemplateType): role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None @@ -324,7 +334,8 @@ class InventoryItemTemplateType(ComponentTemplateType): @strawberry_django.type( models.DeviceRole, fields='__all__', - filters=DeviceRoleFilter + filters=DeviceRoleFilter, + pagination=True ) class DeviceRoleType(OrganizationalObjectType): color: str @@ -337,7 +348,8 @@ class DeviceRoleType(OrganizationalObjectType): @strawberry_django.type( models.DeviceType, fields='__all__', - filters=DeviceTypeFilter + filters=DeviceTypeFilter, + pagination=True ) class DeviceTypeType(NetBoxObjectType): console_port_template_count: BigInt @@ -371,7 +383,8 @@ class DeviceTypeType(NetBoxObjectType): @strawberry_django.type( models.FrontPort, fields='__all__', - filters=FrontPortFilter + filters=FrontPortFilter, + pagination=True ) class FrontPortType(ModularComponentType, CabledObjectMixin): color: str @@ -381,7 +394,8 @@ class FrontPortType(ModularComponentType, CabledObjectMixin): @strawberry_django.type( models.FrontPortTemplate, fields='__all__', - filters=FrontPortTemplateFilter + filters=FrontPortTemplateFilter, + pagination=True ) class FrontPortTemplateType(ModularComponentTemplateType): color: str @@ -391,7 +405,8 @@ class FrontPortTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.MACAddress, exclude=['assigned_object_type', 'assigned_object_id'], - filters=MACAddressFilter + filters=MACAddressFilter, + pagination=True ) class MACAddressType(NetBoxObjectType): mac_address: str @@ -407,7 +422,8 @@ class MACAddressType(NetBoxObjectType): @strawberry_django.type( models.Interface, exclude=['_path'], - filters=InterfaceFilter + filters=InterfaceFilter, + pagination=True ) class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, PathEndpointMixin): _name: str @@ -434,7 +450,8 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P @strawberry_django.type( models.InterfaceTemplate, fields='__all__', - filters=InterfaceTemplateFilter + filters=InterfaceTemplateFilter, + pagination=True ) class InterfaceTemplateType(ModularComponentTemplateType): _name: str @@ -446,7 +463,8 @@ class InterfaceTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.InventoryItem, exclude=['component_type', 'component_id', 'parent'], - filters=InventoryItemFilter + filters=InventoryItemFilter, + pagination=True ) class InventoryItemType(ComponentType): role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None @@ -472,7 +490,8 @@ class InventoryItemType(ComponentType): @strawberry_django.type( models.InventoryItemRole, fields='__all__', - filters=InventoryItemRoleFilter + filters=InventoryItemRoleFilter, + pagination=True ) class InventoryItemRoleType(OrganizationalObjectType): color: str @@ -485,7 +504,8 @@ class InventoryItemRoleType(OrganizationalObjectType): models.Location, # fields='__all__', exclude=['parent'], # bug - temp - filters=LocationFilter + filters=LocationFilter, + pagination=True ) class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, OrganizationalObjectType): site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] @@ -512,7 +532,8 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Organi @strawberry_django.type( models.Manufacturer, fields='__all__', - filters=ManufacturerFilter + filters=ManufacturerFilter, + pagination=True ) class ManufacturerType(OrganizationalObjectType, ContactsMixin): @@ -526,7 +547,8 @@ class ManufacturerType(OrganizationalObjectType, ContactsMixin): @strawberry_django.type( models.Module, fields='__all__', - filters=ModuleFilter + filters=ModuleFilter, + pagination=True ) class ModuleType(NetBoxObjectType): device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] @@ -546,7 +568,8 @@ class ModuleType(NetBoxObjectType): models.ModuleBay, # fields='__all__', exclude=['parent'], - filters=ModuleBayFilter + filters=ModuleBayFilter, + pagination=True ) class ModuleBayType(ModularComponentType): @@ -561,7 +584,8 @@ class ModuleBayType(ModularComponentType): @strawberry_django.type( models.ModuleBayTemplate, fields='__all__', - filters=ModuleBayTemplateFilter + filters=ModuleBayTemplateFilter, + pagination=True ) class ModuleBayTemplateType(ModularComponentTemplateType): pass @@ -570,7 +594,8 @@ class ModuleBayTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.ModuleType, fields='__all__', - filters=ModuleTypeFilter + filters=ModuleTypeFilter, + pagination=True ) class ModuleTypeType(NetBoxObjectType): manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] @@ -588,7 +613,8 @@ class ModuleTypeType(NetBoxObjectType): @strawberry_django.type( models.Platform, fields='__all__', - filters=PlatformFilter + filters=PlatformFilter, + pagination=True ) class PlatformType(OrganizationalObjectType): manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] | None @@ -601,7 +627,8 @@ class PlatformType(OrganizationalObjectType): @strawberry_django.type( models.PowerFeed, exclude=['_path'], - filters=PowerFeedFilter + filters=PowerFeedFilter, + pagination=True ) class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin): power_panel: Annotated["PowerPanelType", strawberry.lazy('dcim.graphql.types')] @@ -612,7 +639,8 @@ class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin): @strawberry_django.type( models.PowerOutlet, exclude=['_path'], - filters=PowerOutletFilter + filters=PowerOutletFilter, + pagination=True ) class PowerOutletType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): power_port: Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')] | None @@ -622,7 +650,8 @@ class PowerOutletType(ModularComponentType, CabledObjectMixin, PathEndpointMixin @strawberry_django.type( models.PowerOutletTemplate, fields='__all__', - filters=PowerOutletTemplateFilter + filters=PowerOutletTemplateFilter, + pagination=True ) class PowerOutletTemplateType(ModularComponentTemplateType): power_port: Annotated["PowerPortTemplateType", strawberry.lazy('dcim.graphql.types')] | None @@ -631,7 +660,8 @@ class PowerOutletTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.PowerPanel, fields='__all__', - filters=PowerPanelFilter + filters=PowerPanelFilter, + pagination=True ) class PowerPanelType(NetBoxObjectType, ContactsMixin): site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] @@ -643,7 +673,8 @@ class PowerPanelType(NetBoxObjectType, ContactsMixin): @strawberry_django.type( models.PowerPort, exclude=['_path'], - filters=PowerPortFilter + filters=PowerPortFilter, + pagination=True ) class PowerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): @@ -653,7 +684,8 @@ class PowerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): @strawberry_django.type( models.PowerPortTemplate, fields='__all__', - filters=PowerPortTemplateFilter + filters=PowerPortTemplateFilter, + pagination=True ) class PowerPortTemplateType(ModularComponentTemplateType): poweroutlet_templates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]] @@ -662,7 +694,8 @@ class PowerPortTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.RackType, fields='__all__', - filters=RackTypeFilter + filters=RackTypeFilter, + pagination=True ) class RackTypeType(NetBoxObjectType): manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] @@ -671,7 +704,8 @@ class RackTypeType(NetBoxObjectType): @strawberry_django.type( models.Rack, fields='__all__', - filters=RackFilter + filters=RackFilter, + pagination=True ) class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] @@ -689,7 +723,8 @@ class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje @strawberry_django.type( models.RackReservation, fields='__all__', - filters=RackReservationFilter + filters=RackReservationFilter, + pagination=True ) class RackReservationType(NetBoxObjectType): units: List[int] @@ -701,7 +736,8 @@ class RackReservationType(NetBoxObjectType): @strawberry_django.type( models.RackRole, fields='__all__', - filters=RackRoleFilter + filters=RackRoleFilter, + pagination=True ) class RackRoleType(OrganizationalObjectType): color: str @@ -712,7 +748,8 @@ class RackRoleType(OrganizationalObjectType): @strawberry_django.type( models.RearPort, fields='__all__', - filters=RearPortFilter + filters=RearPortFilter, + pagination=True ) class RearPortType(ModularComponentType, CabledObjectMixin): color: str @@ -723,7 +760,8 @@ class RearPortType(ModularComponentType, CabledObjectMixin): @strawberry_django.type( models.RearPortTemplate, fields='__all__', - filters=RearPortTemplateFilter + filters=RearPortTemplateFilter, + pagination=True ) class RearPortTemplateType(ModularComponentTemplateType): color: str @@ -734,7 +772,8 @@ class RearPortTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.Region, exclude=['parent'], - filters=RegionFilter + filters=RegionFilter, + pagination=True ) class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): @@ -759,7 +798,8 @@ class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): @strawberry_django.type( models.Site, fields='__all__', - filters=SiteFilter + filters=SiteFilter, + pagination=True ) class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): time_zone: str | None @@ -793,7 +833,8 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje @strawberry_django.type( models.SiteGroup, exclude=['parent'], # bug - temp - filters=SiteGroupFilter + filters=SiteGroupFilter, + pagination=True ) class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): @@ -818,7 +859,8 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): @strawberry_django.type( models.VirtualChassis, fields='__all__', - filters=VirtualChassisFilter + filters=VirtualChassisFilter, + pagination=True ) class VirtualChassisType(NetBoxObjectType): member_count: BigInt @@ -830,7 +872,8 @@ class VirtualChassisType(NetBoxObjectType): @strawberry_django.type( models.VirtualDeviceContext, fields='__all__', - filters=VirtualDeviceContextFilter + filters=VirtualDeviceContextFilter, + pagination=True ) class VirtualDeviceContextType(NetBoxObjectType): device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index 1ceb2682c..f4a1a397f 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -46,7 +46,8 @@ __all__ = ( @strawberry_django.type( models.ConfigContext, fields='__all__', - filters=ConfigContextFilter + filters=ConfigContextFilter, + pagination=True ) class ConfigContextType(ObjectType): data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None @@ -69,7 +70,8 @@ class ConfigContextType(ObjectType): @strawberry_django.type( models.ConfigTemplate, fields='__all__', - filters=ConfigTemplateFilter + filters=ConfigTemplateFilter, + pagination=True ) class ConfigTemplateType(TagsMixin, ObjectType): data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None @@ -84,7 +86,8 @@ class ConfigTemplateType(TagsMixin, ObjectType): @strawberry_django.type( models.CustomField, fields='__all__', - filters=CustomFieldFilter + filters=CustomFieldFilter, + pagination=True ) class CustomFieldType(ObjectType): related_object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None @@ -94,7 +97,8 @@ class CustomFieldType(ObjectType): @strawberry_django.type( models.CustomFieldChoiceSet, exclude=['extra_choices'], - filters=CustomFieldChoiceSetFilter + filters=CustomFieldChoiceSetFilter, + pagination=True ) class CustomFieldChoiceSetType(ObjectType): @@ -105,7 +109,8 @@ class CustomFieldChoiceSetType(ObjectType): @strawberry_django.type( models.CustomLink, fields='__all__', - filters=CustomLinkFilter + filters=CustomLinkFilter, + pagination=True ) class CustomLinkType(ObjectType): pass @@ -114,7 +119,8 @@ class CustomLinkType(ObjectType): @strawberry_django.type( models.ExportTemplate, fields='__all__', - filters=ExportTemplateFilter + filters=ExportTemplateFilter, + pagination=True ) class ExportTemplateType(ObjectType): data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None @@ -124,7 +130,8 @@ class ExportTemplateType(ObjectType): @strawberry_django.type( models.ImageAttachment, fields='__all__', - filters=ImageAttachmentFilter + filters=ImageAttachmentFilter, + pagination=True ) class ImageAttachmentType(BaseObjectType): object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None @@ -133,7 +140,8 @@ class ImageAttachmentType(BaseObjectType): @strawberry_django.type( models.JournalEntry, fields='__all__', - filters=JournalEntryFilter + filters=JournalEntryFilter, + pagination=True ) class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType): assigned_object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None @@ -143,6 +151,7 @@ class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType): @strawberry_django.type( models.Notification, # filters=NotificationFilter + pagination=True ) class NotificationType(ObjectType): user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None @@ -150,7 +159,8 @@ class NotificationType(ObjectType): @strawberry_django.type( models.NotificationGroup, - filters=NotificationGroupFilter + filters=NotificationGroupFilter, + pagination=True ) class NotificationGroupType(ObjectType): users: List[Annotated["UserType", strawberry.lazy('users.graphql.types')]] @@ -160,7 +170,8 @@ class NotificationGroupType(ObjectType): @strawberry_django.type( models.SavedFilter, exclude=['content_types',], - filters=SavedFilterFilter + filters=SavedFilterFilter, + pagination=True ) class SavedFilterType(ObjectType): user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None @@ -169,6 +180,7 @@ class SavedFilterType(ObjectType): @strawberry_django.type( models.Subscription, # filters=NotificationFilter + pagination=True ) class SubscriptionType(ObjectType): user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None @@ -177,7 +189,8 @@ class SubscriptionType(ObjectType): @strawberry_django.type( models.Tag, exclude=['extras_taggeditem_items', ], - filters=TagFilter + filters=TagFilter, + pagination=True ) class TagType(ObjectType): color: str @@ -188,7 +201,8 @@ class TagType(ObjectType): @strawberry_django.type( models.Webhook, exclude=['content_types',], - filters=WebhookFilter + filters=WebhookFilter, + pagination=True ) class WebhookType(OrganizationalObjectType): pass @@ -197,7 +211,8 @@ class WebhookType(OrganizationalObjectType): @strawberry_django.type( models.EventRule, exclude=['content_types',], - filters=EventRuleFilter + filters=EventRuleFilter, + pagination=True ) class EventRuleType(OrganizationalObjectType): action_object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index e63bebcb1..638451f4a 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -70,7 +70,8 @@ class BaseIPAddressFamilyType: @strawberry_django.type( models.ASN, fields='__all__', - filters=ASNFilter + filters=ASNFilter, + pagination=True ) class ASNType(NetBoxObjectType): asn: BigInt @@ -84,7 +85,8 @@ class ASNType(NetBoxObjectType): @strawberry_django.type( models.ASNRange, fields='__all__', - filters=ASNRangeFilter + filters=ASNRangeFilter, + pagination=True ) class ASNRangeType(NetBoxObjectType): start: BigInt @@ -96,7 +98,8 @@ class ASNRangeType(NetBoxObjectType): @strawberry_django.type( models.Aggregate, fields='__all__', - filters=AggregateFilter + filters=AggregateFilter, + pagination=True ) class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType): prefix: str @@ -107,7 +110,8 @@ class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType): @strawberry_django.type( models.FHRPGroup, fields='__all__', - filters=FHRPGroupFilter + filters=FHRPGroupFilter, + pagination=True ) class FHRPGroupType(NetBoxObjectType, IPAddressesMixin): @@ -117,7 +121,8 @@ class FHRPGroupType(NetBoxObjectType, IPAddressesMixin): @strawberry_django.type( models.FHRPGroupAssignment, exclude=['interface_type', 'interface_id'], - filters=FHRPGroupAssignmentFilter + filters=FHRPGroupAssignmentFilter, + pagination=True ) class FHRPGroupAssignmentType(BaseObjectType): group: Annotated["FHRPGroupType", strawberry.lazy('ipam.graphql.types')] @@ -133,7 +138,8 @@ class FHRPGroupAssignmentType(BaseObjectType): @strawberry_django.type( models.IPAddress, exclude=['assigned_object_type', 'assigned_object_id', 'address'], - filters=IPAddressFilter + filters=IPAddressFilter, + pagination=True ) class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType): address: str @@ -157,7 +163,8 @@ class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType): @strawberry_django.type( models.IPRange, fields='__all__', - filters=IPRangeFilter + filters=IPRangeFilter, + pagination=True ) class IPRangeType(NetBoxObjectType): start_address: str @@ -170,7 +177,8 @@ class IPRangeType(NetBoxObjectType): @strawberry_django.type( models.Prefix, exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'], - filters=PrefixFilter + filters=PrefixFilter, + pagination=True ) class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType): prefix: str @@ -192,7 +200,8 @@ class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType): @strawberry_django.type( models.RIR, fields='__all__', - filters=RIRFilter + filters=RIRFilter, + pagination=True ) class RIRType(OrganizationalObjectType): @@ -204,7 +213,8 @@ class RIRType(OrganizationalObjectType): @strawberry_django.type( models.Role, fields='__all__', - filters=RoleFilter + filters=RoleFilter, + pagination=True ) class RoleType(OrganizationalObjectType): @@ -216,7 +226,8 @@ class RoleType(OrganizationalObjectType): @strawberry_django.type( models.RouteTarget, fields='__all__', - filters=RouteTargetFilter + filters=RouteTargetFilter, + pagination=True ) class RouteTargetType(NetBoxObjectType): tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None @@ -230,7 +241,8 @@ class RouteTargetType(NetBoxObjectType): @strawberry_django.type( models.Service, fields='__all__', - filters=ServiceFilter + filters=ServiceFilter, + pagination=True ) class ServiceType(NetBoxObjectType): ports: List[int] @@ -243,7 +255,8 @@ class ServiceType(NetBoxObjectType): @strawberry_django.type( models.ServiceTemplate, fields='__all__', - filters=ServiceTemplateFilter + filters=ServiceTemplateFilter, + pagination=True ) class ServiceTemplateType(NetBoxObjectType): ports: List[int] @@ -252,7 +265,8 @@ class ServiceTemplateType(NetBoxObjectType): @strawberry_django.type( models.VLAN, exclude=['qinq_svlan'], - filters=VLANFilter + filters=VLANFilter, + pagination=True ) class VLANType(NetBoxObjectType): site: Annotated["SiteType", strawberry.lazy('ipam.graphql.types')] | None @@ -275,7 +289,8 @@ class VLANType(NetBoxObjectType): @strawberry_django.type( models.VLANGroup, exclude=['scope_type', 'scope_id'], - filters=VLANGroupFilter + filters=VLANGroupFilter, + pagination=True ) class VLANGroupType(OrganizationalObjectType): @@ -299,7 +314,8 @@ class VLANGroupType(OrganizationalObjectType): @strawberry_django.type( models.VLANTranslationPolicy, fields='__all__', - filters=VLANTranslationPolicyFilter + filters=VLANTranslationPolicyFilter, + pagination=True ) class VLANTranslationPolicyType(NetBoxObjectType): rules: List[Annotated["VLANTranslationRuleType", strawberry.lazy('ipam.graphql.types')]] @@ -308,7 +324,8 @@ class VLANTranslationPolicyType(NetBoxObjectType): @strawberry_django.type( models.VLANTranslationRule, fields='__all__', - filters=VLANTranslationRuleFilter + filters=VLANTranslationRuleFilter, + pagination=True ) class VLANTranslationRuleType(NetBoxObjectType): policy: Annotated[ @@ -320,7 +337,8 @@ class VLANTranslationRuleType(NetBoxObjectType): @strawberry_django.type( models.VRF, fields='__all__', - filters=VRFFilter + filters=VRFFilter, + pagination=True ) class VRFType(NetBoxObjectType): tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index a7609c9d2..c840e769c 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -1,7 +1,7 @@ import strawberry from django.conf import settings from strawberry_django.optimizer import DjangoOptimizerExtension -from strawberry.extensions import MaxAliasesLimiter +from strawberry.extensions import MaxAliasesLimiter # , SchemaExtension from strawberry.schema.config import StrawberryConfig from circuits.graphql.schema import CircuitsQuery diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py index 5df4cfd38..653462630 100644 --- a/netbox/netbox/graphql/types.py +++ b/netbox/netbox/graphql/types.py @@ -84,6 +84,7 @@ class NetBoxObjectType( @strawberry_django.type( ContentType, fields=['id', 'app_label', 'model'], + pagination=True ) class ContentTypeType: pass @@ -92,6 +93,7 @@ class ContentTypeType: @strawberry_django.type( ObjectType_, fields=['id', 'app_label', 'model'], + pagination=True ) class ObjectTypeType: pass diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 9f9b25689..43dcaeed2 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -798,6 +798,7 @@ LOCALE_PATHS = ( STRAWBERRY_DJANGO = { "DEFAULT_PK_FIELD_NAME": "id", "TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING": True, + "PAGINATION_DEFAULT_LIMIT": 100, } # diff --git a/netbox/tenancy/graphql/types.py b/netbox/tenancy/graphql/types.py index b47ac2da3..1a33b44ab 100644 --- a/netbox/tenancy/graphql/types.py +++ b/netbox/tenancy/graphql/types.py @@ -52,7 +52,12 @@ __all__ = ( # -@strawberry_django.type(models.Tenant, fields='__all__', filters=TenantFilter) +@strawberry_django.type( + models.Tenant, + fields='__all__', + filters=TenantFilter, + pagination=True +) class TenantType(NetBoxObjectType): group: Annotated['TenantGroupType', strawberry.lazy('tenancy.graphql.types')] | None contacts: List[Annotated['ContactType', strawberry.lazy('tenancy.graphql.types')]] @@ -82,7 +87,12 @@ class TenantType(NetBoxObjectType): l2vpns: List[Annotated['L2VPNType', strawberry.lazy('vpn.graphql.types')]] -@strawberry_django.type(models.TenantGroup, fields='__all__', filters=TenantGroupFilter) +@strawberry_django.type( + models.TenantGroup, + fields='__all__', + filters=TenantGroupFilter, + pagination=True +) class TenantGroupType(OrganizationalObjectType): parent: Annotated['TenantGroupType', strawberry.lazy('tenancy.graphql.types')] | None @@ -95,17 +105,32 @@ class TenantGroupType(OrganizationalObjectType): # -@strawberry_django.type(models.Contact, fields='__all__', filters=ContactFilter) +@strawberry_django.type( + models.Contact, + fields='__all__', + filters=ContactFilter, + pagination=True +) class ContactType(ContactAssignmentsMixin, NetBoxObjectType): groups: List[Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')]] -@strawberry_django.type(models.ContactRole, fields='__all__', filters=ContactRoleFilter) +@strawberry_django.type( + models.ContactRole, + fields='__all__', + filters=ContactRoleFilter, + pagination=True +) class ContactRoleType(ContactAssignmentsMixin, OrganizationalObjectType): pass -@strawberry_django.type(models.ContactGroup, fields='__all__', filters=ContactGroupFilter) +@strawberry_django.type( + models.ContactGroup, + fields='__all__', + filters=ContactGroupFilter, + pagination=True +) class ContactGroupType(OrganizationalObjectType): parent: Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')] | None @@ -113,7 +138,12 @@ class ContactGroupType(OrganizationalObjectType): children: List[Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')]] -@strawberry_django.type(models.ContactAssignment, fields='__all__', filters=ContactAssignmentFilter) +@strawberry_django.type( + models.ContactAssignment, + fields='__all__', + filters=ContactAssignmentFilter, + pagination=True +) class ContactAssignmentType(CustomFieldsMixin, TagsMixin, BaseObjectType): object_type: Annotated['ContentTypeType', strawberry.lazy('netbox.graphql.types')] | None contact: Annotated['ContactType', strawberry.lazy('tenancy.graphql.types')] | None diff --git a/netbox/users/graphql/types.py b/netbox/users/graphql/types.py index 526bf6e21..c5b338553 100644 --- a/netbox/users/graphql/types.py +++ b/netbox/users/graphql/types.py @@ -15,7 +15,8 @@ __all__ = ( @strawberry_django.type( Group, fields=['id', 'name'], - filters=GroupFilter + filters=GroupFilter, + pagination=True ) class GroupType(BaseObjectType): pass @@ -26,7 +27,8 @@ class GroupType(BaseObjectType): fields=[ 'id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined', 'groups', ], - filters=UserFilter + filters=UserFilter, + pagination=True ) class UserType(BaseObjectType): groups: List[GroupType] diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index 2fcffc20f..cfffa85e2 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -46,7 +46,8 @@ class ComponentType(NetBoxObjectType): @strawberry_django.type( models.Cluster, exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'], - filters=ClusterFilter + filters=ClusterFilter, + pagination=True ) class ClusterType(VLANGroupsMixin, NetBoxObjectType): type: Annotated["ClusterTypeType", strawberry.lazy('virtualization.graphql.types')] | None @@ -68,7 +69,8 @@ class ClusterType(VLANGroupsMixin, NetBoxObjectType): @strawberry_django.type( models.ClusterGroup, fields='__all__', - filters=ClusterGroupFilter + filters=ClusterGroupFilter, + pagination=True ) class ClusterGroupType(VLANGroupsMixin, OrganizationalObjectType): @@ -78,7 +80,8 @@ class ClusterGroupType(VLANGroupsMixin, OrganizationalObjectType): @strawberry_django.type( models.ClusterType, fields='__all__', - filters=ClusterTypeFilter + filters=ClusterTypeFilter, + pagination=True ) class ClusterTypeType(OrganizationalObjectType): @@ -88,7 +91,8 @@ class ClusterTypeType(OrganizationalObjectType): @strawberry_django.type( models.VirtualMachine, fields='__all__', - filters=VirtualMachineFilter + filters=VirtualMachineFilter, + pagination=True ) class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType): interface_count: BigInt @@ -112,7 +116,8 @@ class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType): @strawberry_django.type( models.VMInterface, fields='__all__', - filters=VMInterfaceFilter + filters=VMInterfaceFilter, + pagination=True ) class VMInterfaceType(IPAddressesMixin, ComponentType): _name: str @@ -134,7 +139,8 @@ class VMInterfaceType(IPAddressesMixin, ComponentType): @strawberry_django.type( models.VirtualDisk, fields='__all__', - filters=VirtualDiskFilter + filters=VirtualDiskFilter, + pagination=True ) class VirtualDiskType(ComponentType): pass diff --git a/netbox/vpn/graphql/types.py b/netbox/vpn/graphql/types.py index cc133ee6f..bbf84dd16 100644 --- a/netbox/vpn/graphql/types.py +++ b/netbox/vpn/graphql/types.py @@ -32,7 +32,8 @@ __all__ = ( @strawberry_django.type( models.TunnelGroup, fields='__all__', - filters=TunnelGroupFilter + filters=TunnelGroupFilter, + pagination=True ) class TunnelGroupType(OrganizationalObjectType): @@ -42,7 +43,8 @@ class TunnelGroupType(OrganizationalObjectType): @strawberry_django.type( models.TunnelTermination, fields='__all__', - filters=TunnelTerminationFilter + filters=TunnelTerminationFilter, + pagination=True ) class TunnelTerminationType(CustomFieldsMixin, TagsMixin, ObjectType): tunnel: Annotated["TunnelType", strawberry.lazy('vpn.graphql.types')] @@ -53,7 +55,8 @@ class TunnelTerminationType(CustomFieldsMixin, TagsMixin, ObjectType): @strawberry_django.type( models.Tunnel, fields='__all__', - filters=TunnelFilter + filters=TunnelFilter, + pagination=True ) class TunnelType(NetBoxObjectType): group: Annotated["TunnelGroupType", strawberry.lazy('vpn.graphql.types')] | None @@ -66,7 +69,8 @@ class TunnelType(NetBoxObjectType): @strawberry_django.type( models.IKEProposal, fields='__all__', - filters=IKEProposalFilter + filters=IKEProposalFilter, + pagination=True ) class IKEProposalType(OrganizationalObjectType): @@ -76,7 +80,8 @@ class IKEProposalType(OrganizationalObjectType): @strawberry_django.type( models.IKEPolicy, fields='__all__', - filters=IKEPolicyFilter + filters=IKEPolicyFilter, + pagination=True ) class IKEPolicyType(OrganizationalObjectType): @@ -87,7 +92,8 @@ class IKEPolicyType(OrganizationalObjectType): @strawberry_django.type( models.IPSecProposal, fields='__all__', - filters=IPSecProposalFilter + filters=IPSecProposalFilter, + pagination=True ) class IPSecProposalType(OrganizationalObjectType): @@ -97,7 +103,8 @@ class IPSecProposalType(OrganizationalObjectType): @strawberry_django.type( models.IPSecPolicy, fields='__all__', - filters=IPSecPolicyFilter + filters=IPSecPolicyFilter, + pagination=True ) class IPSecPolicyType(OrganizationalObjectType): @@ -108,7 +115,8 @@ class IPSecPolicyType(OrganizationalObjectType): @strawberry_django.type( models.IPSecProfile, fields='__all__', - filters=IPSecProfileFilter + filters=IPSecProfileFilter, + pagination=True ) class IPSecProfileType(OrganizationalObjectType): ike_policy: Annotated["IKEPolicyType", strawberry.lazy('vpn.graphql.types')] @@ -120,7 +128,8 @@ class IPSecProfileType(OrganizationalObjectType): @strawberry_django.type( models.L2VPN, fields='__all__', - filters=L2VPNFilter + filters=L2VPNFilter, + pagination=True ) class L2VPNType(ContactsMixin, NetBoxObjectType): tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None @@ -133,7 +142,8 @@ class L2VPNType(ContactsMixin, NetBoxObjectType): @strawberry_django.type( models.L2VPNTermination, exclude=['assigned_object_type', 'assigned_object_id'], - filters=L2VPNTerminationFilter + filters=L2VPNTerminationFilter, + pagination=True ) class L2VPNTerminationType(NetBoxObjectType): l2vpn: Annotated["L2VPNType", strawberry.lazy('vpn.graphql.types')] diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py index 7c31dfec8..eeca6a82b 100644 --- a/netbox/wireless/graphql/types.py +++ b/netbox/wireless/graphql/types.py @@ -22,7 +22,8 @@ __all__ = ( @strawberry_django.type( models.WirelessLANGroup, fields='__all__', - filters=WirelessLANGroupFilter + filters=WirelessLANGroupFilter, + pagination=True ) class WirelessLANGroupType(OrganizationalObjectType): parent: Annotated["WirelessLANGroupType", strawberry.lazy('wireless.graphql.types')] | None @@ -34,7 +35,8 @@ class WirelessLANGroupType(OrganizationalObjectType): @strawberry_django.type( models.WirelessLAN, exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'], - filters=WirelessLANFilter + filters=WirelessLANFilter, + pagination=True ) class WirelessLANType(NetBoxObjectType): group: Annotated["WirelessLANGroupType", strawberry.lazy('wireless.graphql.types')] | None @@ -56,7 +58,8 @@ class WirelessLANType(NetBoxObjectType): @strawberry_django.type( models.WirelessLink, fields='__all__', - filters=WirelessLinkFilter + filters=WirelessLinkFilter, + pagination=True ) class WirelessLinkType(NetBoxObjectType): interface_a: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] From 7a71c7b8f844633fa5ac6497575bc35698279d62 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 26 Mar 2025 05:42:13 -0700 Subject: [PATCH 058/103] 18417 Add outer_height to racks (#18940) * 18417 add rack outer height * 18417 add rack outer height * 18417 fix tests * 18417 fix validation message * Update netbox/dcim/filtersets.py Co-authored-by: Jeremy Stretch * Update netbox/dcim/filtersets.py Co-authored-by: Jeremy Stretch * Update netbox/dcim/models/racks.py Co-authored-by: Jeremy Stretch * Update netbox/dcim/models/racks.py Co-authored-by: Jeremy Stretch * Update netbox/dcim/models/racks.py Co-authored-by: Jeremy Stretch * Update netbox/dcim/models/racks.py Co-authored-by: Jeremy Stretch * 16224 review changes * 16224 review changes * 16224 update table display * 18417 use TemplateColumn * 18417 review changes --------- Co-authored-by: Jeremy Stretch --- docs/models/dcim/racktype.md | 2 +- netbox/dcim/api/serializers_/racks.py | 10 +++--- netbox/dcim/filtersets.py | 8 ++--- netbox/dcim/forms/bulk_edit.py | 24 ++++++++----- netbox/dcim/forms/bulk_import.py | 4 +-- netbox/dcim/forms/model_forms.py | 13 +++---- .../migrations/0203_add_rack_outer_height.py | 23 ++++++++++++ netbox/dcim/models/racks.py | 27 ++++++++------ netbox/dcim/tables/racks.py | 35 ++++++++++++------- netbox/dcim/tables/template_code.py | 5 +++ netbox/dcim/tests/test_filtersets.py | 16 +++++++++ .../dcim/inc/panels/racktype_dimensions.html | 10 ++++++ 12 files changed, 128 insertions(+), 49 deletions(-) create mode 100644 netbox/dcim/migrations/0203_add_rack_outer_height.py diff --git a/docs/models/dcim/racktype.md b/docs/models/dcim/racktype.md index b5f2d99e7..ecaf539c9 100644 --- a/docs/models/dcim/racktype.md +++ b/docs/models/dcim/racktype.md @@ -40,7 +40,7 @@ The number of the numerically lowest unit in the rack. This value defaults to on ### Outer Dimensions -The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches. +The external width, height and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches. ### Mounting Depth diff --git a/netbox/dcim/api/serializers_/racks.py b/netbox/dcim/api/serializers_/racks.py index 1378c265a..4bc2900dc 100644 --- a/netbox/dcim/api/serializers_/racks.py +++ b/netbox/dcim/api/serializers_/racks.py @@ -70,8 +70,8 @@ class RackTypeSerializer(RackBaseSerializer): model = RackType fields = [ 'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'description', 'form_factor', - 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'weight', - 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'comments', 'tags', + 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', + 'outer_unit', 'weight', 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description') @@ -129,9 +129,9 @@ class RackSerializer(RackBaseSerializer): fields = [ 'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'weight', - 'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', - 'airflow', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', - 'powerfeed_count', + 'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', + 'mounting_depth', 'airflow', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', 'device_count', 'powerfeed_count', ] brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count') diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 6f9f481c3..81c92d759 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -313,8 +313,8 @@ class RackTypeFilterSet(NetBoxModelFilterSet): class Meta: model = RackType fields = ( - 'id', 'model', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', - 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', + 'id', 'model', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', + 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', ) def search(self, queryset, name, value): @@ -426,8 +426,8 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe model = Rack fields = ( 'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'starting_unit', 'desc_units', 'outer_width', - 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', - 'description', + 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', + 'weight_unit', 'description', ) def search(self, queryset, name, value): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index c1da9c8d1..31f6fd9df 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -260,6 +260,11 @@ class RackTypeBulkEditForm(NetBoxModelBulkEditForm): required=False, min_value=1 ) + outer_height = forms.IntegerField( + label=_('Outer height'), + required=False, + min_value=1 + ) outer_depth = forms.IntegerField( label=_('Outer depth'), required=False, @@ -302,7 +307,7 @@ class RackTypeBulkEditForm(NetBoxModelBulkEditForm): fieldsets = ( FieldSet('manufacturer', 'description', 'form_factor', 'width', 'u_height', name=_('Rack Type')), FieldSet( - InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), + InlineFields('outer_width', 'outer_height', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), 'mounting_depth', name=_('Dimensions') @@ -310,7 +315,7 @@ class RackTypeBulkEditForm(NetBoxModelBulkEditForm): FieldSet('starting_unit', 'desc_units', name=_('Numbering')), ) nullable_fields = ( - 'outer_width', 'outer_depth', 'outer_unit', 'weight', + 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', ) @@ -404,6 +409,11 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): required=False, min_value=1 ) + outer_height = forms.IntegerField( + label=_('Outer height'), + required=False, + min_value=1 + ) outer_depth = forms.IntegerField( label=_('Outer depth'), required=False, @@ -451,15 +461,13 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): fieldsets = ( FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'rack_type', 'description', name=_('Rack')), FieldSet('region', 'site_group', 'site', 'location', name=_('Location')), - FieldSet( - 'form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'outer_width', 'outer_depth', 'outer_unit', - 'mounting_depth', name=_('Hardware') - ), + FieldSet('outer_width', 'outer_height', 'outer_depth', 'outer_unit', name=_('Outer Dimensions')), + FieldSet('form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'mounting_depth', name=_('Hardware')), FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), ) nullable_fields = ( - 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'weight', - 'max_weight', 'weight_unit', 'description', 'comments', + 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_height', 'outer_depth', + 'outer_unit', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', ) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 469e40217..708bc7618 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -222,7 +222,7 @@ class RackTypeImportForm(NetBoxModelImportForm): model = RackType fields = ( 'manufacturer', 'model', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', - 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', + 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', ) @@ -307,7 +307,7 @@ class RackImportForm(NetBoxModelImportForm): model = Rack fields = ( 'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor', 'serial', - 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', + 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', ) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index dea031b64..8fa7e153f 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -226,7 +226,7 @@ class RackTypeForm(NetBoxModelForm): FieldSet('manufacturer', 'model', 'slug', 'description', 'form_factor', 'tags', name=_('Rack Type')), FieldSet( 'width', 'u_height', - InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), + InlineFields('outer_width', 'outer_height', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), 'mounting_depth', name=_('Dimensions') ), @@ -237,8 +237,8 @@ class RackTypeForm(NetBoxModelForm): model = RackType fields = [ 'manufacturer', 'model', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', - 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', - 'description', 'comments', 'tags', + 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', + 'weight_unit', 'description', 'comments', 'tags', ] @@ -283,8 +283,8 @@ class RackForm(TenancyForm, NetBoxModelForm): fields = [ 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', - 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', - 'description', 'comments', 'tags', + 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', + 'weight_unit', 'description', 'comments', 'tags', ] def __init__(self, *args, **kwargs): @@ -306,7 +306,8 @@ class RackForm(TenancyForm, NetBoxModelForm): *self.fieldsets, FieldSet( 'form_factor', 'width', 'starting_unit', 'u_height', - InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), + InlineFields('outer_width', 'outer_height', 'outer_depth', 'outer_unit', + label=_('Outer Dimensions')), InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), 'mounting_depth', 'desc_units', name=_('Dimensions') ), diff --git a/netbox/dcim/migrations/0203_add_rack_outer_height.py b/netbox/dcim/migrations/0203_add_rack_outer_height.py new file mode 100644 index 000000000..2d2fef265 --- /dev/null +++ b/netbox/dcim/migrations/0203_add_rack_outer_height.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2b1 on 2025-03-18 15:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0202_location_comments_region_comments_sitegroup_comments'), + ] + + operations = [ + migrations.AddField( + model_name='rack', + name='outer_height', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='racktype', + name='outer_height', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 7ecbd5d5f..390d9ae9e 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -73,6 +73,12 @@ class RackBase(WeightMixin, PrimaryModel): null=True, help_text=_('Outer dimension of rack (width)') ) + outer_height = models.PositiveSmallIntegerField( + verbose_name=_('outer height'), + blank=True, + null=True, + help_text=_('Outer dimension of rack (height)') + ) outer_depth = models.PositiveSmallIntegerField( verbose_name=_('outer depth'), blank=True, @@ -140,7 +146,7 @@ class RackType(RackBase): ) clone_fields = ( - 'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', + 'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', ) prerequisite_models = ( @@ -173,8 +179,8 @@ class RackType(RackBase): super().clean() # Validate outer dimensions and unit - if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit: - raise ValidationError(_("Must specify a unit when setting an outer width/depth")) + if any([self.outer_width, self.outer_depth, self.outer_height]) and not self.outer_unit: + raise ValidationError(_("Must specify a unit when setting an outer dimension")) # Validate max_weight and weight_unit if self.max_weight and not self.weight_unit: @@ -188,7 +194,7 @@ class RackType(RackBase): self._abs_max_weight = None # Clear unit if outer width & depth are not set - if self.outer_width is None and self.outer_depth is None: + if not any([self.outer_width, self.outer_depth, self.outer_height]): self.outer_unit = None super().save(*args, **kwargs) @@ -235,8 +241,8 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): """ # Fields which cannot be set locally if a RackType is assigned RACKTYPE_FIELDS = ( - 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', - 'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'max_weight', + 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', + 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'max_weight', ) form_factor = models.CharField( @@ -329,7 +335,8 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): clone_fields = ( 'site', 'location', 'tenant', 'status', 'role', 'form_factor', 'width', 'airflow', 'u_height', 'desc_units', - 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', + 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', + 'weight_unit', ) prerequisite_models = ( 'dcim.Site', @@ -364,8 +371,8 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): raise ValidationError(_("Assigned location must belong to parent site ({site}).").format(site=self.site)) # Validate outer dimensions and unit - if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit: - raise ValidationError(_("Must specify a unit when setting an outer width/depth")) + if any([self.outer_width, self.outer_depth, self.outer_height]) and not self.outer_unit: + raise ValidationError(_("Must specify a unit when setting an outer dimension")) # Validate max_weight and weight_unit if self.max_weight and not self.weight_unit: @@ -414,7 +421,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): self._abs_max_weight = None # Clear unit if outer width & depth are not set - if self.outer_width is None and self.outer_depth is None: + if not any([self.outer_width, self.outer_depth, self.outer_height]): self.outer_unit = None super().save(*args, **kwargs) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index dbd99ca24..ee40056de 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -5,7 +5,7 @@ from django_tables2.utils import Accessor from dcim.models import Rack, RackReservation, RackRole, RackType from netbox.tables import NetBoxTable, columns from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin -from .template_code import WEIGHT +from .template_code import OUTER_UNIT, WEIGHT __all__ = ( 'RackTable', @@ -62,12 +62,16 @@ class RackTypeTable(NetBoxTable): template_code="{{ value }}U", verbose_name=_('Height') ) - outer_width = tables.TemplateColumn( - template_code="{{ record.outer_width }} {{ record.outer_unit }}", + outer_width = columns.TemplateColumn( + template_code=OUTER_UNIT, verbose_name=_('Outer Width') ) - outer_depth = tables.TemplateColumn( - template_code="{{ record.outer_depth }} {{ record.outer_unit }}", + outer_height = columns.TemplateColumn( + template_code=OUTER_UNIT, + verbose_name=_('Outer Height') + ) + outer_depth = columns.TemplateColumn( + template_code=OUTER_UNIT, verbose_name=_('Outer Depth') ) weight = columns.TemplateColumn( @@ -96,8 +100,8 @@ class RackTypeTable(NetBoxTable): model = RackType fields = ( 'pk', 'id', 'model', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', - 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description', 'comments', - 'instance_count', 'tags', 'created', 'last_updated', + 'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description', + 'comments', 'instance_count', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'model', 'manufacturer', 'type', 'u_height', 'description', 'instance_count', @@ -159,12 +163,16 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): tags = columns.TagColumn( url_name='dcim:rack_list' ) - outer_width = tables.TemplateColumn( - template_code="{{ record.outer_width }} {{ record.outer_unit }}", + outer_width = columns.TemplateColumn( + template_code=OUTER_UNIT, verbose_name=_('Outer Width') ) - outer_depth = tables.TemplateColumn( - template_code="{{ record.outer_depth }} {{ record.outer_unit }}", + outer_height = columns.TemplateColumn( + template_code=OUTER_UNIT, + verbose_name=_('Outer Height') + ) + outer_depth = columns.TemplateColumn( + template_code=OUTER_UNIT, verbose_name=_('Outer Depth') ) weight = columns.TemplateColumn( @@ -183,8 +191,9 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): fields = ( 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'rack_type', 'serial', 'asset_tag', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', - 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments', 'device_count', - 'get_utilization', 'get_power_utilization', 'description', 'contacts', 'tags', 'created', 'last_updated', + 'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments', + 'device_count', 'get_utilization', 'get_power_utilization', 'description', 'contacts', + 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'rack_type', 'u_height', diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 4b51cd06a..225237ec4 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -109,6 +109,11 @@ LOCATION_BUTTONS = """ """ +OUTER_UNIT = """ +{% load helpers %} +{% if value %}{{ value }} {{ record.outer_unit }}{% endif %} +""" + # # Device component templatebuttons # diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 0c4bbbaff..f46391310 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -585,6 +585,7 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests): starting_unit=1, desc_units=False, outer_width=100, + outer_height=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, mounting_depth=100, @@ -603,6 +604,7 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests): starting_unit=2, desc_units=False, outer_width=200, + outer_height=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, mounting_depth=200, @@ -621,6 +623,7 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests): starting_unit=3, desc_units=True, outer_width=300, + outer_height=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH, mounting_depth=300, @@ -681,6 +684,10 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'outer_width': [100, 200]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_outer_height(self): + params = {'outer_height': [100, 200]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_outer_depth(self): params = {'outer_depth': [100, 200]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -764,6 +771,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): starting_unit=1, desc_units=False, outer_width=100, + outer_height=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, mounting_depth=100, @@ -782,6 +790,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): starting_unit=2, desc_units=False, outer_width=200, + outer_height=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, mounting_depth=200, @@ -831,6 +840,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): u_height=42, desc_units=False, outer_width=100, + outer_height=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=10, @@ -854,6 +864,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): u_height=43, desc_units=False, outer_width=200, + outer_height=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=20, @@ -877,6 +888,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): u_height=44, desc_units=True, outer_width=300, + outer_height=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH, weight=30, @@ -957,6 +969,10 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'outer_width': [100, 200]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_outer_height(self): + params = {'outer_height': [100, 200]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_outer_depth(self): params = {'outer_depth': [100, 200]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/templates/dcim/inc/panels/racktype_dimensions.html b/netbox/templates/dcim/inc/panels/racktype_dimensions.html index 03eab981b..0956cddc1 100644 --- a/netbox/templates/dcim/inc/panels/racktype_dimensions.html +++ b/netbox/templates/dcim/inc/panels/racktype_dimensions.html @@ -24,6 +24,16 @@ {% endif %} + + + + + + + + - - + + @@ -88,6 +88,25 @@ {% plugin_left_page object %}
+
+

{% trans "Module Type" %}

+
{% trans "Group" %}{{ object.group|linkify|placeholder }}{% trans "Groups" %} + {% if object.groups.all|length > 0 %} +
    + {% for group in object.groups.all %} +
  1. {{ group|linkify|placeholder }}
  2. + {% endfor %} +
+ {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Name" %}
{% trans "Weight" %}{{ object.weight }}
{% trans "Tagged Items" %} From 80440fd0250d6b66074a5a8284a4f96058aa6ed9 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Thu, 20 Mar 2025 08:17:56 -0500 Subject: [PATCH 056/103] Fixes #17443: Adds ExportTemplate.file_name field (#18911) * Fixes #17443: Adds ExportTemplate.file_name field * Addresses PR feedback - Adds `file_name` to `ExportTemplateBulkEditForm.nullable_fields` - Shortens max length of `ExportTemplate.file_name` to 200 chars - Adds tests for `ExportTemplateFilterSet.file_extension` * Fixes migration conflict caused by fix for #17841 --- docs/models/extras/exporttemplate.md | 6 +++++ .../api/serializers_/exporttemplates.py | 4 ++-- netbox/extras/filtersets.py | 7 +++--- netbox/extras/forms/bulk_edit.py | 6 ++++- netbox/extras/forms/bulk_import.py | 3 ++- netbox/extras/forms/filtersets.py | 6 ++++- netbox/extras/forms/model_forms.py | 2 +- .../0124_alter_tag_options_tag_weight.py | 2 -- .../0125_exporttemplate_file_name.py | 16 +++++++++++++ netbox/extras/models/models.py | 15 ++++++++---- netbox/extras/tables/tables.py | 7 +++--- netbox/extras/tests/test_api.py | 9 +++++-- netbox/extras/tests/test_filtersets.py | 24 +++++++++++++++++-- netbox/extras/tests/test_utils.py | 19 +++++++++++++++ netbox/extras/tests/test_views.py | 11 +++++---- netbox/extras/utils.py | 7 ++++++ netbox/templates/extras/exporttemplate.html | 4 ++++ 17 files changed, 120 insertions(+), 28 deletions(-) create mode 100644 netbox/extras/migrations/0125_exporttemplate_file_name.py create mode 100644 netbox/extras/tests/test_utils.py diff --git a/docs/models/extras/exporttemplate.md b/docs/models/extras/exporttemplate.md index d2f9292c6..73be522b8 100644 --- a/docs/models/extras/exporttemplate.md +++ b/docs/models/extras/exporttemplate.md @@ -24,6 +24,12 @@ Jinja2 template code for rendering the exported data. The MIME type to indicate in the response when rendering the export template (optional). Defaults to `text/plain`. +### File Name + +The file name to give to the rendered export file (optional). + +!!! info "This field was introduced in NetBox v4.3." + ### File Extension The file extension to append to the file name in the response (optional). diff --git a/netbox/extras/api/serializers_/exporttemplates.py b/netbox/extras/api/serializers_/exporttemplates.py index 11f502a02..ad77cd1f7 100644 --- a/netbox/extras/api/serializers_/exporttemplates.py +++ b/netbox/extras/api/serializers_/exporttemplates.py @@ -27,7 +27,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer): model = ExportTemplate fields = [ 'id', 'url', 'display_url', 'display', 'object_types', 'name', 'description', 'template_code', 'mime_type', - 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created', - 'last_updated', + 'file_name', 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', + 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 635102be2..e63b6d673 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -258,8 +258,8 @@ class ExportTemplateFilterSet(ChangeLoggedModelFilterSet): class Meta: model = ExportTemplate fields = ( - 'id', 'name', 'description', 'mime_type', 'file_extension', 'as_attachment', 'auto_sync_enabled', - 'data_synced', + 'id', 'name', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment', + 'auto_sync_enabled', 'data_synced', ) def search(self, queryset, name, value): @@ -267,7 +267,8 @@ class ExportTemplateFilterSet(ChangeLoggedModelFilterSet): return queryset return queryset.filter( Q(name__icontains=value) | - Q(description__icontains=value) + Q(description__icontains=value) | + Q(file_name__icontains=value) ) diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 7adc303f5..6891edc5d 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -155,6 +155,10 @@ class ExportTemplateBulkEditForm(BulkEditForm): max_length=50, required=False ) + file_name = forms.CharField( + label=_('File name'), + required=False + ) file_extension = forms.CharField( label=_('File extension'), max_length=15, @@ -166,7 +170,7 @@ class ExportTemplateBulkEditForm(BulkEditForm): widget=BulkEditNullBooleanSelect() ) - nullable_fields = ('description', 'mime_type', 'file_extension') + nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension') class SavedFilterBulkEditForm(BulkEditForm): diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index b680382f6..fb522bd7b 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -144,7 +144,8 @@ class ExportTemplateImportForm(CSVModelForm): class Meta: model = ExportTemplate fields = ( - 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code', + 'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment', + 'template_code', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 05dcf96c4..1691559f9 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -162,7 +162,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( FieldSet('q', 'filter_id'), FieldSet('data_source_id', 'data_file_id', name=_('Data')), - FieldSet('object_type_id', 'mime_type', 'file_extension', 'as_attachment', name=_('Attributes')), + FieldSet('object_type_id', 'mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Attributes')), ) data_source_id = DynamicModelMultipleChoiceField( queryset=DataSource.objects.all(), @@ -186,6 +186,10 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): required=False, label=_('MIME type') ) + file_name = forms.CharField( + label=_('File name'), + required=False + ) file_extension = forms.CharField( label=_('File extension'), required=False diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 5591de754..b5bc06b40 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -246,7 +246,7 @@ class ExportTemplateForm(SyncedDataMixin, forms.ModelForm): fieldsets = ( FieldSet('name', 'object_types', 'description', 'template_code', name=_('Export Template')), FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')), - FieldSet('mime_type', 'file_extension', 'as_attachment', name=_('Rendering')), + FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')), ) class Meta: diff --git a/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py b/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py index 86fc71fd5..759ad1595 100644 --- a/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py +++ b/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py @@ -1,5 +1,3 @@ -# Generated by Django 5.2b1 on 2025-03-17 14:41 - from django.db import migrations, models diff --git a/netbox/extras/migrations/0125_exporttemplate_file_name.py b/netbox/extras/migrations/0125_exporttemplate_file_name.py new file mode 100644 index 000000000..2c8ac118b --- /dev/null +++ b/netbox/extras/migrations/0125_exporttemplate_file_name.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0124_alter_tag_options_tag_weight'), + ] + + operations = [ + migrations.AddField( + model_name='exporttemplate', + name='file_name', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index d3e443b14..3cae54f29 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -16,7 +16,7 @@ from core.models import ObjectType from extras.choices import * from extras.conditions import ConditionSet from extras.constants import * -from extras.utils import image_upload +from extras.utils import filename_from_model, image_upload from netbox.config import get_config from netbox.events import get_event_type_choices from netbox.models import ChangeLoggedModel @@ -409,6 +409,11 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change verbose_name=_('MIME type'), help_text=_('Defaults to text/plain; charset=utf-8') ) + file_name = models.CharField( + max_length=200, + blank=True, + help_text=_('Filename to give to the rendered export file') + ) file_extension = models.CharField( verbose_name=_('file extension'), max_length=15, @@ -422,7 +427,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change ) clone_fields = ( - 'object_types', 'template_code', 'mime_type', 'file_extension', 'as_attachment', + 'object_types', 'template_code', 'mime_type', 'file_name', 'file_extension', 'as_attachment', ) class Meta: @@ -480,10 +485,10 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change response = HttpResponse(output, content_type=mime_type) if self.as_attachment: - basename = queryset.model._meta.verbose_name_plural.replace(' ', '_') extension = f'.{self.file_extension}' if self.file_extension else '' - filename = f'netbox_{basename}{extension}' - response['Content-Disposition'] = f'attachment; filename="{filename}"' + filename = self.file_name or filename_from_model(queryset.model) + full_filename = f'{filename}{extension}' + response['Content-Disposition'] = f'attachment; filename="{full_filename}"' return response diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index a14356c1c..7a6e79cab 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -203,11 +203,12 @@ class ExportTemplateTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = ExportTemplate fields = ( - 'pk', 'id', 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', - 'data_source', 'data_file', 'data_synced', 'created', 'last_updated', + 'pk', 'id', 'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension', + 'as_attachment', 'data_source', 'data_file', 'data_synced', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'is_synced', + 'pk', 'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension', + 'as_attachment', 'is_synced', ) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 1d6dfac6d..7a4d63549 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -479,6 +479,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): 'object_types': ['dcim.device'], 'name': 'Test Export Template 6', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', + 'file_name': 'test_export_template_6', }, ] bulk_update_data = { @@ -494,7 +495,9 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): ), ExportTemplate( name='Export Template 2', - template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' + template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', + file_name='export_template_2', + file_extension='test', ), ExportTemplate( name='Export Template 3', @@ -502,8 +505,10 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): ), ) ExportTemplate.objects.bulk_create(export_templates) + + device_object_type = ObjectType.objects.get_for_model(Device) for et in export_templates: - et.object_types.set([ObjectType.objects.get_for_model(Device)]) + et.object_types.set([device_object_type]) class TagTest(APIViewTestCases.APIViewTestCase): diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index c6c53bfcb..ff4543bd2 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -624,8 +624,11 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): export_templates = ( ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'), - ExportTemplate(name='Export Template 2', template_code='TESTING', description='foobar2'), - ExportTemplate(name='Export Template 3', template_code='TESTING'), + ExportTemplate( + name='Export Template 2', template_code='TESTING', description='foobar2', + file_name='export_template_2', file_extension='nagios', + ), + ExportTemplate(name='Export Template 3', template_code='TESTING', file_name='export_filename'), ) ExportTemplate.objects.bulk_create(export_templates) for i, et in enumerate(export_templates): @@ -635,6 +638,9 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'q': 'export_filename'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_name(self): params = {'name': ['Export Template 1', 'Export Template 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -649,6 +655,20 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_file_name(self): + params = {'file_name': ['export_filename']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_file_extension(self): + params = {'file_extension': ['nagios']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'file_name': ['export_template_2'], 'file_extension': ['nagios']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'file_name': 'export_filename', 'file_extension': ['nagios']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) + class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ImageAttachment.objects.all() diff --git a/netbox/extras/tests/test_utils.py b/netbox/extras/tests/test_utils.py new file mode 100644 index 000000000..b851acab8 --- /dev/null +++ b/netbox/extras/tests/test_utils.py @@ -0,0 +1,19 @@ +from django.test import TestCase + +from extras.models import ExportTemplate +from extras.utils import filename_from_model +from tenancy.models import ContactGroup, TenantGroup +from wireless.models import WirelessLANGroup + + +class FilenameFromModelTests(TestCase): + def test_expected_output(self): + cases = ( + (ExportTemplate, 'netbox_export_templates'), + (ContactGroup, 'netbox_contact_groups'), + (TenantGroup, 'netbox_tenant_groups'), + (WirelessLANGroup, 'netbox_wireless_lan_groups'), + ) + + for model, expected in cases: + self.assertEqual(filename_from_model(model), expected) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index be8d80b2b..0688cd2c2 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -305,7 +305,7 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): export_templates = ( ExportTemplate(name='Export Template 1', template_code=TEMPLATE_CODE), ExportTemplate(name='Export Template 2', template_code=TEMPLATE_CODE), - ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE), + ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE, file_name='export_template_3') ) ExportTemplate.objects.bulk_create(export_templates) for et in export_templates: @@ -315,13 +315,14 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'name': 'Export Template X', 'object_types': [site_type.pk], 'template_code': TEMPLATE_CODE, + 'file_name': 'template_x', } cls.csv_data = ( - "name,object_types,template_code", - f"Export Template 4,dcim.site,{TEMPLATE_CODE}", - f"Export Template 5,dcim.site,{TEMPLATE_CODE}", - f"Export Template 6,dcim.site,{TEMPLATE_CODE}", + "name,object_types,template_code,file_name", + f"Export Template 4,dcim.site,{TEMPLATE_CODE},", + f"Export Template 5,dcim.site,{TEMPLATE_CODE},template_5", + f"Export Template 6,dcim.site,{TEMPLATE_CODE},", ) cls.csv_update_data = ( diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index efe7ada5b..411d80f78 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -1,6 +1,7 @@ import importlib from django.core.exceptions import ImproperlyConfigured +from django.db import models from taggit.managers import _TaggableManager from netbox.context import current_request @@ -15,6 +16,12 @@ __all__ = ( ) +def filename_from_model(model: models.Model) -> str: + """Standardises how we generate filenames from model class for exports""" + base = model._meta.verbose_name_plural.lower().replace(' ', '_') + return f'netbox_{base}' + + def is_taggable(obj): """ Return True if the instance can have Tags assigned to it; False otherwise. diff --git a/netbox/templates/extras/exporttemplate.html b/netbox/templates/extras/exporttemplate.html index 5a19426f2..f0e370c03 100644 --- a/netbox/templates/extras/exporttemplate.html +++ b/netbox/templates/extras/exporttemplate.html @@ -23,6 +23,10 @@ {% trans "MIME Type" %} {{ object.mime_type|placeholder }}
{% trans "File Name" %}{{ object.file_name|placeholder }}
{% trans "File Extension" %} {{ object.file_extension|placeholder }}
{% trans "Outer Height" %} + {% if object.outer_height %} + {{ object.outer_height }} {{ object.get_outer_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Outer Depth" %} From 1508e3a770380bf2ebf0c9e6aee284057962ca96 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 28 Mar 2025 12:32:02 -0700 Subject: [PATCH 059/103] Fixes #18245: Make DeviceRole Hierarchical (#19008) Made DeviceRoles hierarchical, had to also change the filtersets for Device, ConfigContext and VirtualMachine to use the TreeNodeMultipleChoiceFilter. Note: The model was changed to use NestedGroupModel, a side-effect of this is it also adds comments field, but I thought that was better then doing a one-off just for DeviceRole and having to define the fields, validators, etc.. - keeps everything DRY / consistent. * 18981 Make Device Roles Hierarchical * 18981 forms, serializer * 18981 fix tests * 18981 fix tests * 18981 fix tests * 18981 fix tests * 18981 fix tests * 18981 fix migration merge * 18981 fix tests * 18981 fix filtersets * 18981 fix tests * 18981 comments * 18981 review changes --- docs/models/dcim/devicerole.md | 4 + netbox/dcim/api/serializers_/nested.py | 7 ++ netbox/dcim/api/serializers_/roles.py | 13 ++- netbox/dcim/filtersets.py | 33 +++++- netbox/dcim/forms/bulk_edit.py | 10 +- netbox/dcim/forms/bulk_import.py | 14 ++- netbox/dcim/forms/filtersets.py | 5 + netbox/dcim/forms/model_forms.py | 11 +- netbox/dcim/graphql/types.py | 2 + .../migrations/0203_device_role_nested.py | 65 +++++++++++ .../migrations/0204_device_role_rebuild.py | 22 ++++ netbox/dcim/models/devices.py | 6 +- netbox/dcim/tables/devices.py | 2 +- netbox/dcim/tests/test_api.py | 16 +-- netbox/dcim/tests/test_filtersets.py | 110 +++++++++++++++--- netbox/dcim/tests/test_models.py | 3 +- netbox/dcim/tests/test_views.py | 13 ++- netbox/extras/filtersets.py | 4 +- netbox/extras/forms/filtersets.py | 4 +- netbox/extras/tests/test_filtersets.py | 3 +- netbox/templates/dcim/devicerole.html | 18 +++ netbox/utilities/tests/test_filters.py | 3 +- netbox/virtualization/filtersets.py | 8 +- .../virtualization/tests/test_filtersets.py | 3 +- netbox/virtualization/tests/test_views.py | 3 +- 25 files changed, 327 insertions(+), 55 deletions(-) create mode 100644 netbox/dcim/migrations/0203_device_role_nested.py create mode 100644 netbox/dcim/migrations/0204_device_role_rebuild.py diff --git a/docs/models/dcim/devicerole.md b/docs/models/dcim/devicerole.md index 786170f2b..e58373565 100644 --- a/docs/models/dcim/devicerole.md +++ b/docs/models/dcim/devicerole.md @@ -4,6 +4,10 @@ Devices can be organized by functional roles, which are fully customizable by th ## Fields +### Parent + +The parent role of which this role is a child (optional). + ### Name A unique human-friendly name. diff --git a/netbox/dcim/api/serializers_/nested.py b/netbox/dcim/api/serializers_/nested.py index ea346cc63..0e9eaa52f 100644 --- a/netbox/dcim/api/serializers_/nested.py +++ b/netbox/dcim/api/serializers_/nested.py @@ -52,6 +52,13 @@ class NestedLocationSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'rack_count', '_depth'] +class NestedDeviceRoleSerializer(WritableNestedSerializer): + + class Meta: + model = models.DeviceRole + fields = ['id', 'url', 'display_url', 'display', 'name'] + + class NestedDeviceSerializer(WritableNestedSerializer): class Meta: diff --git a/netbox/dcim/api/serializers_/roles.py b/netbox/dcim/api/serializers_/roles.py index 8f922da10..17eeaa949 100644 --- a/netbox/dcim/api/serializers_/roles.py +++ b/netbox/dcim/api/serializers_/roles.py @@ -1,7 +1,8 @@ from dcim.models import DeviceRole, InventoryItemRole from extras.api.serializers_.configtemplates import ConfigTemplateSerializer from netbox.api.fields import RelatedObjectCountField -from netbox.api.serializers import NetBoxModelSerializer +from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer +from .nested import NestedDeviceRoleSerializer __all__ = ( 'DeviceRoleSerializer', @@ -9,7 +10,8 @@ __all__ = ( ) -class DeviceRoleSerializer(NetBoxModelSerializer): +class DeviceRoleSerializer(NestedGroupModelSerializer): + parent = NestedDeviceRoleSerializer(required=False, allow_null=True, default=None) config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None) # Related object counts @@ -19,10 +21,13 @@ class DeviceRoleSerializer(NetBoxModelSerializer): class Meta: model = DeviceRole fields = [ - 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', + 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'parent', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', + 'comments', '_depth', ] - brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count') + brief_fields = ( + 'id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count', '_depth' + ) class InventoryItemRoleSerializer(NetBoxModelSerializer): diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 81c92d759..2c8b77f57 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -922,6 +922,29 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet): queryset=ConfigTemplate.objects.all(), label=_('Config template (ID)'), ) + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=DeviceRole.objects.all(), + label=_('Parent device role (ID)'), + ) + parent = django_filters.ModelMultipleChoiceFilter( + field_name='parent__slug', + queryset=DeviceRole.objects.all(), + to_field_name='slug', + label=_('Parent device role (slug)'), + ) + ancestor_id = TreeNodeMultipleChoiceFilter( + queryset=DeviceRole.objects.all(), + field_name='parent', + lookup_expr='in', + label=_('Parent device role (ID)'), + ) + ancestor = TreeNodeMultipleChoiceFilter( + queryset=DeviceRole.objects.all(), + field_name='parent', + lookup_expr='in', + to_field_name='slug', + label=_('Parent device role (slug)'), + ) class Meta: model = DeviceRole @@ -990,14 +1013,16 @@ class DeviceFilterSet( queryset=DeviceType.objects.all(), label=_('Device type (ID)'), ) - role_id = django_filters.ModelMultipleChoiceFilter( - field_name='role_id', + role_id = TreeNodeMultipleChoiceFilter( + field_name='role', queryset=DeviceRole.objects.all(), + lookup_expr='in', label=_('Role (ID)'), ) - role = django_filters.ModelMultipleChoiceFilter( - field_name='role__slug', + role = TreeNodeMultipleChoiceFilter( queryset=DeviceRole.objects.all(), + field_name='role', + lookup_expr='in', to_field_name='slug', label=_('Role (slug)'), ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 31f6fd9df..a77c7fa9c 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -620,6 +620,11 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): + parent = DynamicModelChoiceField( + label=_('Parent'), + queryset=DeviceRole.objects.all(), + required=False, + ) color = ColorField( label=_('Color'), required=False @@ -639,12 +644,13 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = DeviceRole fieldsets = ( - FieldSet('color', 'vm_role', 'config_template', 'description'), + FieldSet('parent', 'color', 'vm_role', 'config_template', 'description'), ) - nullable_fields = ('color', 'config_template', 'description') + nullable_fields = ('parent', 'color', 'config_template', 'description', 'comments') class PlatformBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 708bc7618..081e9d41d 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -460,6 +460,16 @@ class ModuleTypeImportForm(NetBoxModelImportForm): class DeviceRoleImportForm(NetBoxModelImportForm): + parent = CSVModelChoiceField( + label=_('Parent'), + queryset=DeviceRole.objects.all(), + required=False, + to_field_name='name', + help_text=_('Parent Device Role'), + error_messages={ + 'invalid_choice': _('Device role not found.'), + } + ) config_template = CSVModelChoiceField( label=_('Config template'), queryset=ConfigTemplate.objects.all(), @@ -471,7 +481,9 @@ class DeviceRoleImportForm(NetBoxModelImportForm): class Meta: model = DeviceRole - fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags') + fields = ( + 'name', 'slug', 'parent', 'color', 'vm_role', 'config_template', 'description', 'comments', 'tags' + ) class PlatformImportForm(NetBoxModelImportForm): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index d794c6893..b70661348 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -689,6 +689,11 @@ class DeviceRoleFilterForm(NetBoxModelFilterSetForm): required=False, label=_('Config template') ) + parent_id = DynamicModelMultipleChoiceField( + queryset=DeviceRole.objects.all(), + required=False, + label=_('Parent') + ) tag = TagFilterField(model) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 8fa7e153f..2829ac754 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -431,17 +431,24 @@ class DeviceRoleForm(NetBoxModelForm): required=False ) slug = SlugField() + parent = DynamicModelChoiceField( + label=_('Parent'), + queryset=DeviceRole.objects.all(), + required=False, + ) + comments = CommentField() fieldsets = ( FieldSet( - 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags', name=_('Device Role') + 'name', 'slug', 'parent', 'color', 'vm_role', 'config_template', 'description', + 'tags', name=_('Device Role') ), ) class Meta: model = DeviceRole fields = [ - 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags', + 'name', 'slug', 'parent', 'color', 'vm_role', 'config_template', 'description', 'comments', 'tags', ] diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 9554a9f60..24fa16263 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -338,6 +338,8 @@ class InventoryItemTemplateType(ComponentTemplateType): pagination=True ) class DeviceRoleType(OrganizationalObjectType): + parent: Annotated['DeviceRoleType', strawberry.lazy('dcim.graphql.types')] | None + children: List[Annotated['DeviceRoleType', strawberry.lazy('dcim.graphql.types')]] color: str config_template: Annotated["ConfigTemplateType", strawberry.lazy('extras.graphql.types')] | None diff --git a/netbox/dcim/migrations/0203_device_role_nested.py b/netbox/dcim/migrations/0203_device_role_nested.py new file mode 100644 index 000000000..c9dd791b3 --- /dev/null +++ b/netbox/dcim/migrations/0203_device_role_nested.py @@ -0,0 +1,65 @@ +# Generated by Django 5.1.7 on 2025-03-25 18:06 + +import django.db.models.manager +import mptt.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0203_add_rack_outer_height'), + ] + + operations = [ + migrations.AddField( + model_name='devicerole', + name='level', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='devicerole', + name='lft', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='devicerole', + name='rght', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='devicerole', + name='tree_id', + field=models.PositiveIntegerField(db_index=True, default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='devicerole', + name='parent', + field=mptt.fields.TreeForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='children', + to='dcim.devicerole', + ), + ), + migrations.AddField( + model_name='devicerole', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AlterField( + model_name='devicerole', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='devicerole', + name='slug', + field=models.SlugField(max_length=100), + ), + ] diff --git a/netbox/dcim/migrations/0204_device_role_rebuild.py b/netbox/dcim/migrations/0204_device_role_rebuild.py new file mode 100644 index 000000000..69837c522 --- /dev/null +++ b/netbox/dcim/migrations/0204_device_role_rebuild.py @@ -0,0 +1,22 @@ +from django.db import migrations +import mptt +import mptt.managers + + +def rebuild_mptt(apps, schema_editor): + manager = mptt.managers.TreeManager() + DeviceRole = apps.get_model('dcim', 'DeviceRole') + manager.model = DeviceRole + mptt.register(DeviceRole) + manager.contribute_to_class(DeviceRole, 'objects') + manager.rebuild() + + +class Migration(migrations.Migration): + dependencies = [ + ('dcim', '0203_device_role_nested'), + ] + + operations = [ + migrations.RunPython(code=rebuild_mptt, reverse_code=migrations.RunPython.noop), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 2acd98801..76269f5c9 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -23,7 +23,7 @@ from extras.models import ConfigContextModel, CustomField from extras.querysets import ConfigContextModelQuerySet from netbox.choices import ColorChoices from netbox.config import ConfigItem -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel from netbox.models.mixins import WeightMixin from netbox.models.features import ContactsMixin, ImageAttachmentsMixin from utilities.fields import ColorField, CounterCacheField @@ -468,7 +468,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): # Devices # -class DeviceRole(OrganizationalModel): +class DeviceRole(NestedGroupModel): """ Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to @@ -491,6 +491,8 @@ class DeviceRole(OrganizationalModel): null=True ) + clone_fields = ('parent', 'description') + class Meta: ordering = ('name',) verbose_name = _('device role') diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 06f6469d3..5cfb31750 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -59,7 +59,7 @@ MACADDRESS_COPY_BUTTON = """ # class DeviceRoleTable(NetBoxTable): - name = tables.Column( + name = columns.MPTTColumn( verbose_name=_('Name'), linkify=True ) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 807ac77d4..c2a7660c6 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1149,7 +1149,9 @@ class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase): class DeviceRoleTest(APIViewTestCases.APIViewTestCase): model = DeviceRole - brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] + brief_fields = [ + '_depth', 'description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count' + ] create_data = [ { 'name': 'Device Role 4', @@ -1174,12 +1176,9 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): - roles = ( - DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000'), - DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00'), - DeviceRole(name='Device Role 3', slug='device-role-3', color='0000ff'), - ) - DeviceRole.objects.bulk_create(roles) + DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000') + DeviceRole.objects.create(name='Device Role 2', slug='device-role-2', color='00ff00') + DeviceRole.objects.create(name='Device Role 3', slug='device-role-3', color='0000ff') class PlatformTest(APIViewTestCases.APIViewTestCase): @@ -1252,7 +1251,8 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000'), DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index f46391310..b2353b4ba 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2191,12 +2191,65 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): - roles = ( + parent_roles = ( DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000', vm_role=True, description='foobar1'), DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00', vm_role=True, description='foobar2'), - DeviceRole(name='Device Role 3', slug='device-role-3', color='0000ff', vm_role=False), + DeviceRole(name='Device Role 3', slug='device-role-3', color='0000ff', vm_role=False) ) - DeviceRole.objects.bulk_create(roles) + for role in parent_roles: + role.save() + + roles = ( + DeviceRole( + name='Device Role 1A', + slug='device-role-1a', + color='aa0000', + vm_role=True, + parent=parent_roles[0] + ), + DeviceRole( + name='Device Role 2A', + slug='device-role-2a', + color='00aa00', + vm_role=True, + parent=parent_roles[1] + ), + DeviceRole( + name='Device Role 3A', + slug='device-role-3a', + color='0000aa', + vm_role=False, + parent=parent_roles[2] + ) + ) + for role in roles: + role.save() + + child_roles = ( + DeviceRole( + name='Device Role 1A1', + slug='device-role-1a1', + color='bb0000', + vm_role=True, + parent=roles[0] + ), + DeviceRole( + name='Device Role 2A1', + slug='device-role-2a1', + color='00bb00', + vm_role=True, + parent=roles[1] + ), + DeviceRole( + name='Device Role 3A1', + slug='device-role-3a1', + color='0000bb', + vm_role=False, + parent=roles[2] + ) + ) + for role in child_roles: + role.save() def test_q(self): params = {'q': 'foobar1'} @@ -2216,14 +2269,28 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests): def test_vm_role(self): params = {'vm_role': 'true'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) params = {'vm_role': 'false'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) def test_description(self): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_parent(self): + roles = DeviceRole.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [roles[0].pk, roles[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'parent': [roles[0].slug, roles[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_ancestor(self): + roles = DeviceRole.objects.filter(parent__isnull=True)[:2] + params = {'ancestor_id': [roles[0].pk, roles[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'ancestor': [roles[0].slug, roles[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Platform.objects.all() @@ -2309,7 +2376,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() platforms = ( Platform(name='Platform 1', slug='platform-1'), @@ -2974,7 +3042,8 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -3186,7 +3255,8 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -3404,7 +3474,8 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -3648,7 +3719,8 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -3913,7 +3985,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -4492,7 +4565,8 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -4764,7 +4838,8 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -5004,7 +5079,8 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -5176,7 +5252,8 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -5311,7 +5388,8 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() regions = ( Region(name='Region 1', slug='region-1'), diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index bdb07d6d1..66f52b1bf 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -346,7 +346,8 @@ class DeviceTestCase(TestCase): DeviceRole(name='Test Role 1', slug='test-role-1'), DeviceRole(name='Test Role 2', slug='test-role-2'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() # Create a CustomField with a default value & assign it to all component models cf1 = CustomField.objects.create(name='cf1', default='foo') diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 83effa188..0bf8fefb3 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1694,13 +1694,16 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - roles = ( + roles = [ DeviceRole(name='Device Role 1', slug='device-role-1'), DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), - ) - DeviceRole.objects.bulk_create(roles) + DeviceRole(name='Device Role 4', slug='device-role-4'), + ] + for role in roles: + role.save() + roles.append(DeviceRole.objects.create(name='Device Role 5', slug='device-role-5', parent=roles[3])) tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { @@ -1724,6 +1727,7 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): f"{roles[0].pk},Device Role 7,New description7", f"{roles[1].pk},Device Role 8,New description8", f"{roles[2].pk},Device Role 9,New description9", + f"{roles[4].pk},Device Role 10,New description10", ) cls.bulk_edit_data = { @@ -1809,7 +1813,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): DeviceRole(name='Device Role 1', slug='device-role-1'), DeviceRole(name='Device Role 2', slug='device-role-2'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() platforms = ( Platform(name='Platform 1', slug='platform-1'), diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index e63b6d673..8381316cc 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -8,7 +8,9 @@ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from tenancy.models import Tenant, TenantGroup from users.models import Group, User -from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter +from utilities.filters import ( + ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter +) from virtualization.models import Cluster, ClusterGroup, ClusterType from .choices import * from .filters import TagFilter diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 1691559f9..0a50047fe 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -322,7 +322,7 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): FieldSet('q', 'filter_id', 'tag_id'), FieldSet('data_source_id', 'data_file_id', name=_('Data')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), - FieldSet('device_type_id', 'platform_id', 'role_id', name=_('Device')), + FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')), FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')) ) @@ -364,7 +364,7 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): required=False, label=_('Device types') ) - role_id = DynamicModelMultipleChoiceField( + device_role_id = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), required=False, label=_('Roles') diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index ff4543bd2..84d7aad5a 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -904,7 +904,8 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(device_roles) + for device_role in device_roles: + device_role.save() platforms = ( Platform(name='Platform 1', slug='platform-1'), diff --git a/netbox/templates/dcim/devicerole.html b/netbox/templates/dcim/devicerole.html index d9e170af3..3644337e4 100644 --- a/netbox/templates/dcim/devicerole.html +++ b/netbox/templates/dcim/devicerole.html @@ -30,6 +30,10 @@ {% trans "Description" %} {{ object.description|placeholder }}
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
{% trans "Color" %} @@ -52,11 +56,25 @@
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
+
+

+ {% trans "Child Device Roles" %} + {% if perms.dcim.add_devicerole %} + + {% endif %} +

+ {% htmx_table 'dcim:devicerole_list' parent_id=object.pk %} +
{% plugin_full_width_page object %}
diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 6956396d2..1598d3d52 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -391,7 +391,8 @@ class DynamicFilterLookupExpressionTest(TestCase): DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() platforms = ( Platform(name='Platform 1', slug='platform-1'), diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index b031d2bf3..06a38da36 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -171,13 +171,15 @@ class VirtualMachineFilterSet( name = MultiValueCharFilter( lookup_expr='iexact' ) - role_id = django_filters.ModelMultipleChoiceFilter( + role_id = TreeNodeMultipleChoiceFilter( queryset=DeviceRole.objects.all(), + lookup_expr='in', label=_('Role (ID)'), ) - role = django_filters.ModelMultipleChoiceFilter( - field_name='role__slug', + role = TreeNodeMultipleChoiceFilter( + field_name='role', queryset=DeviceRole.objects.all(), + lookup_expr='in', to_field_name='slug', label=_('Role (slug)'), ) diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index eef5d6b52..7fbf0045d 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -294,7 +294,8 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() devices = ( create_test_device('device1', cluster=clusters[0]), diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 3c8d7eadc..27b1a08a5 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -203,7 +203,8 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): DeviceRole(name='Device Role 1', slug='device-role-1'), DeviceRole(name='Device Role 2', slug='device-role-2'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() platforms = ( Platform(name='Platform 1', slug='platform-1'), From 864db469ba31412d82eb24d3ebf537d2bdacac9f Mon Sep 17 00:00:00 2001 From: Renato Almeida de Oliveira Date: Tue, 1 Apr 2025 10:03:25 -0300 Subject: [PATCH 060/103] Fixes: #18305 make contacts mixin available for plugins (#19029) --- docs/plugins/development/models.md | 2 + netbox/circuits/views.py | 16 -------- netbox/dcim/views.py | 41 -------------------- netbox/ipam/views.py | 26 ------------- netbox/netbox/models/features.py | 9 ++++- netbox/netbox/views/generic/feature_views.py | 28 +++++++++++++ netbox/tenancy/views.py | 25 +----------- netbox/virtualization/views.py | 16 -------- netbox/vpn/views.py | 6 --- 9 files changed, 38 insertions(+), 131 deletions(-) diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index 03cedda16..492b7fc97 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -117,6 +117,8 @@ For more information about database migrations, see the [Django documentation](h ::: netbox.models.features.CloningMixin +::: netbox.models.features.ContactsMixin + ::: netbox.models.features.CustomLinksMixin ::: netbox.models.features.CustomFieldsMixin diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 3bd81c33a..644251c35 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -5,7 +5,6 @@ from django.utils.translation import gettext_lazy as _ from dcim.views import PathTraceView from netbox.views import generic -from tenancy.views import ObjectContactsView from utilities.forms import ConfirmationForm from utilities.query import count_related from utilities.views import GetRelatedModelsMixin, register_model_view @@ -74,11 +73,6 @@ class ProviderBulkDeleteView(generic.BulkDeleteView): table = tables.ProviderTable -@register_model_view(Provider, 'contacts') -class ProviderContactsView(ObjectContactsView): - queryset = Provider.objects.all() - - # # ProviderAccounts # @@ -141,11 +135,6 @@ class ProviderAccountBulkDeleteView(generic.BulkDeleteView): table = tables.ProviderAccountTable -@register_model_view(ProviderAccount, 'contacts') -class ProviderAccountContactsView(ObjectContactsView): - queryset = ProviderAccount.objects.all() - - # # Provider networks # @@ -413,11 +402,6 @@ class CircuitSwapTerminations(generic.ObjectEditView): }) -@register_model_view(Circuit, 'contacts') -class CircuitContactsView(ObjectContactsView): - queryset = Circuit.objects.all() - - # # Circuit terminations # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 0978747d1..4b2f22035 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -19,7 +19,6 @@ from ipam.models import ASN, IPAddress, Prefix, VLANGroup from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.views import generic -from tenancy.views import ObjectContactsView from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model @@ -304,11 +303,6 @@ class RegionBulkDeleteView(generic.BulkDeleteView): table = tables.RegionTable -@register_model_view(Region, 'contacts') -class RegionContactsView(ObjectContactsView): - queryset = Region.objects.all() - - # # Site groups # @@ -412,11 +406,6 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView): table = tables.SiteGroupTable -@register_model_view(SiteGroup, 'contacts') -class SiteGroupContactsView(ObjectContactsView): - queryset = SiteGroup.objects.all() - - # # Sites # @@ -494,11 +483,6 @@ class SiteBulkDeleteView(generic.BulkDeleteView): table = tables.SiteTable -@register_model_view(Site, 'contacts') -class SiteContactsView(ObjectContactsView): - queryset = Site.objects.all() - - # # Locations # @@ -596,11 +580,6 @@ class LocationBulkDeleteView(generic.BulkDeleteView): table = tables.LocationTable -@register_model_view(Location, 'contacts') -class LocationContactsView(ObjectContactsView): - queryset = Location.objects.all() - - # # Rack roles # @@ -887,11 +866,6 @@ class RackBulkDeleteView(generic.BulkDeleteView): table = tables.RackTable -@register_model_view(Rack, 'contacts') -class RackContactsView(ObjectContactsView): - queryset = Rack.objects.all() - - # # Rack reservations # @@ -1029,11 +1003,6 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView): table = tables.ManufacturerTable -@register_model_view(Manufacturer, 'contacts') -class ManufacturerContactsView(ObjectContactsView): - queryset = Manufacturer.objects.all() - - # # Device types # @@ -2360,11 +2329,6 @@ class DeviceBulkRenameView(generic.BulkRenameView): table = tables.DeviceTable -@register_model_view(Device, 'contacts') -class DeviceContactsView(ObjectContactsView): - queryset = Device.objects.all() - - # # Modules # @@ -3924,11 +3888,6 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView): table = tables.PowerPanelTable -@register_model_view(PowerPanel, 'contacts') -class PowerPanelContactsView(ObjectContactsView): - queryset = PowerPanel.objects.all() - - # # Power feeds # diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 007a652ca..3dde80b30 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -11,7 +11,6 @@ from dcim.forms import InterfaceFilterForm from dcim.models import Interface, Site from ipam.tables import VLANTranslationRuleTable from netbox.views import generic -from tenancy.views import ObjectContactsView from utilities.query import count_related from utilities.tables import get_table_ordering from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view @@ -434,11 +433,6 @@ class AggregateBulkDeleteView(generic.BulkDeleteView): table = tables.AggregateTable -@register_model_view(Aggregate, 'contacts') -class AggregateContactsView(ObjectContactsView): - queryset = Aggregate.objects.all() - - # # Prefix/VLAN roles # @@ -684,11 +678,6 @@ class PrefixBulkDeleteView(generic.BulkDeleteView): table = tables.PrefixTable -@register_model_view(Prefix, 'contacts') -class PrefixContactsView(ObjectContactsView): - queryset = Prefix.objects.all() - - # # IP Ranges # @@ -778,11 +767,6 @@ class IPRangeBulkDeleteView(generic.BulkDeleteView): table = tables.IPRangeTable -@register_model_view(IPRange, 'contacts') -class IPRangeContactsView(ObjectContactsView): - queryset = IPRange.objects.all() - - # # IP addresses # @@ -964,11 +948,6 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView): return parent.get_related_ips().restrict(request.user, 'view') -@register_model_view(IPAddress, 'contacts') -class IPAddressContactsView(ObjectContactsView): - queryset = IPAddress.objects.all() - - # # VLAN groups # @@ -1476,8 +1455,3 @@ class ServiceBulkDeleteView(generic.BulkDeleteView): queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filtersets.ServiceFilterSet table = tables.ServiceTable - - -@register_model_view(Service, 'contacts') -class ServiceContactsView(ObjectContactsView): - queryset = Service.objects.all() diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index a2fb8d615..d14fdb17f 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -353,7 +353,7 @@ class ImageAttachmentsMixin(models.Model): class ContactsMixin(models.Model): """ - Enables the assignments of Contacts (via ContactAssignment). + Enables the assignment of Contacts to a model (via ContactAssignment). """ contacts = GenericRelation( to='tenancy.ContactAssignment', @@ -368,7 +368,8 @@ class ContactsMixin(models.Model): """ Return a `QuerySet` matching all contacts assigned to this object. - :param inherited: If `True`, inherited contacts from parent objects are included. + Args: + inherited: If `True`, inherited contacts from parent objects are included. """ from tenancy.models import ContactAssignment from . import NestedGroupModel @@ -659,6 +660,10 @@ def register_models(*models): ) # Register applicable feature views for the model + if issubclass(model, ContactsMixin): + register_model_view(model, 'contacts', kwargs={'model': model})( + 'netbox.views.generic.ObjectContactsView' + ) if issubclass(model, JournalingMixin): register_model_view(model, 'journal', kwargs={'model': model})( 'netbox.views.generic.ObjectJournalView' diff --git a/netbox/netbox/views/generic/feature_views.py b/netbox/netbox/views/generic/feature_views.py index 1e17d5354..63bbc86a5 100644 --- a/netbox/netbox/views/generic/feature_views.py +++ b/netbox/netbox/views/generic/feature_views.py @@ -12,13 +12,19 @@ from core.tables import JobTable, ObjectChangeTable from extras.forms import JournalEntryForm from extras.models import JournalEntry from extras.tables import JournalEntryTable +from tenancy.models import ContactAssignment +from tenancy.tables import ContactAssignmentTable +from tenancy.filtersets import ContactAssignmentFilterSet +from tenancy.forms import ContactAssignmentFilterForm from utilities.permissions import get_permission_for_model from utilities.views import ConditionalLoginRequiredMixin, GetReturnURLMixin, ViewTab from .base import BaseMultiObjectView +from .object_views import ObjectChildrenView __all__ = ( 'BulkSyncDataView', 'ObjectChangeLogView', + 'ObjectContactsView', 'ObjectJobsView', 'ObjectJournalView', 'ObjectSyncDataView', @@ -244,3 +250,25 @@ class BulkSyncDataView(GetReturnURLMixin, BaseMultiObjectView): )) return redirect(self.get_return_url(request)) + + +class ObjectContactsView(ObjectChildrenView): + child_model = ContactAssignment + table = ContactAssignmentTable + filterset = ContactAssignmentFilterSet + filterset_form = ContactAssignmentFilterForm + template_name = 'tenancy/object_contacts.html' + tab = ViewTab( + label=_('Contacts'), + badge=lambda obj: obj.get_contacts().count(), + permission='tenancy.view_contactassignment', + weight=5000 + ) + + def dispatch(self, request, *args, **kwargs): + model = kwargs.pop('model') + self.queryset = model.objects.all() + return super().dispatch(request, *args, **kwargs) + + def get_children(self, request, parent): + return parent.get_contacts().restrict(request.user, 'view').order_by('priority', 'contact', 'role') diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index d0c80b76f..dd584d745 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -1,31 +1,13 @@ from django.contrib.contenttypes.models import ContentType from django.shortcuts import get_object_or_404 -from django.utils.translation import gettext_lazy as _ from netbox.views import generic from utilities.query import count_related -from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view +from utilities.views import GetRelatedModelsMixin, register_model_view from . import filtersets, forms, tables from .models import * -class ObjectContactsView(generic.ObjectChildrenView): - child_model = ContactAssignment - table = tables.ContactAssignmentTable - filterset = filtersets.ContactAssignmentFilterSet - filterset_form = forms.ContactAssignmentFilterForm - template_name = 'tenancy/object_contacts.html' - tab = ViewTab( - label=_('Contacts'), - badge=lambda obj: obj.get_contacts().count(), - permission='tenancy.view_contactassignment', - weight=5000 - ) - - def get_children(self, request, parent): - return parent.get_contacts().restrict(request.user, 'view').order_by('priority', 'contact', 'role') - - # # Tenant groups # @@ -156,11 +138,6 @@ class TenantBulkDeleteView(generic.BulkDeleteView): table = tables.TenantTable -@register_model_view(Tenant, 'contacts') -class TenantContactsView(ObjectContactsView): - queryset = Tenant.objects.all() - - # # Contact groups # diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 7682d0fc8..e76f2d52f 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -16,7 +16,6 @@ from ipam.models import IPAddress from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.views import generic -from tenancy.views import ObjectContactsView from utilities.query import count_related from utilities.query_functions import CollateAsChar from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view @@ -148,11 +147,6 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView): table = tables.ClusterGroupTable -@register_model_view(ClusterGroup, 'contacts') -class ClusterGroupContactsView(ObjectContactsView): - queryset = ClusterGroup.objects.all() - - # # Clusters # @@ -344,11 +338,6 @@ class ClusterRemoveDevicesView(generic.ObjectEditView): }) -@register_model_view(Cluster, 'contacts') -class ClusterContactsView(ObjectContactsView): - queryset = Cluster.objects.all() - - # # Virtual machines # @@ -509,11 +498,6 @@ class VirtualMachineBulkDeleteView(generic.BulkDeleteView): table = tables.VirtualMachineTable -@register_model_view(VirtualMachine, 'contacts') -class VirtualMachineContactsView(ObjectContactsView): - queryset = VirtualMachine.objects.all() - - # # VM interfaces # diff --git a/netbox/vpn/views.py b/netbox/vpn/views.py index 3372e9412..8206f4541 100644 --- a/netbox/vpn/views.py +++ b/netbox/vpn/views.py @@ -1,6 +1,5 @@ from ipam.tables import RouteTargetTable from netbox.views import generic -from tenancy.views import ObjectContactsView from utilities.query import count_related from utilities.views import GetRelatedModelsMixin, register_model_view from . import filtersets, forms, tables @@ -497,11 +496,6 @@ class L2VPNBulkDeleteView(generic.BulkDeleteView): table = tables.L2VPNTable -@register_model_view(L2VPN, 'contacts') -class L2VPNContactsView(ObjectContactsView): - queryset = L2VPN.objects.all() - - # # L2VPN terminations # From 8d7889e2c00e44689fe9efe66da0422b6669ba9b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 1 Apr 2025 13:05:06 -0400 Subject: [PATCH 061/103] Closes #19002: Module type profiles (#19014) * Move Module & ModuleType models to a separate file * Add ModuleTypeProfile & related fields * Initial work on JSON schema validation * Add attributes property on ModuleType * Introduce MultipleOfValidator * Introduce JSONSchemaProperty * Enable dynamic form field rendering * Misc cleanup * Fix migration conflict * Ensure deterministic ordering of attriubte fields * Support choices & default values * Include module type attributes on module view * Enable modifying individual attributes via REST API * Enable filtering by attribute values * Add documentation & tests * Schema should be optional * Include attributes column for profiles * Profile is nullable * Include some initial profiles to be installed via migration * Fix migrations conflict * Fix filterset test * Misc cleanup * Fixes #19023: get_field_value() should respect null values in bound forms (#19024) * Skip filters which do not specify a JSON-serializable value * Fix handling of array item types * Fix initial data in schema field during bulk edit * Implement sanity checking for JSON schema definitions * Fall back to filtering by string value --- base_requirements.txt | 4 + docs/models/dcim/moduletype.md | 8 + docs/models/dcim/moduletypeprofile.md | 40 ++ netbox/dcim/api/serializers_/devicetypes.py | 34 +- netbox/dcim/api/urls.py | 1 + netbox/dcim/api/views.py | 6 + netbox/dcim/filtersets.py | 31 +- netbox/dcim/forms/bulk_edit.py | 33 +- netbox/dcim/forms/bulk_import.py | 16 + netbox/dcim/forms/filtersets.py | 16 +- netbox/dcim/forms/model_forms.py | 92 ++++- netbox/dcim/graphql/filters.py | 6 + netbox/dcim/graphql/schema.py | 3 + netbox/dcim/graphql/types.py | 12 + .../dcim/migrations/0205_moduletypeprofile.py | 57 +++ .../0206_load_module_type_profiles.py | 42 ++ .../module_type_profiles/cpu.json | 20 + .../module_type_profiles/fan.json | 12 + .../module_type_profiles/gpu.json | 28 ++ .../module_type_profiles/hard_disk.json | 29 ++ .../module_type_profiles/memory.json | 36 ++ .../module_type_profiles/power_supply.json | 34 ++ netbox/dcim/models/__init__.py | 1 + netbox/dcim/models/devices.py | 282 +------------- netbox/dcim/models/modules.py | 360 ++++++++++++++++++ netbox/dcim/search.py | 11 + netbox/dcim/tables/modules.py | 60 ++- netbox/dcim/tables/template_code.py | 4 + netbox/dcim/tests/test_api.py | 66 +++- netbox/dcim/tests/test_filtersets.py | 128 ++++++- netbox/dcim/tests/test_views.py | 74 ++++ netbox/dcim/urls.py | 3 + netbox/dcim/utils.py | 20 + netbox/dcim/views.py | 56 +++ netbox/extras/tests/test_filtersets.py | 1 + netbox/netbox/api/fields.py | 17 + netbox/netbox/filtersets.py | 32 ++ netbox/netbox/navigation/menu.py | 1 + netbox/netbox/tables/columns.py | 12 + netbox/templates/dcim/module.html | 25 +- netbox/templates/dcim/moduletype.html | 25 ++ netbox/templates/dcim/moduletypeprofile.html | 59 +++ netbox/utilities/forms/utils.py | 8 +- netbox/utilities/jsonschema.py | 166 ++++++++ netbox/utilities/tests/test_forms.py | 63 ++- netbox/utilities/validators.py | 18 + requirements.txt | 1 + 47 files changed, 1732 insertions(+), 321 deletions(-) create mode 100644 docs/models/dcim/moduletypeprofile.md create mode 100644 netbox/dcim/migrations/0205_moduletypeprofile.py create mode 100644 netbox/dcim/migrations/0206_load_module_type_profiles.py create mode 100644 netbox/dcim/migrations/initial_data/module_type_profiles/cpu.json create mode 100644 netbox/dcim/migrations/initial_data/module_type_profiles/fan.json create mode 100644 netbox/dcim/migrations/initial_data/module_type_profiles/gpu.json create mode 100644 netbox/dcim/migrations/initial_data/module_type_profiles/hard_disk.json create mode 100644 netbox/dcim/migrations/initial_data/module_type_profiles/memory.json create mode 100644 netbox/dcim/migrations/initial_data/module_type_profiles/power_supply.json create mode 100644 netbox/dcim/models/modules.py create mode 100644 netbox/templates/dcim/moduletypeprofile.html create mode 100644 netbox/utilities/jsonschema.py diff --git a/base_requirements.txt b/base_requirements.txt index 6921f2d49..7eaa9d928 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -82,6 +82,10 @@ gunicorn # https://jinja.palletsprojects.com/changes/ Jinja2 +# JSON schema validation +# https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst +jsonschema + # Simple markup language for rendering HTML # https://python-markdown.github.io/changelog/ Markdown diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md index 7077e16c2..88f04466a 100644 --- a/docs/models/dcim/moduletype.md +++ b/docs/models/dcim/moduletype.md @@ -43,3 +43,11 @@ The numeric weight of the module, including a unit designation (e.g. 3 kilograms ### Airflow The direction in which air circulates through the device chassis for cooling. + +### Profile + +The assigned [profile](./moduletypeprofile.md) for the type of module. Profiles can be used to classify module types by function (e.g. power supply, hard disk, etc.), and they support the addition of user-configurable attributes on module types. The assignment of a module type to a profile is optional. + +### Attributes + +Depending on the module type's assigned [profile](./moduletypeprofile.md) (if any), one or more user-defined attributes may be available to configure. diff --git a/docs/models/dcim/moduletypeprofile.md b/docs/models/dcim/moduletypeprofile.md new file mode 100644 index 000000000..80345c82b --- /dev/null +++ b/docs/models/dcim/moduletypeprofile.md @@ -0,0 +1,40 @@ +# Module Type Profiles + +!!! info "This model was introduced in NetBox v4.3." + +Each [module type](./moduletype.md) may optionally be assigned a profile according to its classification. A profile can extend module types with user-configured attributes. For example, you might want to specify the input current and voltage of a power supply, or the clock speed and number of cores for a processor. + +Module type attributes are managed via the configuration of a [JSON schema](https://json-schema.org/) on the profile. For example, the following schema introduces three module type attributes, two of which are designated as required attributes. + +```json +{ + "properties": { + "type": { + "type": "string", + "title": "Disk type", + "enum": ["HD", "SSD", "NVME"], + "default": "HD" + }, + "capacity": { + "type": "integer", + "title": "Capacity (GB)", + "description": "Gross disk size" + }, + "speed": { + "type": "integer", + "title": "Speed (RPM)" + } + }, + "required": [ + "type", "capacity" + ] +} +``` + +The assignment of module types to a profile is optional. The designation of a schema for a profile is also optional: A profile can be used simply as a mechanism for classifying module types if the addition of custom attributes is not needed. + +## Fields + +### Schema + +This field holds the [JSON schema](https://json-schema.org/) for the profile. The configured JSON schema must be valid (or the field must be null). diff --git a/netbox/dcim/api/serializers_/devicetypes.py b/netbox/dcim/api/serializers_/devicetypes.py index 0ce2af2f8..61e3833ec 100644 --- a/netbox/dcim/api/serializers_/devicetypes.py +++ b/netbox/dcim/api/serializers_/devicetypes.py @@ -4,8 +4,8 @@ from django.utils.translation import gettext as _ from rest_framework import serializers from dcim.choices import * -from dcim.models import DeviceType, ModuleType -from netbox.api.fields import ChoiceField, RelatedObjectCountField +from dcim.models import DeviceType, ModuleType, ModuleTypeProfile +from netbox.api.fields import AttributesField, ChoiceField, RelatedObjectCountField from netbox.api.serializers import NetBoxModelSerializer from netbox.choices import * from .manufacturers import ManufacturerSerializer @@ -13,6 +13,7 @@ from .platforms import PlatformSerializer __all__ = ( 'DeviceTypeSerializer', + 'ModuleTypeProfileSerializer', 'ModuleTypeSerializer', ) @@ -62,7 +63,23 @@ class DeviceTypeSerializer(NetBoxModelSerializer): brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count') +class ModuleTypeProfileSerializer(NetBoxModelSerializer): + + class Meta: + model = ModuleTypeProfile + fields = [ + 'id', 'url', 'display_url', 'display', 'name', 'description', 'schema', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + class ModuleTypeSerializer(NetBoxModelSerializer): + profile = ModuleTypeProfileSerializer( + nested=True, + required=False, + allow_null=True + ) manufacturer = ManufacturerSerializer( nested=True ) @@ -78,12 +95,17 @@ class ModuleTypeSerializer(NetBoxModelSerializer): required=False, allow_null=True ) + attributes = AttributesField( + source='attribute_data', + required=False, + allow_null=True + ) class Meta: model = ModuleType fields = [ - 'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'part_number', 'airflow', - 'weight', 'weight_unit', 'description', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', + 'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow', + 'weight', 'weight_unit', 'description', 'attributes', 'comments', 'tags', 'custom_fields', 'created', + 'last_updated', ] - brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'description') + brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description') diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index fc3740374..734ac13db 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -21,6 +21,7 @@ router.register('rack-reservations', views.RackReservationViewSet) router.register('manufacturers', views.ManufacturerViewSet) router.register('device-types', views.DeviceTypeViewSet) router.register('module-types', views.ModuleTypeViewSet) +router.register('module-type-profiles', views.ModuleTypeProfileViewSet) # Device type components router.register('console-port-templates', views.ConsolePortTemplateViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index d7dbbef91..575ee770f 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -269,6 +269,12 @@ class DeviceTypeViewSet(NetBoxModelViewSet): filterset_class = filtersets.DeviceTypeFilterSet +class ModuleTypeProfileViewSet(NetBoxModelViewSet): + queryset = ModuleTypeProfile.objects.all() + serializer_class = serializers.ModuleTypeProfileSerializer + filterset_class = filtersets.ModuleTypeProfileFilterSet + + class ModuleTypeViewSet(NetBoxModelViewSet): queryset = ModuleType.objects.all() serializer_class = serializers.ModuleTypeSerializer diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 2c8b77f57..fed660c1f 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -11,7 +11,7 @@ from ipam.filtersets import PrimaryIPFilterSet from ipam.models import ASN, IPAddress, VLANTranslationPolicy, VRF from netbox.choices import ColorChoices from netbox.filtersets import ( - BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet, + AttributeFiltersMixin, BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet, ) from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet @@ -59,6 +59,7 @@ __all__ = ( 'ModuleBayTemplateFilterSet', 'ModuleFilterSet', 'ModuleTypeFilterSet', + 'ModuleTypeProfileFilterSet', 'PathEndpointFilterSet', 'PlatformFilterSet', 'PowerConnectionFilterSet', @@ -674,7 +675,33 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): return queryset.exclude(inventoryitemtemplates__isnull=value) -class ModuleTypeFilterSet(NetBoxModelFilterSet): +class ModuleTypeProfileFilterSet(NetBoxModelFilterSet): + + class Meta: + model = ModuleTypeProfile + fields = ('id', 'name', 'description') + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) | + Q(comments__icontains=value) + ) + + +class ModuleTypeFilterSet(AttributeFiltersMixin, NetBoxModelFilterSet): + profile_id = django_filters.ModelMultipleChoiceFilter( + queryset=ModuleTypeProfile.objects.all(), + label=_('Profile (ID)'), + ) + profile = django_filters.ModelMultipleChoiceFilter( + field_name='profile__name', + queryset=ModuleTypeProfile.objects.all(), + to_field_name='name', + label=_('Profile (name)'), + ) manufacturer_id = django_filters.ModelMultipleChoiceFilter( queryset=Manufacturer.objects.all(), label=_('Manufacturer (ID)'), diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index a77c7fa9c..dd6c4a791 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -14,7 +14,9 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from users.models import User from utilities.forms import BulkEditForm, add_blank_choice, form_from_model -from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.fields import ( + ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, +) from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions from virtualization.models import Cluster @@ -46,6 +48,7 @@ __all__ = ( 'ModuleBayBulkEditForm', 'ModuleBayTemplateBulkEditForm', 'ModuleTypeBulkEditForm', + 'ModuleTypeProfileBulkEditForm', 'PlatformBulkEditForm', 'PowerFeedBulkEditForm', 'PowerOutletBulkEditForm', @@ -574,7 +577,31 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments') +class ModuleTypeProfileBulkEditForm(NetBoxModelBulkEditForm): + schema = JSONField( + label=_('Schema'), + required=False + ) + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + comments = CommentField() + + model = ModuleTypeProfile + fieldsets = ( + FieldSet('name', 'description', 'schema', name=_('Profile')), + ) + nullable_fields = ('description', 'comments') + + class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): + profile = DynamicModelChoiceField( + label=_('Profile'), + queryset=ModuleTypeProfile.objects.all(), + required=False + ) manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), queryset=Manufacturer.objects.all(), @@ -609,14 +636,14 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): model = ModuleType fieldsets = ( - FieldSet('manufacturer', 'part_number', 'description', name=_('Module Type')), + FieldSet('profile', 'manufacturer', 'part_number', 'description', name=_('Module Type')), FieldSet( 'airflow', InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), name=_('Chassis') ), ) - nullable_fields = ('part_number', 'weight', 'weight_unit', 'description', 'comments') + nullable_fields = ('part_number', 'weight', 'weight_unit', 'profile', 'description', 'comments') class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 081e9d41d..d412694b3 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -39,6 +39,7 @@ __all__ = ( 'ModuleImportForm', 'ModuleBayImportForm', 'ModuleTypeImportForm', + 'ModuleTypeProfileImportForm', 'PlatformImportForm', 'PowerFeedImportForm', 'PowerOutletImportForm', @@ -427,7 +428,22 @@ class DeviceTypeImportForm(NetBoxModelImportForm): ] +class ModuleTypeProfileImportForm(NetBoxModelImportForm): + + class Meta: + model = ModuleTypeProfile + fields = [ + 'name', 'description', 'schema', 'comments', 'tags', + ] + + class ModuleTypeImportForm(NetBoxModelImportForm): + profile = forms.ModelChoiceField( + label=_('Profile'), + queryset=ModuleTypeProfile.objects.all(), + to_field_name='name', + required=False + ) manufacturer = forms.ModelChoiceField( label=_('Manufacturer'), queryset=Manufacturer.objects.all(), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index b70661348..8465f6404 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -39,6 +39,7 @@ __all__ = ( 'ModuleFilterForm', 'ModuleBayFilterForm', 'ModuleTypeFilterForm', + 'ModuleTypeProfileFilterForm', 'PlatformFilterForm', 'PowerConnectionFilterForm', 'PowerFeedFilterForm', @@ -602,11 +603,19 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): ) +class ModuleTypeProfileFilterForm(NetBoxModelFilterSetForm): + model = ModuleTypeProfile + fieldsets = ( + FieldSet('q', 'filter_id', 'tag'), + ) + selector_fields = ('filter_id', 'q') + + class ModuleTypeFilterForm(NetBoxModelFilterSetForm): model = ModuleType fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('manufacturer_id', 'part_number', 'airflow', name=_('Hardware')), + FieldSet('profile_id', 'manufacturer_id', 'part_number', 'airflow', name=_('Hardware')), FieldSet( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', name=_('Components') @@ -614,6 +623,11 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm): FieldSet('weight', 'weight_unit', name=_('Weight')), ) selector_fields = ('filter_id', 'q', 'manufacturer_id') + profile_id = DynamicModelMultipleChoiceField( + queryset=ModuleTypeProfile.objects.all(), + required=False, + label=_('Profile') + ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 2829ac754..ac9b0ea9a 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.core.validators import EMPTY_VALUES from django.utils.translation import gettext_lazy as _ from timezone_field import TimeZoneFormField @@ -18,6 +19,7 @@ from utilities.forms.fields import ( ) from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK +from utilities.jsonschema import JSONSchemaProperty from virtualization.models import Cluster, VMInterface from wireless.models import WirelessLAN, WirelessLANGroup from .common import InterfaceCommonForm, ModuleCommonForm @@ -48,6 +50,7 @@ __all__ = ( 'ModuleBayForm', 'ModuleBayTemplateForm', 'ModuleTypeForm', + 'ModuleTypeProfileForm', 'PlatformForm', 'PopulateDeviceBayForm', 'PowerFeedForm', @@ -404,25 +407,104 @@ class DeviceTypeForm(NetBoxModelForm): } +class ModuleTypeProfileForm(NetBoxModelForm): + schema = JSONField( + label=_('Schema'), + required=False, + help_text=_("Enter a valid JSON schema to define supported attributes.") + ) + comments = CommentField() + + fieldsets = ( + FieldSet('name', 'description', 'schema', 'tags', name=_('Profile')), + ) + + class Meta: + model = ModuleTypeProfile + fields = [ + 'name', 'description', 'schema', 'comments', 'tags', + ] + + class ModuleTypeForm(NetBoxModelForm): + profile = forms.ModelChoiceField( + queryset=ModuleTypeProfile.objects.all(), + label=_('Profile'), + required=False, + widget=HTMXSelect() + ) manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), queryset=Manufacturer.objects.all() ) comments = CommentField() - fieldsets = ( - FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')), - FieldSet('airflow', 'weight', 'weight_unit', name=_('Chassis')) - ) + @property + def fieldsets(self): + return [ + FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')), + FieldSet('airflow', 'weight', 'weight_unit', name=_('Hardware')), + FieldSet('profile', *self.attr_fields, name=_('Profile & Attributes')) + ] class Meta: model = ModuleType fields = [ - 'manufacturer', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description', + 'profile', 'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'comments', 'tags', ] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Track profile-specific attribute fields + self.attr_fields = [] + + # Retrieve assigned ModuleTypeProfile, if any + if not (profile_id := get_field_value(self, 'profile')): + return + if not (profile := ModuleTypeProfile.objects.filter(pk=profile_id).first()): + return + + # Extend form with fields for profile attributes + for attr, form_field in self._get_attr_form_fields(profile).items(): + field_name = f'attr_{attr}' + self.attr_fields.append(field_name) + self.fields[field_name] = form_field + if self.instance.attribute_data: + self.fields[field_name].initial = self.instance.attribute_data.get(attr) + + @staticmethod + def _get_attr_form_fields(profile): + """ + Return a dictionary mapping of attribute names to form fields, suitable for extending + the form per the selected ModuleTypeProfile. + """ + if not profile.schema: + return {} + + properties = profile.schema.get('properties', {}) + required_fields = profile.schema.get('required', []) + + attr_fields = {} + for name, options in properties.items(): + prop = JSONSchemaProperty(**options) + attr_fields[name] = prop.to_form_field(name, required=name in required_fields) + + return dict(sorted(attr_fields.items())) + + def _post_clean(self): + + # Compile attribute data from the individual form fields + if self.cleaned_data.get('profile'): + self.instance.attribute_data = { + name[5:]: self.cleaned_data[name] # Remove the attr_ prefix + for name in self.attr_fields + if self.cleaned_data.get(name) not in EMPTY_VALUES + } + + return super()._post_clean() + class DeviceRoleForm(NetBoxModelForm): config_template = DynamicModelChoiceField( diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 4203517cc..5dfc0d73c 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -68,6 +68,7 @@ __all__ = ( 'ModuleBayFilter', 'ModuleBayTemplateFilter', 'ModuleTypeFilter', + 'ModuleTypeProfileFilter', 'PlatformFilter', 'PowerFeedFilter', 'PowerOutletFilter', @@ -559,6 +560,11 @@ class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin): position: FilterLookup[str] | None = strawberry_django.filter_field() +@strawberry_django.filter(models.ModuleTypeProfile, lookups=True) +class ModuleTypeProfileFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + + @strawberry_django.filter(models.ModuleType, lookups=True) class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin): manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 011a2b58b..1b0661bc2 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -77,6 +77,9 @@ class DCIMQuery: module_bay_template: ModuleBayTemplateType = strawberry_django.field() module_bay_template_list: List[ModuleBayTemplateType] = strawberry_django.field() + module_type_profile: ModuleTypeProfileType = strawberry_django.field() + module_type_profile_list: List[ModuleTypeProfileType] = strawberry_django.field() + module_type: ModuleTypeType = strawberry_django.field() module_type_list: List[ModuleTypeType] = strawberry_django.field() diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 24fa16263..fb8c136ad 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -61,6 +61,7 @@ __all__ = ( 'ModuleType', 'ModuleBayType', 'ModuleBayTemplateType', + 'ModuleTypeProfileType', 'ModuleTypeType', 'PlatformType', 'PowerFeedType', @@ -593,6 +594,16 @@ class ModuleBayTemplateType(ModularComponentTemplateType): pass +@strawberry_django.type( + models.ModuleTypeProfile, + fields='__all__', + filters=ModuleTypeProfileFilter, + pagination=True +) +class ModuleTypeProfileType(NetBoxObjectType): + module_types: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]] + + @strawberry_django.type( models.ModuleType, fields='__all__', @@ -600,6 +611,7 @@ class ModuleBayTemplateType(ModularComponentTemplateType): pagination=True ) class ModuleTypeType(NetBoxObjectType): + profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] frontporttemplates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]] diff --git a/netbox/dcim/migrations/0205_moduletypeprofile.py b/netbox/dcim/migrations/0205_moduletypeprofile.py new file mode 100644 index 000000000..3e9a4d6d8 --- /dev/null +++ b/netbox/dcim/migrations/0205_moduletypeprofile.py @@ -0,0 +1,57 @@ +import django.db.models.deletion +import taggit.managers +from django.db import migrations, models + +import utilities.json + + +class Migration(migrations.Migration): + dependencies = [ + ('dcim', '0204_device_role_rebuild'), + ('extras', '0125_exporttemplate_file_name'), + ] + + operations = [ + migrations.CreateModel( + name='ModuleTypeProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ( + 'custom_field_data', + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ('description', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('schema', models.JSONField(blank=True, null=True)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'module type profile', + 'verbose_name_plural': 'module type profiles', + 'ordering': ('name',), + }, + ), + migrations.AddField( + model_name='moduletype', + name='attribute_data', + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name='moduletype', + name='profile', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='module_types', + to='dcim.moduletypeprofile', + ), + ), + migrations.AlterModelOptions( + name='moduletype', + options={'ordering': ('profile', 'manufacturer', 'model')}, + ), + ] diff --git a/netbox/dcim/migrations/0206_load_module_type_profiles.py b/netbox/dcim/migrations/0206_load_module_type_profiles.py new file mode 100644 index 000000000..e3ca7d27a --- /dev/null +++ b/netbox/dcim/migrations/0206_load_module_type_profiles.py @@ -0,0 +1,42 @@ +import json +from pathlib import Path + +from django.db import migrations + +DATA_FILES_PATH = Path(__file__).parent / 'initial_data' / 'module_type_profiles' + + +def load_initial_data(apps, schema_editor): + """ + Load initial ModuleTypeProfile objects from file. + """ + ModuleTypeProfile = apps.get_model('dcim', 'ModuleTypeProfile') + initial_profiles = ( + 'cpu', + 'fan', + 'gpu', + 'hard_disk', + 'memory', + 'power_supply' + ) + + for name in initial_profiles: + file_path = DATA_FILES_PATH / f'{name}.json' + with file_path.open('r') as f: + data = json.load(f) + try: + ModuleTypeProfile.objects.create(**data) + except Exception as e: + print(f"Error loading data from {file_path}") + raise e + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0205_moduletypeprofile'), + ] + + operations = [ + migrations.RunPython(load_initial_data), + ] diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/cpu.json b/netbox/dcim/migrations/initial_data/module_type_profiles/cpu.json new file mode 100644 index 000000000..255886c5e --- /dev/null +++ b/netbox/dcim/migrations/initial_data/module_type_profiles/cpu.json @@ -0,0 +1,20 @@ +{ + "name": "CPU", + "schema": { + "properties": { + "architecture": { + "type": "string", + "title": "Architecture" + }, + "speed": { + "type": "number", + "title": "Speed", + "description": "Clock speed in GHz" + }, + "cores": { + "type": "integer", + "description": "Number of cores present" + } + } + } +} diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/fan.json b/netbox/dcim/migrations/initial_data/module_type_profiles/fan.json new file mode 100644 index 000000000..e6a2a384e --- /dev/null +++ b/netbox/dcim/migrations/initial_data/module_type_profiles/fan.json @@ -0,0 +1,12 @@ +{ + "name": "Fan", + "schema": { + "properties": { + "rpm": { + "type": "integer", + "title": "RPM", + "description": "Fan speed (RPM)" + } + } + } +} diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/gpu.json b/netbox/dcim/migrations/initial_data/module_type_profiles/gpu.json new file mode 100644 index 000000000..1725a4ab7 --- /dev/null +++ b/netbox/dcim/migrations/initial_data/module_type_profiles/gpu.json @@ -0,0 +1,28 @@ +{ + "name": "GPU", + "schema": { + "properties": { + "interface": { + "type": "string", + "enum": [ + "PCIe 4.0", + "PCIe 4.0 x8", + "PCIe 4.0 x16", + "PCIe 5.0 x16" + ] + }, + "gpu" : { + "type": "string", + "title": "GPU" + }, + "memory": { + "type": "integer", + "title": "Memory (GB)", + "description": "Total memory capacity (in GB)" + } + }, + "required": [ + "memory" + ] + } +} diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/hard_disk.json b/netbox/dcim/migrations/initial_data/module_type_profiles/hard_disk.json new file mode 100644 index 000000000..8d55cfde6 --- /dev/null +++ b/netbox/dcim/migrations/initial_data/module_type_profiles/hard_disk.json @@ -0,0 +1,29 @@ +{ + "name": "Hard disk", + "schema": { + "properties": { + "type": { + "type": "string", + "title": "Disk type", + "enum": [ + "HD", + "SSD", + "NVME" + ], + "default": "SSD" + }, + "size": { + "type": "integer", + "title": "Size (GB)", + "description": "Raw disk capacity" + }, + "speed": { + "type": "integer", + "title": "Speed (RPM)" + } + }, + "required": [ + "size" + ] + } +} diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/memory.json b/netbox/dcim/migrations/initial_data/module_type_profiles/memory.json new file mode 100644 index 000000000..8346bfce9 --- /dev/null +++ b/netbox/dcim/migrations/initial_data/module_type_profiles/memory.json @@ -0,0 +1,36 @@ +{ + "name": "Memory", + "schema": { + "properties": { + "class": { + "type": "string", + "title": "Memory class", + "enum": [ + "DDR3", + "DDR4", + "DDR5" + ], + "default": "DDR5" + }, + "size": { + "type": "integer", + "title": "Size (GB)", + "description": "Raw capacity of the module" + }, + "data_rate": { + "type": "integer", + "title": "Data rate", + "description": "Speed in MT/s" + }, + "ecc": { + "type": "boolean", + "title": "ECC", + "description": "Error-correcting code is enabled" + } + }, + "required": [ + "class", + "size" + ] + } +} diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/power_supply.json b/netbox/dcim/migrations/initial_data/module_type_profiles/power_supply.json new file mode 100644 index 000000000..ea060a889 --- /dev/null +++ b/netbox/dcim/migrations/initial_data/module_type_profiles/power_supply.json @@ -0,0 +1,34 @@ +{ + "name": "Power supply", + "schema": { + "properties": { + "input_current": { + "type": "string", + "title": "Current type", + "enum": [ + "AC", + "DC" + ], + "default": "AC" + }, + "input_voltage": { + "type": "integer", + "title": "Voltage", + "default": 120 + }, + "wattage": { + "type": "integer", + "description": "Available output power (watts)" + }, + "hot_swappable": { + "type": "boolean", + "title": "Hot-swappable", + "default": false + } + }, + "required": [ + "input_current", + "input_voltage" + ] + } +} diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index d74f34828..33af25678 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -2,6 +2,7 @@ from .cables import * from .device_component_templates import * from .device_components import * from .devices import * +from .modules import * from .power import * from .racks import * from .sites import * diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 76269f5c9..ae6d9c316 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -19,6 +19,7 @@ from core.models import ObjectType from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField +from dcim.utils import update_interface_bridges from extras.models import ConfigContextModel, CustomField from extras.querysets import ConfigContextModelQuerySet from netbox.choices import ColorChoices @@ -30,6 +31,7 @@ from utilities.fields import ColorField, CounterCacheField from utilities.tracking import TrackingModelMixin from .device_components import * from .mixins import RenderConfigMixin +from .modules import Module __all__ = ( @@ -38,8 +40,6 @@ __all__ = ( 'DeviceType', 'MACAddress', 'Manufacturer', - 'Module', - 'ModuleType', 'Platform', 'VirtualChassis', 'VirtualDeviceContext', @@ -367,103 +367,6 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD -class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): - """ - A ModuleType represents a hardware element that can be installed within a device and which houses additional - components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a - DeviceType, each ModuleType can have console, power, interface, and pass-through port templates assigned to it. It - cannot, however house device bays or module bays. - """ - manufacturer = models.ForeignKey( - to='dcim.Manufacturer', - on_delete=models.PROTECT, - related_name='module_types' - ) - model = models.CharField( - verbose_name=_('model'), - max_length=100 - ) - part_number = models.CharField( - verbose_name=_('part number'), - max_length=50, - blank=True, - help_text=_('Discrete part number (optional)') - ) - airflow = models.CharField( - verbose_name=_('airflow'), - max_length=50, - choices=ModuleAirflowChoices, - blank=True, - null=True - ) - - clone_fields = ('manufacturer', 'weight', 'weight_unit', 'airflow') - prerequisite_models = ( - 'dcim.Manufacturer', - ) - - class Meta: - ordering = ('manufacturer', 'model') - constraints = ( - models.UniqueConstraint( - fields=('manufacturer', 'model'), - name='%(app_label)s_%(class)s_unique_manufacturer_model' - ), - ) - verbose_name = _('module type') - verbose_name_plural = _('module types') - - def __str__(self): - return self.model - - @property - def full_name(self): - return f"{self.manufacturer} {self.model}" - - def to_yaml(self): - data = { - 'manufacturer': self.manufacturer.name, - 'model': self.model, - 'part_number': self.part_number, - 'description': self.description, - 'weight': float(self.weight) if self.weight is not None else None, - 'weight_unit': self.weight_unit, - 'comments': self.comments, - } - - # Component templates - if self.consoleporttemplates.exists(): - data['console-ports'] = [ - c.to_yaml() for c in self.consoleporttemplates.all() - ] - if self.consoleserverporttemplates.exists(): - data['console-server-ports'] = [ - c.to_yaml() for c in self.consoleserverporttemplates.all() - ] - if self.powerporttemplates.exists(): - data['power-ports'] = [ - c.to_yaml() for c in self.powerporttemplates.all() - ] - if self.poweroutlettemplates.exists(): - data['power-outlets'] = [ - c.to_yaml() for c in self.poweroutlettemplates.all() - ] - if self.interfacetemplates.exists(): - data['interfaces'] = [ - c.to_yaml() for c in self.interfacetemplates.all() - ] - if self.frontporttemplates.exists(): - data['front-ports'] = [ - c.to_yaml() for c in self.frontporttemplates.all() - ] - if self.rearporttemplates.exists(): - data['rear-ports'] = [ - c.to_yaml() for c in self.rearporttemplates.all() - ] - - return yaml.dump(dict(data), sort_keys=False) - - # # Devices # @@ -526,23 +429,6 @@ class Platform(OrganizationalModel): verbose_name_plural = _('platforms') -def update_interface_bridges(device, interface_templates, module=None): - """ - Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned - and applies it to the actual interfaces. - """ - for interface_template in interface_templates.exclude(bridge=None): - interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module)) - - if interface_template.bridge: - interface.bridge = Interface.objects.get( - device=device, - name=interface_template.bridge.resolve_name(module=module) - ) - interface.full_clean() - interface.save() - - class Device( ContactsMixin, ImageAttachmentsMixin, @@ -1155,170 +1041,6 @@ class Device( return round(total_weight / 1000, 2) -class Module(PrimaryModel, ConfigContextModel): - """ - A Module represents a field-installable component within a Device which may itself hold multiple device components - (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes. - """ - device = models.ForeignKey( - to='dcim.Device', - on_delete=models.CASCADE, - related_name='modules' - ) - module_bay = models.OneToOneField( - to='dcim.ModuleBay', - on_delete=models.CASCADE, - related_name='installed_module' - ) - module_type = models.ForeignKey( - to='dcim.ModuleType', - on_delete=models.PROTECT, - related_name='instances' - ) - status = models.CharField( - verbose_name=_('status'), - max_length=50, - choices=ModuleStatusChoices, - default=ModuleStatusChoices.STATUS_ACTIVE - ) - serial = models.CharField( - max_length=50, - blank=True, - verbose_name=_('serial number') - ) - asset_tag = models.CharField( - max_length=50, - blank=True, - null=True, - unique=True, - verbose_name=_('asset tag'), - help_text=_('A unique tag used to identify this device') - ) - - clone_fields = ('device', 'module_type', 'status') - - class Meta: - ordering = ('module_bay',) - verbose_name = _('module') - verbose_name_plural = _('modules') - - def __str__(self): - return f'{self.module_bay.name}: {self.module_type} ({self.pk})' - - def get_status_color(self): - return ModuleStatusChoices.colors.get(self.status) - - def clean(self): - super().clean() - - if hasattr(self, "module_bay") and (self.module_bay.device != self.device): - raise ValidationError( - _("Module must be installed within a module bay belonging to the assigned device ({device}).").format( - device=self.device - ) - ) - - # Check for recursion - module = self - module_bays = [] - modules = [] - while module: - if module.pk in modules or module.module_bay.pk in module_bays: - raise ValidationError(_("A module bay cannot belong to a module installed within it.")) - modules.append(module.pk) - module_bays.append(module.module_bay.pk) - module = module.module_bay.module if module.module_bay else None - - def save(self, *args, **kwargs): - is_new = self.pk is None - - super().save(*args, **kwargs) - - adopt_components = getattr(self, '_adopt_components', False) - disable_replication = getattr(self, '_disable_replication', False) - - # We skip adding components if the module is being edited or - # both replication and component adoption is disabled - if not is_new or (disable_replication and not adopt_components): - return - - # Iterate all component types - for templates, component_attribute, component_model in [ - ("consoleporttemplates", "consoleports", ConsolePort), - ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort), - ("interfacetemplates", "interfaces", Interface), - ("powerporttemplates", "powerports", PowerPort), - ("poweroutlettemplates", "poweroutlets", PowerOutlet), - ("rearporttemplates", "rearports", RearPort), - ("frontporttemplates", "frontports", FrontPort), - ("modulebaytemplates", "modulebays", ModuleBay), - ]: - create_instances = [] - update_instances = [] - - # Prefetch installed components - installed_components = { - component.name: component - for component in getattr(self.device, component_attribute).filter(module__isnull=True) - } - - # Get the template for the module type. - for template in getattr(self.module_type, templates).all(): - template_instance = template.instantiate(device=self.device, module=self) - - if adopt_components: - existing_item = installed_components.get(template_instance.name) - - # Check if there's a component with the same name already - if existing_item: - # Assign it to the module - existing_item.module = self - update_instances.append(existing_item) - continue - - # Only create new components if replication is enabled - if not disable_replication: - create_instances.append(template_instance) - - # Set default values for any applicable custom fields - if cf_defaults := CustomField.objects.get_defaults_for_model(component_model): - for component in create_instances: - component.custom_field_data = cf_defaults - - if component_model is not ModuleBay: - component_model.objects.bulk_create(create_instances) - # Emit the post_save signal for each newly created object - for component in create_instances: - post_save.send( - sender=component_model, - instance=component, - created=True, - raw=False, - using='default', - update_fields=None - ) - else: - # ModuleBays must be saved individually for MPTT - for instance in create_instances: - instance.save() - - update_fields = ['module'] - component_model.objects.bulk_update(update_instances, update_fields) - # Emit the post_save signal for each updated object - for component in update_instances: - post_save.send( - sender=component_model, - instance=component, - created=False, - raw=False, - using='default', - update_fields=update_fields - ) - - # Interface bridges have to be set after interface instantiation - update_interface_bridges(self.device, self.module_type.interfacetemplates, self) - - # # Virtual chassis # diff --git a/netbox/dcim/models/modules.py b/netbox/dcim/models/modules.py new file mode 100644 index 000000000..432b7afd1 --- /dev/null +++ b/netbox/dcim/models/modules.py @@ -0,0 +1,360 @@ +import jsonschema +import yaml +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models.signals import post_save +from django.utils.translation import gettext_lazy as _ +from jsonschema.exceptions import ValidationError as JSONValidationError + +from dcim.choices import * +from dcim.utils import update_interface_bridges +from extras.models import ConfigContextModel, CustomField +from netbox.models import PrimaryModel +from netbox.models.features import ImageAttachmentsMixin +from netbox.models.mixins import WeightMixin +from utilities.jsonschema import validate_schema +from utilities.string import title +from .device_components import * + +__all__ = ( + 'Module', + 'ModuleType', + 'ModuleTypeProfile', +) + + +class ModuleTypeProfile(PrimaryModel): + """ + A profile which defines the attributes which can be set on one or more ModuleTypes. + """ + name = models.CharField( + verbose_name=_('name'), + max_length=100, + unique=True + ) + schema = models.JSONField( + blank=True, + null=True, + verbose_name=_('schema') + ) + + clone_fields = ('schema',) + + class Meta: + ordering = ('name',) + verbose_name = _('module type profile') + verbose_name_plural = _('module type profiles') + + def __str__(self): + return self.name + + def clean(self): + super().clean() + + # Validate the schema definition + if self.schema is not None: + try: + validate_schema(self.schema) + except ValidationError as e: + raise ValidationError({ + 'schema': e.message, + }) + + +class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): + """ + A ModuleType represents a hardware element that can be installed within a device and which houses additional + components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a + DeviceType, each ModuleType can have console, power, interface, and pass-through port templates assigned to it. It + cannot, however house device bays or module bays. + """ + profile = models.ForeignKey( + to='dcim.ModuleTypeProfile', + on_delete=models.PROTECT, + related_name='module_types', + blank=True, + null=True + ) + manufacturer = models.ForeignKey( + to='dcim.Manufacturer', + on_delete=models.PROTECT, + related_name='module_types' + ) + model = models.CharField( + verbose_name=_('model'), + max_length=100 + ) + part_number = models.CharField( + verbose_name=_('part number'), + max_length=50, + blank=True, + help_text=_('Discrete part number (optional)') + ) + airflow = models.CharField( + verbose_name=_('airflow'), + max_length=50, + choices=ModuleAirflowChoices, + blank=True, + null=True + ) + attribute_data = models.JSONField( + blank=True, + null=True, + verbose_name=_('attributes') + ) + + clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow') + prerequisite_models = ( + 'dcim.Manufacturer', + ) + + class Meta: + ordering = ('profile', 'manufacturer', 'model') + constraints = ( + models.UniqueConstraint( + fields=('manufacturer', 'model'), + name='%(app_label)s_%(class)s_unique_manufacturer_model' + ), + ) + verbose_name = _('module type') + verbose_name_plural = _('module types') + + def __str__(self): + return self.model + + @property + def full_name(self): + return f"{self.manufacturer} {self.model}" + + @property + def attributes(self): + """ + Returns a human-friendly representation of the attributes defined for a ModuleType according to its profile. + """ + if not self.attribute_data or self.profile is None or not self.profile.schema: + return {} + attrs = {} + for name, options in self.profile.schema.get('properties', {}).items(): + key = options.get('title', title(name)) + attrs[key] = self.attribute_data.get(name) + return dict(sorted(attrs.items())) + + def clean(self): + super().clean() + + # Validate any attributes against the assigned profile's schema + if self.profile: + try: + jsonschema.validate(self.attribute_data, schema=self.profile.schema) + except JSONValidationError as e: + raise ValidationError(_("Invalid schema: {error}").format(error=e)) + else: + self.attribute_data = None + + def to_yaml(self): + data = { + 'profile': self.profile.name if self.profile else None, + 'manufacturer': self.manufacturer.name, + 'model': self.model, + 'part_number': self.part_number, + 'description': self.description, + 'weight': float(self.weight) if self.weight is not None else None, + 'weight_unit': self.weight_unit, + 'comments': self.comments, + } + + # Component templates + if self.consoleporttemplates.exists(): + data['console-ports'] = [ + c.to_yaml() for c in self.consoleporttemplates.all() + ] + if self.consoleserverporttemplates.exists(): + data['console-server-ports'] = [ + c.to_yaml() for c in self.consoleserverporttemplates.all() + ] + if self.powerporttemplates.exists(): + data['power-ports'] = [ + c.to_yaml() for c in self.powerporttemplates.all() + ] + if self.poweroutlettemplates.exists(): + data['power-outlets'] = [ + c.to_yaml() for c in self.poweroutlettemplates.all() + ] + if self.interfacetemplates.exists(): + data['interfaces'] = [ + c.to_yaml() for c in self.interfacetemplates.all() + ] + if self.frontporttemplates.exists(): + data['front-ports'] = [ + c.to_yaml() for c in self.frontporttemplates.all() + ] + if self.rearporttemplates.exists(): + data['rear-ports'] = [ + c.to_yaml() for c in self.rearporttemplates.all() + ] + + return yaml.dump(dict(data), sort_keys=False) + + +class Module(PrimaryModel, ConfigContextModel): + """ + A Module represents a field-installable component within a Device which may itself hold multiple device components + (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes. + """ + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='modules' + ) + module_bay = models.OneToOneField( + to='dcim.ModuleBay', + on_delete=models.CASCADE, + related_name='installed_module' + ) + module_type = models.ForeignKey( + to='dcim.ModuleType', + on_delete=models.PROTECT, + related_name='instances' + ) + status = models.CharField( + verbose_name=_('status'), + max_length=50, + choices=ModuleStatusChoices, + default=ModuleStatusChoices.STATUS_ACTIVE + ) + serial = models.CharField( + max_length=50, + blank=True, + verbose_name=_('serial number') + ) + asset_tag = models.CharField( + max_length=50, + blank=True, + null=True, + unique=True, + verbose_name=_('asset tag'), + help_text=_('A unique tag used to identify this device') + ) + + clone_fields = ('device', 'module_type', 'status') + + class Meta: + ordering = ('module_bay',) + verbose_name = _('module') + verbose_name_plural = _('modules') + + def __str__(self): + return f'{self.module_bay.name}: {self.module_type} ({self.pk})' + + def get_status_color(self): + return ModuleStatusChoices.colors.get(self.status) + + def clean(self): + super().clean() + + if hasattr(self, "module_bay") and (self.module_bay.device != self.device): + raise ValidationError( + _("Module must be installed within a module bay belonging to the assigned device ({device}).").format( + device=self.device + ) + ) + + # Check for recursion + module = self + module_bays = [] + modules = [] + while module: + if module.pk in modules or module.module_bay.pk in module_bays: + raise ValidationError(_("A module bay cannot belong to a module installed within it.")) + modules.append(module.pk) + module_bays.append(module.module_bay.pk) + module = module.module_bay.module if module.module_bay else None + + def save(self, *args, **kwargs): + is_new = self.pk is None + + super().save(*args, **kwargs) + + adopt_components = getattr(self, '_adopt_components', False) + disable_replication = getattr(self, '_disable_replication', False) + + # We skip adding components if the module is being edited or + # both replication and component adoption is disabled + if not is_new or (disable_replication and not adopt_components): + return + + # Iterate all component types + for templates, component_attribute, component_model in [ + ("consoleporttemplates", "consoleports", ConsolePort), + ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort), + ("interfacetemplates", "interfaces", Interface), + ("powerporttemplates", "powerports", PowerPort), + ("poweroutlettemplates", "poweroutlets", PowerOutlet), + ("rearporttemplates", "rearports", RearPort), + ("frontporttemplates", "frontports", FrontPort), + ("modulebaytemplates", "modulebays", ModuleBay), + ]: + create_instances = [] + update_instances = [] + + # Prefetch installed components + installed_components = { + component.name: component + for component in getattr(self.device, component_attribute).filter(module__isnull=True) + } + + # Get the template for the module type. + for template in getattr(self.module_type, templates).all(): + template_instance = template.instantiate(device=self.device, module=self) + + if adopt_components: + existing_item = installed_components.get(template_instance.name) + + # Check if there's a component with the same name already + if existing_item: + # Assign it to the module + existing_item.module = self + update_instances.append(existing_item) + continue + + # Only create new components if replication is enabled + if not disable_replication: + create_instances.append(template_instance) + + # Set default values for any applicable custom fields + if cf_defaults := CustomField.objects.get_defaults_for_model(component_model): + for component in create_instances: + component.custom_field_data = cf_defaults + + if component_model is not ModuleBay: + component_model.objects.bulk_create(create_instances) + # Emit the post_save signal for each newly created object + for component in create_instances: + post_save.send( + sender=component_model, + instance=component, + created=True, + raw=False, + using='default', + update_fields=None + ) + else: + # ModuleBays must be saved individually for MPTT + for instance in create_instances: + instance.save() + + update_fields = ['module'] + component_model.objects.bulk_update(update_instances, update_fields) + # Emit the post_save signal for each updated object + for component in update_instances: + post_save.send( + sender=component_model, + instance=component, + created=False, + raw=False, + using='default', + update_fields=update_fields + ) + + # Interface bridges have to be set after interface instantiation + update_interface_bridges(self.device, self.module_type.interfacetemplates, self) diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index a85005679..33b666df7 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -183,6 +183,17 @@ class ModuleBayIndex(SearchIndex): display_attrs = ('device', 'label', 'position', 'description') +@register_search +class ModuleTypeProfileIndex(SearchIndex): + model = models.ModuleTypeProfile + fields = ( + ('name', 100), + ('description', 500), + ('comments', 5000), + ) + display_attrs = ('name', 'description') + + @register_search class ModuleTypeIndex(SearchIndex): model = models.ModuleType diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index 6bd0d53b5..52edea8b4 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -1,25 +1,64 @@ from django.utils.translation import gettext_lazy as _ import django_tables2 as tables -from dcim.models import Module, ModuleType +from dcim.models import Module, ModuleType, ModuleTypeProfile from netbox.tables import NetBoxTable, columns -from .template_code import WEIGHT +from .template_code import MODULETYPEPROFILE_ATTRIBUTES, WEIGHT __all__ = ( 'ModuleTable', + 'ModuleTypeProfileTable', 'ModuleTypeTable', ) +class ModuleTypeProfileTable(NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + attributes = columns.TemplateColumn( + template_code=MODULETYPEPROFILE_ATTRIBUTES, + accessor=tables.A('schema__properties'), + orderable=False, + verbose_name=_('Attributes') + ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) + tags = columns.TagColumn( + url_name='dcim:moduletypeprofile_list' + ) + + class Meta(NetBoxTable.Meta): + model = ModuleTypeProfile + fields = ( + 'pk', 'id', 'name', 'description', 'comments', 'tags', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'description', 'attributes', + ) + + class ModuleTypeTable(NetBoxTable): - model = tables.Column( - linkify=True, - verbose_name=_('Module Type') + profile = tables.Column( + verbose_name=_('Profile'), + linkify=True ) manufacturer = tables.Column( verbose_name=_('Manufacturer'), linkify=True ) + model = tables.Column( + linkify=True, + verbose_name=_('Module Type') + ) + weight = columns.TemplateColumn( + verbose_name=_('Weight'), + template_code=WEIGHT, + order_by=('_abs_weight', 'weight_unit') + ) + attributes = columns.DictColumn() instance_count = columns.LinkedCountColumn( viewname='dcim:module_list', url_params={'module_type_id': 'pk'}, @@ -31,20 +70,15 @@ class ModuleTypeTable(NetBoxTable): tags = columns.TagColumn( url_name='dcim:moduletype_list' ) - weight = columns.TemplateColumn( - verbose_name=_('Weight'), - template_code=WEIGHT, - order_by=('_abs_weight', 'weight_unit') - ) class Meta(NetBoxTable.Meta): model = ModuleType fields = ( - 'pk', 'id', 'model', 'manufacturer', 'part_number', 'airflow', 'weight', 'description', 'comments', 'tags', - 'created', 'last_updated', + 'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'description', + 'attributes', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'model', 'manufacturer', 'part_number', + 'pk', 'model', 'profile', 'manufacturer', 'part_number', ) diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 225237ec4..3c4ca7bd4 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -568,3 +568,7 @@ MODULEBAY_BUTTONS = """ {% endif %} {% endif %} """ + +MODULETYPEPROFILE_ATTRIBUTES = """ +{% if value %}{% for attr in value %}{{ attr }}{% if not forloop.last %}, {% endif %}{% endfor %}{% endif %} +""" diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index c2a7660c6..8007d9161 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -591,7 +591,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase): class ModuleTypeTest(APIViewTestCases.APIViewTestCase): model = ModuleType - brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'url'] + brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'profile', 'url'] bulk_update_data = { 'part_number': 'ABC123', } @@ -629,6 +629,70 @@ class ModuleTypeTest(APIViewTestCases.APIViewTestCase): ] +class ModuleTypeProfileTest(APIViewTestCases.APIViewTestCase): + model = ModuleTypeProfile + brief_fields = ['description', 'display', 'id', 'name', 'url'] + SCHEMAS = [ + { + "properties": { + "foo": { + "type": "string" + } + } + }, + { + "properties": { + "foo": { + "type": "integer" + } + } + }, + { + "properties": { + "foo": { + "type": "boolean" + } + } + }, + ] + create_data = [ + { + 'name': 'Module Type Profile 4', + 'schema': SCHEMAS[0], + }, + { + 'name': 'Module Type Profile 5', + 'schema': SCHEMAS[1], + }, + { + 'name': 'Module Type Profile 6', + 'schema': SCHEMAS[2], + }, + ] + bulk_update_data = { + 'description': 'New description', + 'comments': 'New comments', + } + + @classmethod + def setUpTestData(cls): + module_type_profiles = ( + ModuleTypeProfile( + name='Module Type Profile 1', + schema=cls.SCHEMAS[0] + ), + ModuleTypeProfile( + name='Module Type Profile 2', + schema=cls.SCHEMAS[1] + ), + ModuleTypeProfile( + name='Module Type Profile 3', + schema=cls.SCHEMAS[2] + ), + ) + ModuleTypeProfile.objects.bulk_create(module_type_profiles) + + class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): model = ConsolePortTemplate brief_fields = ['description', 'display', 'id', 'name', 'url'] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index b2353b4ba..b56ea3919 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1486,6 +1486,16 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ModuleType.objects.all() filterset = ModuleTypeFilterSet + ignore_fields = ['attribute_data'] + + PROFILE_SCHEMA = { + "properties": { + "string": {"type": "string"}, + "integer": {"type": "integer"}, + "number": {"type": "number"}, + "boolean": {"type": "boolean"}, + } + } @classmethod def setUpTestData(cls): @@ -1496,6 +1506,21 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), ) Manufacturer.objects.bulk_create(manufacturers) + module_type_profiles = ( + ModuleTypeProfile( + name='Module Type Profile 1', + schema=cls.PROFILE_SCHEMA + ), + ModuleTypeProfile( + name='Module Type Profile 2', + schema=cls.PROFILE_SCHEMA + ), + ModuleTypeProfile( + name='Module Type Profile 3', + schema=cls.PROFILE_SCHEMA + ), + ) + ModuleTypeProfile.objects.bulk_create(module_type_profiles) module_types = ( ModuleType( @@ -1505,7 +1530,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): weight=10, weight_unit=WeightUnitChoices.UNIT_POUND, description='foobar1', - airflow=ModuleAirflowChoices.FRONT_TO_REAR + airflow=ModuleAirflowChoices.FRONT_TO_REAR, + profile=module_type_profiles[0], + attribute_data={ + 'string': 'string1', + 'integer': 1, + 'number': 1.0, + 'boolean': True, + } ), ModuleType( manufacturer=manufacturers[1], @@ -1514,7 +1546,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): weight=20, weight_unit=WeightUnitChoices.UNIT_POUND, description='foobar2', - airflow=ModuleAirflowChoices.REAR_TO_FRONT + airflow=ModuleAirflowChoices.REAR_TO_FRONT, + profile=module_type_profiles[1], + attribute_data={ + 'string': 'string2', + 'integer': 2, + 'number': 2.0, + 'boolean_': False, + } ), ModuleType( manufacturer=manufacturers[2], @@ -1522,7 +1561,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): part_number='Part Number 3', weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM, - description='foobar3' + description='foobar3', + profile=module_type_profiles[2], + attribute_data={ + 'string': 'string3', + 'integer': 3, + 'number': 3.0, + 'boolean': None, + } ), ) ModuleType.objects.bulk_create(module_types) @@ -1641,6 +1687,82 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'airflow': RackAirflowChoices.FRONT_TO_REAR} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_profile(self): + profiles = ModuleTypeProfile.objects.filter(name__startswith="Module Type Profile")[:2] + params = {'profile_id': [profiles[0].pk, profiles[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'profile': [profiles[0].name, profiles[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_profile_attributes(self): + params = {'attr_string': 'string1'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'attr_integer': '1'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'attr_number': '2.0'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'attr_boolean': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + +class ModuleTypeProfileTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ModuleTypeProfile.objects.all() + filterset = ModuleTypeProfileFilterSet + ignore_fields = ['schema'] + + SCHEMAS = [ + { + "properties": { + "foo": { + "type": "string" + } + } + }, + { + "properties": { + "foo": { + "type": "integer" + } + } + }, + { + "properties": { + "foo": { + "type": "boolean" + } + } + }, + ] + + @classmethod + def setUpTestData(cls): + module_type_profiles = ( + ModuleTypeProfile( + name='Module Type Profile 1', + description='foobar1', + schema=cls.SCHEMAS[0] + ), + ModuleTypeProfile( + name='Module Type Profile 2', + description='foobar2 2', + schema=cls.SCHEMAS[1] + ), + ModuleTypeProfile( + name='Module Type Profile 3', + description='foobar3', + schema=cls.SCHEMAS[2] + ), + ) + ModuleTypeProfile.objects.bulk_create(module_type_profiles) + + def test_q(self): + params = {'q': 'foobar1'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_name(self): + params = {'name': ['Module Type Profile 1', 'Module Type Profile 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class ConsolePortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests): queryset = ConsolePortTemplate.objects.all() diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 0bf8fefb3..3c43d1834 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1,3 +1,4 @@ +import json from decimal import Decimal from zoneinfo import ZoneInfo @@ -1305,6 +1306,79 @@ front-ports: self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8') +class ModuleTypeProfileTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = ModuleTypeProfile + + SCHEMAS = [ + { + "properties": { + "foo": { + "type": "string" + } + } + }, + { + "properties": { + "foo": { + "type": "integer" + } + } + }, + { + "properties": { + "foo": { + "type": "boolean" + } + } + }, + ] + + @classmethod + def setUpTestData(cls): + module_type_profiles = ( + ModuleTypeProfile( + name='Module Type Profile 1', + schema=cls.SCHEMAS[0] + ), + ModuleTypeProfile( + name='Module Type Profile 2', + schema=cls.SCHEMAS[1] + ), + ModuleTypeProfile( + name='Module Type Profile 3', + schema=cls.SCHEMAS[2] + ), + ) + ModuleTypeProfile.objects.bulk_create(module_type_profiles) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'Module Type Profile X', + 'description': 'A new profile', + 'schema': json.dumps(cls.SCHEMAS[0]), + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,schema", + f"Module Type Profile 4,{json.dumps(cls.SCHEMAS[0])}", + f"Module Type Profile 5,{json.dumps(cls.SCHEMAS[1])}", + f"Module Type Profile 6,{json.dumps(cls.SCHEMAS[2])}", + ) + + cls.csv_update_data = ( + "id,description", + f"{module_type_profiles[0].pk},New description", + f"{module_type_profiles[1].pk},New description", + f"{module_type_profiles[2].pk},New description", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + + # # DeviceType components # diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index bcfd32707..122593834 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -37,6 +37,9 @@ urlpatterns = [ path('device-types/', include(get_model_urls('dcim', 'devicetype', detail=False))), path('device-types//', include(get_model_urls('dcim', 'devicetype'))), + path('module-type-profiles/', include(get_model_urls('dcim', 'moduletypeprofile', detail=False))), + path('module-type-profiles//', include(get_model_urls('dcim', 'moduletypeprofile'))), + path('module-types/', include(get_model_urls('dcim', 'moduletype', detail=False))), path('module-types//', include(get_model_urls('dcim', 'moduletype'))), diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 4d4228490..0931761bf 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -1,3 +1,4 @@ +from django.apps import apps from django.contrib.contenttypes.models import ContentType from django.db import transaction @@ -56,3 +57,22 @@ def rebuild_paths(terminations): for cp in cable_paths: cp.delete() create_cablepath(cp.origins) + + +def update_interface_bridges(device, interface_templates, module=None): + """ + Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned + and applies it to the actual interfaces. + """ + Interface = apps.get_model('dcim', 'Interface') + + for interface_template in interface_templates.exclude(bridge=None): + interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module)) + + if interface_template.bridge: + interface.bridge = Interface.objects.get( + device=device, + name=interface_template.bridge.resolve_name(module=module) + ) + interface.full_clean() + interface.save() diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4b2f22035..2027a7368 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1247,6 +1247,62 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView): table = tables.DeviceTypeTable +# +# Module type profiles +# + +@register_model_view(ModuleTypeProfile, 'list', path='', detail=False) +class ModuleTypeProfileListView(generic.ObjectListView): + queryset = ModuleTypeProfile.objects.annotate( + instance_count=count_related(ModuleType, 'profile') + ) + filterset = filtersets.ModuleTypeProfileFilterSet + filterset_form = forms.ModuleTypeProfileFilterForm + table = tables.ModuleTypeProfileTable + + +@register_model_view(ModuleTypeProfile) +class ModuleTypeProfileView(GetRelatedModelsMixin, generic.ObjectView): + queryset = ModuleTypeProfile.objects.all() + + +@register_model_view(ModuleTypeProfile, 'add', detail=False) +@register_model_view(ModuleTypeProfile, 'edit') +class ModuleTypeProfileEditView(generic.ObjectEditView): + queryset = ModuleTypeProfile.objects.all() + form = forms.ModuleTypeProfileForm + + +@register_model_view(ModuleTypeProfile, 'delete') +class ModuleTypeProfileDeleteView(generic.ObjectDeleteView): + queryset = ModuleTypeProfile.objects.all() + + +@register_model_view(ModuleTypeProfile, 'bulk_import', detail=False) +class ModuleTypeProfileBulkImportView(generic.BulkImportView): + queryset = ModuleTypeProfile.objects.all() + model_form = forms.ModuleTypeProfileImportForm + + +@register_model_view(ModuleTypeProfile, 'bulk_edit', path='edit', detail=False) +class ModuleTypeProfileBulkEditView(generic.BulkEditView): + queryset = ModuleTypeProfile.objects.annotate( + instance_count=count_related(Module, 'module_type') + ) + filterset = filtersets.ModuleTypeProfileFilterSet + table = tables.ModuleTypeProfileTable + form = forms.ModuleTypeProfileBulkEditForm + + +@register_model_view(ModuleTypeProfile, 'bulk_delete', path='delete', detail=False) +class ModuleTypeProfileBulkDeleteView(generic.BulkDeleteView): + queryset = ModuleTypeProfile.objects.annotate( + instance_count=count_related(Module, 'module_type') + ) + filterset = filtersets.ModuleTypeProfileFilterSet + table = tables.ModuleTypeProfileTable + + # # Module types # diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 84d7aad5a..247653472 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -1161,6 +1161,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): 'module', 'modulebay', 'moduletype', + 'moduletypeprofile', 'platform', 'powerfeed', 'poweroutlet', diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py index e7d1ef574..db5ec184d 100644 --- a/netbox/netbox/api/fields.py +++ b/netbox/netbox/api/fields.py @@ -9,6 +9,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.relations import PrimaryKeyRelatedField, RelatedField __all__ = ( + 'AttributesField', 'ChoiceField', 'ContentTypeField', 'IPNetworkSerializer', @@ -172,3 +173,19 @@ class IntegerRangeSerializer(serializers.Serializer): def to_representation(self, instance): return instance.lower, instance.upper - 1 + + +class AttributesField(serializers.JSONField): + """ + Custom attributes stored as JSON data. + """ + def to_internal_value(self, data): + data = super().to_internal_value(data) + + # If updating an object, start with the initial attribute data. This enables the client to modify + # individual attributes without having to rewrite the entire field. + if data and self.parent.instance: + initial_data = getattr(self.parent.instance, self.source, None) or {} + return {**initial_data, **data} + + return data diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index d80b07e90..ffae9b7ff 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -1,3 +1,5 @@ +import json + import django_filters from copy import deepcopy from django.contrib.contenttypes.models import ContentType @@ -20,6 +22,7 @@ from utilities.forms.fields import MACAddressField from utilities import filters __all__ = ( + 'AttributeFiltersMixin', 'BaseFilterSet', 'ChangeLoggedModelFilterSet', 'NetBoxModelFilterSet', @@ -345,3 +348,32 @@ class NestedGroupModelFilterSet(NetBoxModelFilterSet): ) return queryset + + +class AttributeFiltersMixin: + attributes_field_name = 'attribute_data' + attribute_filter_prefix = 'attr_' + + def __init__(self, data=None, queryset=None, *, request=None, prefix=None): + self.attr_filters = {} + + # Extract JSONField-based filters from the incoming data + if data is not None: + for key, value in data.items(): + if field := self._get_field_lookup(key): + # Attempt to cast the value to a native JSON type + try: + self.attr_filters[field] = json.loads(value) + except (ValueError, json.JSONDecodeError): + self.attr_filters[field] = value + + super().__init__(data=data, queryset=queryset, request=request, prefix=prefix) + + def _get_field_lookup(self, key): + if not key.startswith(self.attribute_filter_prefix): + return + lookup = key.split(self.attribute_filter_prefix, 1)[1] # Strip prefix + return f'{self.attributes_field_name}__{lookup}' + + def filter_queryset(self, queryset): + return super().filter_queryset(queryset).filter(**self.attr_filters) diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 9148caa8e..778f0d67c 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -85,6 +85,7 @@ DEVICES_MENU = Menu( items=( get_model_item('dcim', 'devicetype', _('Device Types')), get_model_item('dcim', 'moduletype', _('Module Types')), + get_model_item('dcim', 'moduletypeprofile', _('Module Type Profiles')), get_model_item('dcim', 'manufacturer', _('Manufacturers')), ), ), diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index cf6e1f133..f0e55176d 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -35,6 +35,7 @@ __all__ = ( 'ContentTypesColumn', 'CustomFieldColumn', 'CustomLinkColumn', + 'DictColumn', 'DistanceColumn', 'DurationColumn', 'LinkedCountColumn', @@ -707,3 +708,14 @@ class DistanceColumn(TemplateColumn): def __init__(self, template_code=template_code, order_by='_abs_distance', **kwargs): super().__init__(template_code=template_code, order_by=order_by, **kwargs) + + +class DictColumn(tables.Column): + """ + Render a dictionary of data in a simple key: value format, one pair per line. + """ + def render(self, value): + output = '
'.join([ + f'{escape(k)}: {escape(v)}' for k, v in value.items() + ]) + return mark_safe(output) diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html index f702c6608..3f09ec82f 100644 --- a/netbox/templates/dcim/module.html +++ b/netbox/templates/dcim/module.html @@ -1,8 +1,8 @@ {% extends 'generic/object.html' %} {% load helpers %} {% load plugins %} -{% load tz %} {% load i18n %} +{% load mptt %} {% block breadcrumbs %} {{ block.super }} @@ -62,8 +62,8 @@
{{ object.device.device_type|linkify }}
{% trans "Module Type" %}{{ object.module_type|linkify:"full_name" }}{% trans "Module Bay" %}{% nested_tree object.module_bay %}
{% trans "Status" %}
+ + + + + + + + + {% for k, v in object.module_type.attributes.items %} + + + + + {% endfor %} +
{% trans "Manufacturer" %}{{ object.module_type.manufacturer|linkify }}
{% trans "Model" %}{{ object.module_type|linkify }}
{{ k }}{{ v|placeholder }}
+
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/moduletype.html b/netbox/templates/dcim/moduletype.html index b3d53e09b..adc5e2a98 100644 --- a/netbox/templates/dcim/moduletype.html +++ b/netbox/templates/dcim/moduletype.html @@ -23,6 +23,10 @@

{% trans "Module Type" %}

+ + + + @@ -60,6 +64,27 @@ {% plugin_left_page object %}
+
+

{% trans "Attributes" %}

+ {% if not object.profile %} +
+ {% trans "No profile assigned" %} +
+ {% elif object.attributes %} +
{% trans "Profile" %}{{ object.profile|linkify|placeholder }}
{% trans "Manufacturer" %} {{ object.manufacturer|linkify }}
+ {% for k, v in object.attributes.items %} + + + + + {% endfor %} +
{{ k }}{{ v|placeholder }}
+ {% else %} +
+ {% trans "None" %} +
+ {% endif %} +
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/image_attachments.html' %} diff --git a/netbox/templates/dcim/moduletypeprofile.html b/netbox/templates/dcim/moduletypeprofile.html new file mode 100644 index 000000000..87e576bda --- /dev/null +++ b/netbox/templates/dcim/moduletypeprofile.html @@ -0,0 +1,59 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block title %}{{ object.name }}{% endblock %} + +{% block content %} +
+
+
+

{% trans "Module Type Profile" %}

+ + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+ {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} +
+
+
+

+ {% trans "Schema" %} + {% copy_content 'profile_schema' %} +

+
{{ object.schema|json }}
+
+ {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+
+
+
+

+ {% trans "Module Types" %} + {% if perms.dcim.add_moduletype %} + + {% endif %} +

+ {% htmx_table 'dcim:moduletype_list' profile_id=object.pk %} +
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index 0429fe571..b8985c6b0 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -136,9 +136,11 @@ def get_field_value(form, field_name): """ field = form.fields[field_name] - if form.is_bound and (data := form.data.get(field_name)): - if hasattr(field, 'valid_value') and field.valid_value(data): - return data + if form.is_bound and field_name in form.data: + if (value := form.data[field_name]) is None: + return + if hasattr(field, 'valid_value') and field.valid_value(value): + return value return form.get_initial_for_field(field, field_name) diff --git a/netbox/utilities/jsonschema.py b/netbox/utilities/jsonschema.py new file mode 100644 index 000000000..724253a50 --- /dev/null +++ b/netbox/utilities/jsonschema.py @@ -0,0 +1,166 @@ +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +from django import forms +from django.contrib.postgres.forms import SimpleArrayField +from django.core.exceptions import ValidationError +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from jsonschema.exceptions import SchemaError +from jsonschema.validators import validator_for + +from utilities.string import title +from utilities.validators import MultipleOfValidator + +__all__ = ( + 'JSONSchemaProperty', + 'PropertyTypeEnum', + 'StringFormatEnum', + 'validate_schema', +) + + +class PropertyTypeEnum(Enum): + STRING = 'string' + INTEGER = 'integer' + NUMBER = 'number' + BOOLEAN = 'boolean' + ARRAY = 'array' + OBJECT = 'object' + + +class StringFormatEnum(Enum): + EMAIL = 'email' + URI = 'uri' + IRI = 'iri' + UUID = 'uuid' + DATE = 'date' + TIME = 'time' + DATETIME = 'datetime' + + +FORM_FIELDS = { + PropertyTypeEnum.STRING.value: forms.CharField, + PropertyTypeEnum.INTEGER.value: forms.IntegerField, + PropertyTypeEnum.NUMBER.value: forms.FloatField, + PropertyTypeEnum.BOOLEAN.value: forms.BooleanField, + PropertyTypeEnum.ARRAY.value: SimpleArrayField, + PropertyTypeEnum.OBJECT.value: forms.JSONField, +} + +STRING_FORM_FIELDS = { + StringFormatEnum.EMAIL.value: forms.EmailField, + StringFormatEnum.URI.value: forms.URLField, + StringFormatEnum.IRI.value: forms.URLField, + StringFormatEnum.UUID.value: forms.UUIDField, + StringFormatEnum.DATE.value: forms.DateField, + StringFormatEnum.TIME.value: forms.TimeField, + StringFormatEnum.DATETIME.value: forms.DateTimeField, +} + + +@dataclass +class JSONSchemaProperty: + type: PropertyTypeEnum = PropertyTypeEnum.STRING.value + title: str | None = None + description: str | None = None + default: Any = None + enum: list | None = None + + # Strings + minLength: int | None = None + maxLength: int | None = None + pattern: str | None = None # Regex + format: StringFormatEnum | None = None + + # Numbers + minimum: int | float | None = None + maximum: int | float | None = None + multipleOf: int | float | None = None + + # Arrays + items: dict | None = field(default_factory=dict) + + def to_form_field(self, name, required=False): + """ + Instantiate and return a Django form field suitable for editing the property's value. + """ + field_kwargs = { + 'label': self.title or title(name), + 'help_text': self.description, + 'required': required, + 'initial': self.default, + } + + # Choices + if self.enum: + choices = [(v, v) for v in self.enum] + if not required: + choices = [(None, ''), *choices] + field_kwargs['choices'] = choices + + # Arrays + if self.type == PropertyTypeEnum.ARRAY.value: + items_type = self.items.get('type', PropertyTypeEnum.STRING.value) + field_kwargs['base_field'] = FORM_FIELDS[items_type]() + + # String validation + if self.type == PropertyTypeEnum.STRING.value: + if self.minLength is not None: + field_kwargs['min_length'] = self.minLength + if self.maxLength is not None: + field_kwargs['max_length'] = self.maxLength + if self.pattern is not None: + field_kwargs['validators'] = [ + RegexValidator(regex=self.pattern) + ] + + # Integer/number validation + elif self.type in (PropertyTypeEnum.INTEGER.value, PropertyTypeEnum.NUMBER.value): + field_kwargs['widget'] = forms.NumberInput(attrs={'step': 'any'}) + if self.minimum: + field_kwargs['min_value'] = self.minimum + if self.maximum: + field_kwargs['max_value'] = self.maximum + if self.multipleOf: + field_kwargs['validators'] = [ + MultipleOfValidator(multiple=self.multipleOf) + ] + + return self.field_class(**field_kwargs) + + @property + def field_class(self): + """ + Resolve the property's type (and string format, if specified) to the appropriate field class. + """ + if self.enum: + if self.type == PropertyTypeEnum.ARRAY.value: + return forms.MultipleChoiceField + return forms.ChoiceField + if self.type == PropertyTypeEnum.STRING.value and self.format is not None: + try: + return STRING_FORM_FIELDS[self.format] + except KeyError: + raise ValueError(f"Unsupported string format type: {self.format}") + try: + return FORM_FIELDS[self.type] + except KeyError: + raise ValueError(f"Unknown property type: {self.type}") + + +def validate_schema(schema): + """ + Check that a minimum JSON schema definition is defined. + """ + # Provide some basic sanity checking (not provided by jsonschema) + if not schema or type(schema) is not dict: + raise ValidationError(_("Invalid JSON schema definition")) + if not schema.get('properties'): + raise ValidationError(_("JSON schema must define properties")) + try: + ValidatorClass = validator_for(schema) + ValidatorClass.check_schema(schema) + except SchemaError as e: + raise ValidationError(_("Invalid JSON schema definition: {error}").format(error=e)) diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py index a0592f626..8ec1404d5 100644 --- a/netbox/utilities/tests/test_forms.py +++ b/netbox/utilities/tests/test_forms.py @@ -1,10 +1,11 @@ from django import forms from django.test import TestCase +from dcim.models import Site from netbox.choices import ImportFormatChoices from utilities.forms.bulk_import import BulkImportForm from utilities.forms.forms import BulkRenameForm -from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern +from utilities.forms.utils import get_field_value, expand_alphanumeric_pattern, expand_ipaddress_pattern class ExpandIPAddress(TestCase): @@ -387,3 +388,63 @@ class BulkRenameFormTest(TestCase): self.assertTrue(form.is_valid()) self.assertEqual(form.cleaned_data["find"], " hello ") self.assertEqual(form.cleaned_data["replace"], " world ") + + +class GetFieldValueTest(TestCase): + + @classmethod + def setUpTestData(cls): + class TestForm(forms.Form): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False + ) + cls.form_class = TestForm + + cls.sites = ( + Site(name='Test Site 1', slug='test-site-1'), + Site(name='Test Site 2', slug='test-site-2'), + ) + Site.objects.bulk_create(cls.sites) + + def test_unbound_without_initial(self): + form = self.form_class() + self.assertEqual( + get_field_value(form, 'site'), + None + ) + + def test_unbound_with_initial(self): + form = self.form_class(initial={'site': self.sites[0].pk}) + self.assertEqual( + get_field_value(form, 'site'), + self.sites[0].pk + ) + + def test_bound_value_without_initial(self): + form = self.form_class({'site': self.sites[0].pk}) + self.assertEqual( + get_field_value(form, 'site'), + self.sites[0].pk + ) + + def test_bound_value_with_initial(self): + form = self.form_class({'site': self.sites[0].pk}, initial={'site': self.sites[1].pk}) + self.assertEqual( + get_field_value(form, 'site'), + self.sites[0].pk + ) + + def test_bound_null_without_initial(self): + form = self.form_class({'site': None}) + self.assertEqual( + get_field_value(form, 'site'), + None + ) + + def test_bound_null_with_initial(self): + form = self.form_class({'site': None}, initial={'site': self.sites[1].pk}) + self.assertEqual( + get_field_value(form, 'site'), + None + ) diff --git a/netbox/utilities/validators.py b/netbox/utilities/validators.py index 0e896e52a..4b7529472 100644 --- a/netbox/utilities/validators.py +++ b/netbox/utilities/validators.py @@ -1,3 +1,4 @@ +import decimal import re from django.core.exceptions import ValidationError @@ -10,6 +11,7 @@ __all__ = ( 'ColorValidator', 'EnhancedURLValidator', 'ExclusionValidator', + 'MultipleOfValidator', 'validate_regex', ) @@ -54,6 +56,22 @@ class ExclusionValidator(BaseValidator): return a in b +class MultipleOfValidator(BaseValidator): + """ + Checks that a field's value is a numeric multiple of the given value. Both values are + cast as Decimals for comparison. + """ + def __init__(self, multiple): + self.multiple = decimal.Decimal(str(multiple)) + super().__init__(limit_value=None) + + def __call__(self, value): + if decimal.Decimal(str(value)) % self.multiple != 0: + raise ValidationError( + _("{value} must be a multiple of {multiple}.").format(value=value, multiple=self.multiple) + ) + + def validate_regex(value): """ Checks that the value is a valid regular expression. (Don't confuse this with RegexValidator, which *uses* a regex diff --git a/requirements.txt b/requirements.txt index 210a0b1d8..06e0e1617 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,6 +20,7 @@ drf-spectacular-sidecar==2025.2.1 feedparser==6.0.11 gunicorn==23.0.0 Jinja2==3.1.5 +jsonschema==4.23.0 Markdown==3.7 mkdocs-material==9.6.7 mkdocstrings[python]==0.28.2 From a00144026b23c0d07f64cb69df27b2841f902916 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 1 Apr 2025 15:09:49 -0400 Subject: [PATCH 062/103] Closes #16630: Enable plugins to embed custom content (#19055) --- docs/plugins/development/views.md | 1 + netbox/netbox/plugins/templates.py | 7 +++++++ netbox/netbox/tests/dummy_plugin/template_content.py | 3 +++ netbox/templates/base/base.html | 2 ++ netbox/utilities/templatetags/plugins.py | 8 ++++++++ 5 files changed, 21 insertions(+) diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md index e3740de59..43cc0ce82 100644 --- a/docs/plugins/development/views.md +++ b/docs/plugins/development/views.md @@ -198,6 +198,7 @@ Plugins can inject custom content into certain areas of core NetBox views. This | Method | View | Description | |---------------------|-------------|-----------------------------------------------------| +| `head()` | All | Custom HTML `` block includes | | `navbar()` | All | Inject content inside the top navigation bar | | `list_buttons()` | List view | Add buttons to the top of the page | | `buttons()` | Object view | Add buttons to the top of the page | diff --git a/netbox/netbox/plugins/templates.py b/netbox/netbox/plugins/templates.py index 58f9ad80e..586391d4f 100644 --- a/netbox/netbox/plugins/templates.py +++ b/netbox/netbox/plugins/templates.py @@ -47,6 +47,13 @@ class PluginTemplateExtension: # Global methods # + def head(self): + """ + HTML returned by this method will be inserted in the page's `` block. This may be useful e.g. for + including additional Javascript or CSS resources. + """ + raise NotImplementedError + def navbar(self): """ Content that will be rendered inside the top navigation menu. Content should be returned as an HTML diff --git a/netbox/netbox/tests/dummy_plugin/template_content.py b/netbox/netbox/tests/dummy_plugin/template_content.py index e9a6b9da1..e962594d4 100644 --- a/netbox/netbox/tests/dummy_plugin/template_content.py +++ b/netbox/netbox/tests/dummy_plugin/template_content.py @@ -3,6 +3,9 @@ from netbox.plugins.templates import PluginTemplateExtension class GlobalContent(PluginTemplateExtension): + def head(self): + return "" + def navbar(self): return "GLOBAL CONTENT - NAVBAR" diff --git a/netbox/templates/base/base.html b/netbox/templates/base/base.html index 7ca2f575d..443562027 100644 --- a/netbox/templates/base/base.html +++ b/netbox/templates/base/base.html @@ -3,6 +3,7 @@ {% load helpers %} {% load i18n %} {% load django_htmx %} +{% load plugins %} content #} {% block head %}{% endblock %} + {% plugin_head %} diff --git a/netbox/utilities/templatetags/plugins.py b/netbox/utilities/templatetags/plugins.py index 16e65d697..40e6b8196 100644 --- a/netbox/utilities/templatetags/plugins.py +++ b/netbox/utilities/templatetags/plugins.py @@ -45,6 +45,14 @@ def _get_registered_content(obj, method, template_context): return mark_safe(html) +@register.simple_tag(takes_context=True) +def plugin_head(context): + """ + Render any content embedded by plugins + """ + return _get_registered_content(None, 'head', context) + + @register.simple_tag(takes_context=True) def plugin_navbar(context): """ From 6a966ee6c1124abf0d0028c5af54823083f51524 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 1 Apr 2025 17:06:23 -0400 Subject: [PATCH 063/103] Closes #18785: Allow for custom rack/device/module airflow choices (#19054) --- netbox/dcim/choices.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 8bd41b3d2..47d774bd2 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -128,14 +128,15 @@ class RackElevationDetailRenderChoices(ChoiceSet): class RackAirflowChoices(ChoiceSet): + key = 'Rack.airflow' FRONT_TO_REAR = 'front-to-rear' REAR_TO_FRONT = 'rear-to-front' - CHOICES = ( + CHOICES = [ (FRONT_TO_REAR, _('Front to rear')), (REAR_TO_FRONT, _('Rear to front')), - ) + ] # @@ -191,6 +192,7 @@ class DeviceStatusChoices(ChoiceSet): class DeviceAirflowChoices(ChoiceSet): + key = 'Device.airflow' AIRFLOW_FRONT_TO_REAR = 'front-to-rear' AIRFLOW_REAR_TO_FRONT = 'rear-to-front' @@ -203,7 +205,7 @@ class DeviceAirflowChoices(ChoiceSet): AIRFLOW_PASSIVE = 'passive' AIRFLOW_MIXED = 'mixed' - CHOICES = ( + CHOICES = [ (AIRFLOW_FRONT_TO_REAR, _('Front to rear')), (AIRFLOW_REAR_TO_FRONT, _('Rear to front')), (AIRFLOW_LEFT_TO_RIGHT, _('Left to right')), @@ -214,7 +216,7 @@ class DeviceAirflowChoices(ChoiceSet): (AIRFLOW_TOP_TO_BOTTOM, _('Top to bottom')), (AIRFLOW_PASSIVE, _('Passive')), (AIRFLOW_MIXED, _('Mixed')), - ) + ] # @@ -242,6 +244,7 @@ class ModuleStatusChoices(ChoiceSet): class ModuleAirflowChoices(ChoiceSet): + key = 'Module.airflow' FRONT_TO_REAR = 'front-to-rear' REAR_TO_FRONT = 'rear-to-front' @@ -250,14 +253,14 @@ class ModuleAirflowChoices(ChoiceSet): SIDE_TO_REAR = 'side-to-rear' PASSIVE = 'passive' - CHOICES = ( + CHOICES = [ (FRONT_TO_REAR, _('Front to rear')), (REAR_TO_FRONT, _('Rear to front')), (LEFT_TO_RIGHT, _('Left to right')), (RIGHT_TO_LEFT, _('Right to left')), (SIDE_TO_REAR, _('Side to rear')), (PASSIVE, _('Passive')), - ) + ] # From d93d398afa1920c59a81c3272e0627bb6c38fa47 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 3 Apr 2025 09:17:20 -0400 Subject: [PATCH 064/103] Closes #17166: Remove obsolete limit_choices_to argument from ForeignKey & M2M fields --- .../0047_circuittermination__termination.py | 3 -- .../0051_virtualcircuit_group_assignment.py | 2 - netbox/circuits/models/circuits.py | 2 - netbox/dcim/migrations/0003_squashed_0130.py | 44 ---------------- netbox/dcim/migrations/0131_squashed_0159.py | 52 ------------------- netbox/dcim/migrations/0199_macaddress.py | 7 --- netbox/dcim/models/cables.py | 1 - .../dcim/models/device_component_templates.py | 1 - netbox/dcim/models/device_components.py | 1 - netbox/dcim/models/devices.py | 1 - netbox/dcim/models/mixins.py | 2 - netbox/ipam/migrations/0001_squashed.py | 6 --- netbox/ipam/migrations/0002_squashed_0046.py | 7 --- netbox/ipam/migrations/0047_squashed_0053.py | 8 --- netbox/ipam/migrations/0054_squashed_0067.py | 8 --- netbox/ipam/migrations/0071_prefix_scope.py | 1 - netbox/ipam/models/ip.py | 1 - netbox/ipam/models/vlans.py | 1 - netbox/users/migrations/0001_squashed_0011.py | 14 ----- ...07_objectpermission_update_object_types.py | 19 +------ .../migrations/0009_update_group_perms.py | 18 +------ netbox/users/models/permissions.py | 2 - .../migrations/0001_squashed_0022.py | 1 - .../migrations/0044_cluster_scope.py | 1 - .../virtualization/models/virtualmachines.py | 1 - netbox/vpn/migrations/0002_move_l2vpn.py | 8 --- netbox/vpn/models/l2vpn.py | 2 - .../wireless/migrations/0001_squashed_0008.py | 3 -- ...__location_wirelesslan__region_and_more.py | 1 - netbox/wireless/models.py | 8 --- 30 files changed, 2 insertions(+), 224 deletions(-) diff --git a/netbox/circuits/migrations/0047_circuittermination__termination.py b/netbox/circuits/migrations/0047_circuittermination__termination.py index f78e17ec3..4caa3a37d 100644 --- a/netbox/circuits/migrations/0047_circuittermination__termination.py +++ b/netbox/circuits/migrations/0047_circuittermination__termination.py @@ -39,9 +39,6 @@ class Migration(migrations.Migration): name='termination_type', field=models.ForeignKey( blank=True, - limit_choices_to=models.Q( - ('model__in', ('region', 'sitegroup', 'site', 'location', 'providernetwork')) - ), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', diff --git a/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py b/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py index f8c0fd653..0418c26e5 100644 --- a/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py +++ b/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py @@ -51,7 +51,6 @@ class Migration(migrations.Migration): name='member_type', field=models.ForeignKey( on_delete=django.db.models.deletion.PROTECT, - limit_choices_to=models.Q(('app_label', 'circuits'), ('model__in', ['circuit', 'virtualcircuit'])), related_name='+', to='contenttypes.contenttype', blank=True, @@ -68,7 +67,6 @@ class Migration(migrations.Migration): model_name='circuitgroupassignment', name='member_type', field=models.ForeignKey( - limit_choices_to=models.Q(('app_label', 'circuits'), ('model__in', ['circuit', 'virtualcircuit'])), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype' diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 9c7714153..102377296 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -182,7 +182,6 @@ class CircuitGroupAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, """ member_type = models.ForeignKey( to='contenttypes.ContentType', - limit_choices_to=CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS, on_delete=models.PROTECT, related_name='+' ) @@ -249,7 +248,6 @@ class CircuitTermination( termination_type = models.ForeignKey( to='contenttypes.ContentType', on_delete=models.PROTECT, - limit_choices_to=Q(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES), related_name='+', blank=True, null=True diff --git a/netbox/dcim/migrations/0003_squashed_0130.py b/netbox/dcim/migrations/0003_squashed_0130.py index 330a64e63..490ab8e8b 100644 --- a/netbox/dcim/migrations/0003_squashed_0130.py +++ b/netbox/dcim/migrations/0003_squashed_0130.py @@ -505,28 +505,6 @@ class Migration(migrations.Migration): model_name='cable', name='termination_a_type', field=models.ForeignKey( - limit_choices_to=models.Q( - models.Q( - models.Q(('app_label', 'circuits'), ('model__in', ('circuittermination',))), - models.Q( - ('app_label', 'dcim'), - ( - 'model__in', - ( - 'consoleport', - 'consoleserverport', - 'frontport', - 'interface', - 'powerfeed', - 'poweroutlet', - 'powerport', - 'rearport', - ), - ), - ), - _connector='OR', - ) - ), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype', @@ -536,28 +514,6 @@ class Migration(migrations.Migration): model_name='cable', name='termination_b_type', field=models.ForeignKey( - limit_choices_to=models.Q( - models.Q( - models.Q(('app_label', 'circuits'), ('model__in', ('circuittermination',))), - models.Q( - ('app_label', 'dcim'), - ( - 'model__in', - ( - 'consoleport', - 'consoleserverport', - 'frontport', - 'interface', - 'powerfeed', - 'poweroutlet', - 'powerport', - 'rearport', - ), - ), - ), - _connector='OR', - ) - ), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype', diff --git a/netbox/dcim/migrations/0131_squashed_0159.py b/netbox/dcim/migrations/0131_squashed_0159.py index 7c2ef7006..1c1f2ff38 100644 --- a/netbox/dcim/migrations/0131_squashed_0159.py +++ b/netbox/dcim/migrations/0131_squashed_0159.py @@ -866,21 +866,6 @@ class Migration(migrations.Migration): name='component_type', field=models.ForeignKey( blank=True, - limit_choices_to=models.Q( - ('app_label', 'dcim'), - ( - 'model__in', - ( - 'consoleport', - 'consoleserverport', - 'frontport', - 'interface', - 'poweroutlet', - 'powerport', - 'rearport', - ), - ), - ), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', @@ -1238,21 +1223,6 @@ class Migration(migrations.Migration): 'component_type', models.ForeignKey( blank=True, - limit_choices_to=models.Q( - ('app_label', 'dcim'), - ( - 'model__in', - ( - 'consoleporttemplate', - 'consoleserverporttemplate', - 'frontporttemplate', - 'interfacetemplate', - 'poweroutlettemplate', - 'powerporttemplate', - 'rearporttemplate', - ), - ), - ), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', @@ -1478,28 +1448,6 @@ class Migration(migrations.Migration): ( 'termination_type', models.ForeignKey( - limit_choices_to=models.Q( - models.Q( - models.Q(('app_label', 'circuits'), ('model__in', ('circuittermination',))), - models.Q( - ('app_label', 'dcim'), - ( - 'model__in', - ( - 'consoleport', - 'consoleserverport', - 'frontport', - 'interface', - 'powerfeed', - 'poweroutlet', - 'powerport', - 'rearport', - ), - ), - ), - _connector='OR', - ) - ), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype', diff --git a/netbox/dcim/migrations/0199_macaddress.py b/netbox/dcim/migrations/0199_macaddress.py index ae18d5f63..c668858b4 100644 --- a/netbox/dcim/migrations/0199_macaddress.py +++ b/netbox/dcim/migrations/0199_macaddress.py @@ -31,13 +31,6 @@ class Migration(migrations.Migration): 'assigned_object_type', models.ForeignKey( blank=True, - limit_choices_to=models.Q( - models.Q( - models.Q(('app_label', 'dcim'), ('model', 'interface')), - models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), - _connector='OR', - ) - ), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 7117ea7e0..f3829a8bf 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -259,7 +259,6 @@ class CableTermination(ChangeLoggedModel): ) termination_type = models.ForeignKey( to='contenttypes.ContentType', - limit_choices_to=CABLE_TERMINATION_MODELS, on_delete=models.PROTECT, related_name='+' ) diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index b4f057711..e0b05b388 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -751,7 +751,6 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): ) component_type = models.ForeignKey( to='contenttypes.ContentType', - limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS, on_delete=models.PROTECT, related_name='+', blank=True, diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 6a994d770..0ff74529f 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1274,7 +1274,6 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin): ) component_type = models.ForeignKey( to='contenttypes.ContentType', - limit_choices_to=MODULAR_COMPONENT_MODELS, on_delete=models.PROTECT, related_name='+', blank=True, diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index ae6d9c316..f9c72b7c6 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1225,7 +1225,6 @@ class MACAddress(PrimaryModel): ) assigned_object_type = models.ForeignKey( to='contenttypes.ContentType', - limit_choices_to=MACADDRESS_ASSIGNMENT_MODELS, on_delete=models.PROTECT, related_name='+', blank=True, diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index a0fc15a25..127dfb9e5 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -3,7 +3,6 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ -from dcim.constants import LOCATION_SCOPE_TYPES __all__ = ( 'CachedScopeMixin', @@ -44,7 +43,6 @@ class CachedScopeMixin(models.Model): scope_type = models.ForeignKey( to='contenttypes.ContentType', on_delete=models.PROTECT, - limit_choices_to=models.Q(model__in=LOCATION_SCOPE_TYPES), related_name='+', blank=True, null=True diff --git a/netbox/ipam/migrations/0001_squashed.py b/netbox/ipam/migrations/0001_squashed.py index 8df31a4d6..15fb71dde 100644 --- a/netbox/ipam/migrations/0001_squashed.py +++ b/netbox/ipam/migrations/0001_squashed.py @@ -195,12 +195,6 @@ class Migration(migrations.Migration): 'scope_type', models.ForeignKey( blank=True, - limit_choices_to=models.Q( - ( - 'model__in', - ('region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster'), - ) - ), null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', diff --git a/netbox/ipam/migrations/0002_squashed_0046.py b/netbox/ipam/migrations/0002_squashed_0046.py index 428a93f3a..43b1223d0 100644 --- a/netbox/ipam/migrations/0002_squashed_0046.py +++ b/netbox/ipam/migrations/0002_squashed_0046.py @@ -154,13 +154,6 @@ class Migration(migrations.Migration): name='assigned_object_type', field=models.ForeignKey( blank=True, - limit_choices_to=models.Q( - models.Q( - models.Q(('app_label', 'dcim'), ('model', 'interface')), - models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), - _connector='OR', - ) - ), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', diff --git a/netbox/ipam/migrations/0047_squashed_0053.py b/netbox/ipam/migrations/0047_squashed_0053.py index e10bdf7e0..151792eb6 100644 --- a/netbox/ipam/migrations/0047_squashed_0053.py +++ b/netbox/ipam/migrations/0047_squashed_0053.py @@ -136,14 +136,6 @@ class Migration(migrations.Migration): name='assigned_object_type', field=models.ForeignKey( blank=True, - limit_choices_to=models.Q( - models.Q( - models.Q(('app_label', 'dcim'), ('model', 'interface')), - models.Q(('app_label', 'ipam'), ('model', 'fhrpgroup')), - models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), - _connector='OR', - ) - ), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', diff --git a/netbox/ipam/migrations/0054_squashed_0067.py b/netbox/ipam/migrations/0054_squashed_0067.py index 34ef34bb5..26bd54115 100644 --- a/netbox/ipam/migrations/0054_squashed_0067.py +++ b/netbox/ipam/migrations/0054_squashed_0067.py @@ -304,14 +304,6 @@ class Migration(migrations.Migration): ( 'assigned_object_type', models.ForeignKey( - limit_choices_to=models.Q( - models.Q( - models.Q(('app_label', 'dcim'), ('model', 'interface')), - models.Q(('app_label', 'ipam'), ('model', 'vlan')), - models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), - _connector='OR', - ) - ), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype', diff --git a/netbox/ipam/migrations/0071_prefix_scope.py b/netbox/ipam/migrations/0071_prefix_scope.py index 2ab54d023..47a971750 100644 --- a/netbox/ipam/migrations/0071_prefix_scope.py +++ b/netbox/ipam/migrations/0071_prefix_scope.py @@ -33,7 +33,6 @@ class Migration(migrations.Migration): name='scope_type', field=models.ForeignKey( blank=True, - limit_choices_to=models.Q(('model__in', ('region', 'sitegroup', 'site', 'location'))), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index e1a8d91e3..26eff5c82 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -742,7 +742,6 @@ class IPAddress(ContactsMixin, PrimaryModel): ) assigned_object_type = models.ForeignKey( to='contenttypes.ContentType', - limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS, on_delete=models.PROTECT, related_name='+', blank=True, diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index b639fd185..98491ad23 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -45,7 +45,6 @@ class VLANGroup(OrganizationalModel): scope_type = models.ForeignKey( to='contenttypes.ContentType', on_delete=models.CASCADE, - limit_choices_to=Q(model__in=VLANGROUP_SCOPE_TYPES), blank=True, null=True ) diff --git a/netbox/users/migrations/0001_squashed_0011.py b/netbox/users/migrations/0001_squashed_0011.py index 263604d34..ffba6f21b 100644 --- a/netbox/users/migrations/0001_squashed_0011.py +++ b/netbox/users/migrations/0001_squashed_0011.py @@ -132,20 +132,6 @@ class Migration(migrations.Migration): ( 'object_types', models.ManyToManyField( - limit_choices_to=models.Q( - models.Q( - models.Q( - ( - 'app_label__in', - ['account', 'admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users'], - ), - _negated=True, - ), - models.Q(('app_label', 'auth'), ('model__in', ['group', 'user'])), - models.Q(('app_label', 'users'), ('model__in', ['objectpermission', 'token'])), - _connector='OR', - ) - ), related_name='object_permissions', to='contenttypes.ContentType', ), diff --git a/netbox/users/migrations/0007_objectpermission_update_object_types.py b/netbox/users/migrations/0007_objectpermission_update_object_types.py index 598b00b92..3be93270b 100644 --- a/netbox/users/migrations/0007_objectpermission_update_object_types.py +++ b/netbox/users/migrations/0007_objectpermission_update_object_types.py @@ -13,23 +13,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='objectpermission', name='object_types', - field=models.ManyToManyField( - limit_choices_to=models.Q( - models.Q( - models.Q( - ( - 'app_label__in', - ['account', 'admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users'], - ), - _negated=True, - ), - models.Q(('app_label', 'auth'), ('model__in', ['group', 'user'])), - models.Q(('app_label', 'users'), ('model__in', ['objectpermission', 'token'])), - _connector='OR', - ) - ), - related_name='object_permissions', - to='core.objecttype', - ), + field=models.ManyToManyField(related_name='object_permissions', to='core.objecttype'), ), ] diff --git a/netbox/users/migrations/0009_update_group_perms.py b/netbox/users/migrations/0009_update_group_perms.py index 7698fd1e7..63fdeffec 100644 --- a/netbox/users/migrations/0009_update_group_perms.py +++ b/netbox/users/migrations/0009_update_group_perms.py @@ -28,22 +28,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='objectpermission', name='object_types', - field=models.ManyToManyField( - limit_choices_to=models.Q( - models.Q( - models.Q( - ( - 'app_label__in', - ['account', 'admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users'], - ), - _negated=True, - ), - models.Q(('app_label', 'users'), ('model__in', ['objectpermission', 'token', 'group', 'user'])), - _connector='OR', - ) - ), - related_name='object_permissions', - to='core.objecttype', - ), + field=models.ManyToManyField(related_name='object_permissions', to='core.objecttype'), ), ] diff --git a/netbox/users/models/permissions.py b/netbox/users/models/permissions.py index 8b471f12b..772adcdb7 100644 --- a/netbox/users/models/permissions.py +++ b/netbox/users/models/permissions.py @@ -3,7 +3,6 @@ from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from users.constants import OBJECTPERMISSION_OBJECT_TYPES from utilities.querysets import RestrictedQuerySet __all__ = ( @@ -31,7 +30,6 @@ class ObjectPermission(models.Model): ) object_types = models.ManyToManyField( to='core.ObjectType', - limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES, related_name='object_permissions' ) actions = ArrayField( diff --git a/netbox/virtualization/migrations/0001_squashed_0022.py b/netbox/virtualization/migrations/0001_squashed_0022.py index 482108a76..caa890b13 100644 --- a/netbox/virtualization/migrations/0001_squashed_0022.py +++ b/netbox/virtualization/migrations/0001_squashed_0022.py @@ -154,7 +154,6 @@ class Migration(migrations.Migration): 'role', models.ForeignKey( blank=True, - limit_choices_to={'vm_role': True}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', diff --git a/netbox/virtualization/migrations/0044_cluster_scope.py b/netbox/virtualization/migrations/0044_cluster_scope.py index 521db1877..31dd72989 100644 --- a/netbox/virtualization/migrations/0044_cluster_scope.py +++ b/netbox/virtualization/migrations/0044_cluster_scope.py @@ -32,7 +32,6 @@ class Migration(migrations.Migration): name='scope_type', field=models.ForeignKey( blank=True, - limit_choices_to=models.Q(('model__in', ('region', 'sitegroup', 'site', 'location'))), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index fab30c6f2..1922922e8 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -82,7 +82,6 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co to='dcim.DeviceRole', on_delete=models.PROTECT, related_name='virtual_machines', - limit_choices_to={'vm_role': True}, blank=True, null=True ) diff --git a/netbox/vpn/migrations/0002_move_l2vpn.py b/netbox/vpn/migrations/0002_move_l2vpn.py index 5f1480dce..41ccb8a8d 100644 --- a/netbox/vpn/migrations/0002_move_l2vpn.py +++ b/netbox/vpn/migrations/0002_move_l2vpn.py @@ -72,14 +72,6 @@ class Migration(migrations.Migration): ( 'assigned_object_type', models.ForeignKey( - limit_choices_to=models.Q( - models.Q( - models.Q(('app_label', 'dcim'), ('model', 'interface')), - models.Q(('app_label', 'ipam'), ('model', 'vlan')), - models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), - _connector='OR', - ) - ), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype', diff --git a/netbox/vpn/models/l2vpn.py b/netbox/vpn/models/l2vpn.py index 575f6e234..463c650a1 100644 --- a/netbox/vpn/models/l2vpn.py +++ b/netbox/vpn/models/l2vpn.py @@ -8,7 +8,6 @@ from core.models import ObjectType from netbox.models import NetBoxModel, PrimaryModel from netbox.models.features import ContactsMixin from vpn.choices import L2VPNStatusChoices, L2VPNTypeChoices -from vpn.constants import L2VPN_ASSIGNMENT_MODELS __all__ = ( 'L2VPN', @@ -93,7 +92,6 @@ class L2VPNTermination(NetBoxModel): ) assigned_object_type = models.ForeignKey( to='contenttypes.ContentType', - limit_choices_to=L2VPN_ASSIGNMENT_MODELS, on_delete=models.PROTECT, related_name='+' ) diff --git a/netbox/wireless/migrations/0001_squashed_0008.py b/netbox/wireless/migrations/0001_squashed_0008.py index 75d9ae22a..2ffc287f9 100644 --- a/netbox/wireless/migrations/0001_squashed_0008.py +++ b/netbox/wireless/migrations/0001_squashed_0008.py @@ -4,7 +4,6 @@ import taggit.managers from django.db import migrations, models import utilities.json -import wireless.models class Migration(migrations.Migration): @@ -149,7 +148,6 @@ class Migration(migrations.Migration): ( 'interface_a', models.ForeignKey( - limit_choices_to=wireless.models.get_wireless_interface_types, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface', @@ -158,7 +156,6 @@ class Migration(migrations.Migration): ( 'interface_b', models.ForeignKey( - limit_choices_to=wireless.models.get_wireless_interface_types, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface', diff --git a/netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py b/netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py index 334d41bdd..bac1819dd 100644 --- a/netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py +++ b/netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py @@ -66,7 +66,6 @@ class Migration(migrations.Migration): name='scope_type', field=models.ForeignKey( blank=True, - limit_choices_to=models.Q(('model__in', ('region', 'sitegroup', 'site', 'location'))), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 61ff72bc1..6cdc6fc5b 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -123,26 +123,18 @@ class WirelessLAN(WirelessAuthenticationBase, CachedScopeMixin, PrimaryModel): return WirelessLANStatusChoices.colors.get(self.status) -def get_wireless_interface_types(): - # Wrap choices in a callable to avoid generating dummy migrations - # when the choices are updated. - return {'type__in': WIRELESS_IFACE_TYPES} - - class WirelessLink(WirelessAuthenticationBase, DistanceMixin, PrimaryModel): """ A point-to-point connection between two wireless Interfaces. """ interface_a = models.ForeignKey( to='dcim.Interface', - limit_choices_to=get_wireless_interface_types, on_delete=models.PROTECT, related_name='+', verbose_name=_('interface A'), ) interface_b = models.ForeignKey( to='dcim.Interface', - limit_choices_to=get_wireless_interface_types, on_delete=models.PROTECT, related_name='+', verbose_name=_('interface B'), From d44012963f8c49a5e3b6483eedad7bf81ea57f68 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 3 Apr 2025 14:49:54 -0400 Subject: [PATCH 065/103] Closes #19004: Mark inventory items as deprecated in the documentation --- docs/models/dcim/inventoryitem.md | 3 +++ docs/models/dcim/inventoryitemrole.md | 3 +++ docs/models/dcim/inventoryitemtemplate.md | 3 +++ 3 files changed, 9 insertions(+) diff --git a/docs/models/dcim/inventoryitem.md b/docs/models/dcim/inventoryitem.md index 2d648341b..6aed0fc86 100644 --- a/docs/models/dcim/inventoryitem.md +++ b/docs/models/dcim/inventoryitem.md @@ -1,5 +1,8 @@ # Inventory Items +!!! warning "Deprecation Warning" + Beginning in NetBox v4.3, the use of inventory items has been deprecated. They are planned for removal in a future NetBox release. Users are strongly encouraged to begin using [modules](./module.md) and [module types](./moduletype.md) in place of inventory items. Modules provide enhanced functionality and can be configured with user-defined attributes. + Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. They are intended to be used primarily for inventory purposes. Inventory items are hierarchical in nature, such that any individual item may be designated as the parent for other items. For example, an inventory item might be created to represent a line card which houses several SFP optics, each of which exists as a child item within the device. An inventory item may also be associated with a specific component within the same device. For example, you may wish to associate a transceiver with an interface. diff --git a/docs/models/dcim/inventoryitemrole.md b/docs/models/dcim/inventoryitemrole.md index 50eb61abd..b77637604 100644 --- a/docs/models/dcim/inventoryitemrole.md +++ b/docs/models/dcim/inventoryitemrole.md @@ -1,5 +1,8 @@ # Inventory Item Roles +!!! warning "Deprecation Warning" + Beginning in NetBox v4.3, the use of inventory items has been deprecated. They are planned for removal in a future NetBox release. Users are strongly encouraged to begin using [modules](./module.md) and [module types](./moduletype.md) in place of inventory items. Modules provide enhanced functionality and can be configured with user-defined attributes. + Inventory items can be organized by functional roles, which are fully customizable by the user. For example, you might create roles for power supplies, fans, interface optics, etc. ## Fields diff --git a/docs/models/dcim/inventoryitemtemplate.md b/docs/models/dcim/inventoryitemtemplate.md index 02fde5995..7d8ff504d 100644 --- a/docs/models/dcim/inventoryitemtemplate.md +++ b/docs/models/dcim/inventoryitemtemplate.md @@ -1,3 +1,6 @@ # Inventory Item Templates +!!! warning "Deprecation Warning" + Beginning in NetBox v4.3, the use of inventory items has been deprecated. They are planned for removal in a future NetBox release. Users are strongly encouraged to begin using [modules](./module.md) and [module types](./moduletype.md) in place of inventory items. Modules provide enhanced functionality and can be configured with user-defined attributes. + A template for an inventory item that will be automatically created when instantiating a new device. All attributes of this object will be copied to the new inventory item, including the associations with a parent item and assigned component, if any. See the [inventory item](./inventoryitem.md) documentation for more detail. From 67480dcf4fa1823f36c0e6e49c6095e29766d1a6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 3 Apr 2025 16:16:57 -0400 Subject: [PATCH 066/103] Closes #18191: Remove duplicate SQL indexes (#19074) * Closes #18191: Remove redundant SQL indexes * Update developer documentation * Add a system check for duplicate indexes --- docs/development/extending-models.md | 2 +- netbox/core/apps.py | 1 + netbox/core/checks.py | 41 +++++++++++++++++++ .../0014_remove_redundant_indexes.py | 25 +++++++++++ netbox/core/models/data.py | 6 --- netbox/core/models/files.py | 3 -- .../0207_remove_redundant_indexes.py | 17 ++++++++ netbox/dcim/models/cables.py | 3 -- .../0009_remove_redundant_indexes.py | 21 ++++++++++ netbox/vpn/models/l2vpn.py | 3 -- netbox/vpn/models/tunnels.py | 3 -- 11 files changed, 106 insertions(+), 19 deletions(-) create mode 100644 netbox/core/checks.py create mode 100644 netbox/core/migrations/0014_remove_redundant_indexes.py create mode 100644 netbox/dcim/migrations/0207_remove_redundant_indexes.py create mode 100644 netbox/vpn/migrations/0009_remove_redundant_indexes.py diff --git a/docs/development/extending-models.md b/docs/development/extending-models.md index 16d1c3451..d870a371d 100644 --- a/docs/development/extending-models.md +++ b/docs/development/extending-models.md @@ -6,7 +6,7 @@ Below is a list of tasks to consider when adding a new field to a core model. Add the field to the model, taking care to address any of the following conditions. -* When adding a GenericForeignKey field, also add an index under `Meta` for its two concrete fields. For example: +* When adding a GenericForeignKey field, you may need add an index under `Meta` for its two concrete fields. (This is required only for non-unique GFK relationships, as the unique constraint introduces its own index.) For example: ```python class Meta: diff --git a/netbox/core/apps.py b/netbox/core/apps.py index b1337c7ed..f74a01aa9 100644 --- a/netbox/core/apps.py +++ b/netbox/core/apps.py @@ -19,6 +19,7 @@ class CoreConfig(AppConfig): def ready(self): from core.api import schema # noqa: F401 + from core.checks import check_duplicate_indexes # noqa: F401 from netbox.models.features import register_models from . import data_backends, events, search # noqa: F401 from netbox import context_managers # noqa: F401 diff --git a/netbox/core/checks.py b/netbox/core/checks.py new file mode 100644 index 000000000..cab52a025 --- /dev/null +++ b/netbox/core/checks.py @@ -0,0 +1,41 @@ +from django.core.checks import Error, register, Tags +from django.db.models import Index, UniqueConstraint +from django.apps import apps + +__all__ = ( + 'check_duplicate_indexes', +) + + +@register(Tags.models) +def check_duplicate_indexes(app_configs, **kwargs): + """ + Check for an index which is redundant to a declared unique constraint. + """ + errors = [] + + for model in apps.get_models(): + if not (meta := getattr(model, "_meta", None)): + continue + + index_fields = { + tuple(index.fields) for index in getattr(meta, 'indexes', []) + if isinstance(index, Index) + } + constraint_fields = { + tuple(constraint.fields) for constraint in getattr(meta, 'constraints', []) + if isinstance(constraint, UniqueConstraint) + } + + # Find overlapping definitions + if duplicated := index_fields & constraint_fields: + for fields in duplicated: + errors.append( + Error( + f"Model '{model.__name__}' defines the same field set {fields} in both `Meta.indexes` and " + f"`Meta.constraints`.", + obj=model, + ) + ) + + return errors diff --git a/netbox/core/migrations/0014_remove_redundant_indexes.py b/netbox/core/migrations/0014_remove_redundant_indexes.py new file mode 100644 index 000000000..fc90a67fa --- /dev/null +++ b/netbox/core/migrations/0014_remove_redundant_indexes.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2b1 on 2025-04-03 18:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0013_datasource_sync_interval'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='autosyncrecord', + name='core_autosy_object__c17bac_idx', + ), + migrations.RemoveIndex( + model_name='datafile', + name='core_datafile_source_path', + ), + migrations.RemoveIndex( + model_name='managedfile', + name='core_managedfile_root_path', + ), + ] diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 9a5da333a..52a11c58e 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -310,9 +310,6 @@ class DataFile(models.Model): name='%(app_label)s_%(class)s_unique_source_path' ), ) - indexes = [ - models.Index(fields=('source', 'path'), name='core_datafile_source_path'), - ] verbose_name = _('data file') verbose_name_plural = _('data files') @@ -387,8 +384,5 @@ class AutoSyncRecord(models.Model): name='%(app_label)s_%(class)s_object' ), ) - indexes = ( - models.Index(fields=('object_type', 'object_id')), - ) verbose_name = _('auto sync record') verbose_name_plural = _('auto sync records') diff --git a/netbox/core/models/files.py b/netbox/core/models/files.py index ade13627f..d60269b8b 100644 --- a/netbox/core/models/files.py +++ b/netbox/core/models/files.py @@ -58,9 +58,6 @@ class ManagedFile(SyncedDataMixin, models.Model): name='%(app_label)s_%(class)s_unique_root_path' ), ) - indexes = [ - models.Index(fields=('file_root', 'file_path'), name='core_managedfile_root_path'), - ] verbose_name = _('managed file') verbose_name_plural = _('managed files') diff --git a/netbox/dcim/migrations/0207_remove_redundant_indexes.py b/netbox/dcim/migrations/0207_remove_redundant_indexes.py new file mode 100644 index 000000000..b63e6423f --- /dev/null +++ b/netbox/dcim/migrations/0207_remove_redundant_indexes.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2b1 on 2025-04-03 18:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0206_load_module_type_profiles'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='cabletermination', + name='dcim_cablet_termina_884752_idx', + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 7117ea7e0..062c5a781 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -299,9 +299,6 @@ class CableTermination(ChangeLoggedModel): class Meta: ordering = ('cable', 'cable_end', 'pk') - indexes = ( - models.Index(fields=('termination_type', 'termination_id')), - ) constraints = ( models.UniqueConstraint( fields=('termination_type', 'termination_id'), diff --git a/netbox/vpn/migrations/0009_remove_redundant_indexes.py b/netbox/vpn/migrations/0009_remove_redundant_indexes.py new file mode 100644 index 000000000..9f474f9ed --- /dev/null +++ b/netbox/vpn/migrations/0009_remove_redundant_indexes.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2b1 on 2025-04-03 18:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0008_add_l2vpn_status'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='l2vpntermination', + name='vpn_l2vpnte_assigne_9c55f8_idx', + ), + migrations.RemoveIndex( + model_name='tunneltermination', + name='vpn_tunnelt_termina_c1f04b_idx', + ), + ] diff --git a/netbox/vpn/models/l2vpn.py b/netbox/vpn/models/l2vpn.py index 575f6e234..9d7c55ffe 100644 --- a/netbox/vpn/models/l2vpn.py +++ b/netbox/vpn/models/l2vpn.py @@ -110,9 +110,6 @@ class L2VPNTermination(NetBoxModel): class Meta: ordering = ('l2vpn',) - indexes = ( - models.Index(fields=('assigned_object_type', 'assigned_object_id')), - ) constraints = ( models.UniqueConstraint( fields=('assigned_object_type', 'assigned_object_id'), diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py index 714024a81..b94892126 100644 --- a/netbox/vpn/models/tunnels.py +++ b/netbox/vpn/models/tunnels.py @@ -138,9 +138,6 @@ class TunnelTermination(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ChangeLo class Meta: ordering = ('tunnel', 'role', 'pk') - indexes = ( - models.Index(fields=('termination_type', 'termination_id')), - ) constraints = ( models.UniqueConstraint( fields=('termination_type', 'termination_id'), From 092769da7e4c19241b8c96143e7a3b24be4bb758 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 3 Apr 2025 22:09:04 -0400 Subject: [PATCH 067/103] Closes #16058: Fix circular import involving register_model_view() (#19076) --- netbox/netbox/models/features.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index d14fdb17f..55093e69d 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -20,7 +20,6 @@ from netbox.registry import registry from netbox.signals import post_clean from utilities.json import CustomFieldJSONEncoder from utilities.serialization import serialize_object -from utilities.views import register_model_view __all__ = ( 'BookmarksMixin', @@ -640,6 +639,8 @@ def register_models(*models): register_model() should be called for each relevant model under the ready() of an app's AppConfig class. """ + from utilities.views import register_model_view + for model in models: app_label, model_name = model._meta.label_lower.split('.') From e252cc3ce1d8f2706bc178f4ca853050d0ee7548 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 4 Apr 2025 09:39:04 -0400 Subject: [PATCH 068/103] Closes #19083: Upgrade Django to v5.2.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 06e0e1617..1111af085 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==5.2b1 +Django==5.2.0 django-cors-headers==4.6.0 django-debug-toolbar==5.0.1 django-filter==24.3 From 5e44e49a8adaeed9ae131181c8692d3d58678bac Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 4 Apr 2025 10:16:13 -0400 Subject: [PATCH 069/103] Closes #18236: Upgrade to HTMX v2.0 (#19077) --- netbox/project-static/dist/netbox.js | Bin 391058 -> 382572 bytes netbox/project-static/dist/netbox.js.map | Bin 525511 -> 536090 bytes netbox/project-static/package.json | 4 ++-- netbox/project-static/yarn.lock | 8 ++++---- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 7e516f7f4859ffcafee6698af56ca52c89acb58c..c93cdc4c46d245fb82844331b0671d0e0e251366 100644 GIT binary patch literal 382572 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%0D#Arc6x}ns1RKF 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

Y!=kUI zgKcoRDT4HwE-Te7Gpu7IeZu-7X$we#LznCKuCGP(8!yzW=X<8x@YbK*s&A>F(|jVh zh^PQiK%_>Qs8AUs)3KU49!85?QmeF$jyqdA>KrAGI)Ppz{cY@%bcyPywS^H+EqEPi zpc=M8ZHf|VUtiY+_RX_T^(oN~igtbNc&GK(xcDkRLBLz~jA+cL_ZoENSZ}vJ66ki> zs$2i8)=0$0zr!gkt?bt^#7@N-C3&M$6l(`XDDNSzcvO95pA-`DO(6cZmNl-#lQsiO z+KlXI(^(Pm?WxT_U2hB%Yf9;;TNNqH$YnhEr1C~PKJN6R+D}0 zcn!~cvCja8SRd2~Q6>_1vM3Qg>$NaJR1oD1oRSwm8YtPwjr4%@t31doGxbTZ6V}|r za?;YSvmV}Gkf%A5VUKe1BMgO5nJOC?v4m)VYdqOwc_m3PZd2`%Gm64!44g_*4H{Fh z-$~4QB*d*}Y_1Y;PGY}&6|yXjYCNM@iQJKCF1V6cI}+^nRVlr#yQEmxNFf%Q8o6@pQ3|0!wSP&s+?4uQt)H7CXPvSW%DAZXQB}S4 z?w5DSj>G3$IC0M_UeX4^N$`O-HMh}F8qC28&vD&<27sB$D+U*F?}V%jX35|5nd#-~H&iT}kY$a}WcHuod z1nE6InfUI%GZJyEZPeCAL6=cJ2J&#J9RqZO@_ltwzChq192p}zN>3o*$k^yqv+4_= zhOz?w*v zQm}Y=a(?lE6q-Xh9?*70hA8b1`p~-frDzY2rldzQ292SpntmhUPQ(S>?W0JG0{voE zzfCDi5EtHEilp!}dv{WaGz2Qm5IN;1f(3{Xa&p;yUn#?H{jkL#I~t4*`Fm-aFC>IP z<}5g{W+N#}-smH^L?VWt z{hn;cwBW$-gvhMch&DfDqK!nVAlfMMls2__j&>T$a|PsMd5MqJvE*UU869?Jk0;)K zo$||moGAr9Xwd8JjXPJY@Qr`+;elu)BA4F$#*g3T_3iD2lAsTFf2j7~VFZ0&t&_bB zMKYAL312`hx9@e=?UA6xl2J#Vxz6mdHso*IEFuzn#D+@h7L$!>wNSWVubzq25 znD4b-;;uUXSdfM7z5VLYxv2|!|Chm%8pXAx-J^kYxah@`PO*v;=-GmJWQ7n-w2%aIoho5d!2V)NhP2z_PW08hI`|SAD z^$4MX^ktGIq8em9(N;q3BU$aja+k9GOIrb z8r10A zGsp~!PUHk><{A?c3&FI~)SPUJCDpTE&&$ za`Zh~PYBl%u+tO%BS{1&$*SKp$}h3&yEi#FcoDx&&kKo?76qiPo+7#_;R#$`*Pv?? z{Q0MjX#A{gkVT>lYiBbgPt}<;%3mKYP@9tprRH8ExqRH<3r5#9zzNPI1eZn6ogr6^ zlILB!d^90a-0etH`V2~np>yMv!!+3r6h%&u)`BOJ8v|EMZaK6AGse-TZ+`&2vUu9Z z$-?{<7Pc5Dt9zTQZ>~Wey7W6!SQZ@nd$U;DX6Zyc57hgFL(TY?Wx{)i7XW>d0#CGL z5!^)Qb0~F&ob3{Hi1`STw#rDJ>%l;7R55WpM2Td$htpWLz*?A32_w}90tI0#*WT=! zE=VGo<|Xkwv^zT|jzjTUc4U|oG8eHc9!{e8pAch`4g9Qs+rZZLnpU?Ab`l4RGg8w$SXcaM5A~&CeE(-|sanCA;6~bx z`$&8W;h?CenghR;0b5)-Dl=h%Ovk&BYze~8xnnrgoL`rOO%@F&I+u-guCY5QCx!`x zd^^YNg)W~#0c1@zecmK@_^li~TS(p5FeHuvuEgd#2*mV96K6hJh5AkffFw#T!BYdT z_wL1F6lr_Mp@b>{kEucMtK0B>gS0bY;efyaClEl1q~|m&^POfcD7Md{&{-vBK46xK z>Y^Drv?kRyGtVlR$xdZiE!Kc+R`?qPhNJq4}&OB-dCKp#m`L7eF)M z--_9-$=875;vd{VFVwy$eVc8{oiM{}=0I$5E$cGu+_pFKoAR&5sbLNff^cf|p*Mx_ zg(DLmf6_Lw2l4smuzV|;3cAY{85jN*PI9)J24);)8G1~Q6oxyE(>k_^r^WmV6nZE zP~O{;L}>EEM7gi3eo^=MG4`hJFT>FMu5tN8?pX*t(N_NJbenB(4#4s5bn!rfdfNcZ z-4k72LmdMz$#(CuAr$%svz>NA_cK8`=qR-f+YfSb?wCOv_?R7vufA0^f;J>Vfp|5wXpL(mmdUsR!Y+#nmCQXNP>`2KE z8*<(5anP2(0WJCEW9Xfx%%6{b-^1%&>cL;sTkw;UfFKxw^R?*ige*w@Oh<^HlpiN} z+i2v>)11=Uicz(VBA!abH$^7+$EJ^|KOQf915Uaw_12f*uj6l^u{9-6+>!E)qYMxk zwSt2L4v9So_*_aEyHtnX;%4iR=-hW(RuR9+T*Z2rM#ZyOu`y9P#~hV+c$(Kst7y~< z!L4Cn^%v&oGCraeL>bQ#t1*DNanp5a$9<5ITHrpupatJjfCc>9VKE&ul7+STpY=Aj zA3Qt;MgP&sSFkr%|IY38<4SJ+f9BqWFREkN|9&et$CEH|KuBV;HwO72V$`S@FS#LG zM`pxPm>~?H1bOez_g7WjtJll|lkB~p^WPVl)vH%us;leX)yWD4VrH&;z#`}5ONkPZ2w(?gFn207cj=_NdQdA$l=RIRw3Bf@SEcbgJC%niu&SF7ni7+_=OawPW} zVS$B^&8^y>D^HxPIrUqjFz6t){FiF@FZaOnLIhXe>#trLA$G}zQm3(0vmbhIKa?p> zRIqMub6rTgIo3*<>wH6F$6gz3>h6P>qeYN%#nZIG1Ea6;KduDY#O3><@Hz8Ju?@27 z>$Yn@o>8&$cn#55!pCy`_rc~pJj2+-HxGteU+fSqnYQuo#WsGHb#<2E zcNtrkqU7#|%=4rZJU=CC?N}R@LXs?u-uHW}=mw8c_`q2Eme+af{XB-wY3Dj;(RSUI zaJzD=yL78NC66fWL&zh1z!NQTMHn|{a&||X`l2teM5W*%zEqr8`#R^m{Rklq5sRrs zCU1n%Idia0Nf&t1iYyj8gUw@n2~IT&TQ*c6!BA%Al1z!f1@vHaXYEb6EG=%~yz}7Y zm|uxgBny}y6QfxPX7yp5n8$sNKa8dn%wM?_3HH(p;v=(sJC5)o4r+#A;6|X3jyByg z0}mjq5iZg$M7kF>q133hY;>U%MX-j;5^_>&*Ia^C)^?Pf`|~fx?*{wDJ>P~fa$K+C zfj-xsX-IWwRlqwCF{2TF2RCR689}^P-R*aEq~k@xS+I?#v_8Qh$|Vvid0&~gICGf5 z^GW`&Q1ORmUp`dXn z4zB0hmj_M~8;ymEQ|WhjBm|m?Lauc?9`u4=OqtVQBExnJuA;MCa;MNADDVz>)l8s; z;=VCew?*Pa?eRL8@YN+8vIyk~FcpG?T=NywFlS@lp_hAu6$DgdPktcM_J-I0Fsgn7 ze=EctYD7I2eKbT@&{idtqCYANm7lYgd7FFP>~!>RgUufYTi-1&xV^k69t~B@EH)q5 zFMMmmd5qBqCly6`Tai|!Om3e7Y^Er{FS?|8_6{+ve+U@)+-_pyBF_|a_^Uz=R=!$V zB%S1P5+{XZwk9OqVyQU%+p9Vx!Ir%{84}8)NP4B*63IBp$(&EPZJx}HeUdED(&UJr zRB8U6h!5JwyZK|rPtv`X=?$FIA-m&ctX=sWDIPOi=HueL(e65F=9u1DME!o$i*h! z+U8Jj+K1#~;LDK8^2d4~C#QI80$KW0?S3e%!%sZ`$N8T2rAbISo(j|3D-Myuj1np1 zJUt!<}Z1W+NF#VL(9?>gt8EiH6Y3 zjfK*LqgqK4ba9mgC05GK0g{Y(K(#?&kR2B`1!RbI5QpLyp)ymBXcF`vsOD0x}?ZSpy-!C@Fd!dWu94vE2iNFpQlVtx; zY-q2L)3JL6Svlxj8%>Cu!%vL6N!?F+%nS%){SaYe7I;+BzJNpvuWz=Pp2`HlGx@# zhdUn_3}gIRj^O$sZ`KpxIc?GH!DbKa1j+~vY1aj2o207=60ORsOk=mSCAy+4><0dx zOksSev4P5Etir@*HAIeTSgDYuG>D5Tom{~{)1Inj(Wrn|9Qba=M+eZSF;c;l*wtlD+ext>;%ER$Z*RQ#&t5?7|n>fnA?ULl8t9p&u?i zEnL+}9GLml{eYq2=t%l9O6^K0w-wH=T?CPHarWQQ9~LY9AqjQon3&ic1sxCVA*mLw zI2`s5-}DbABQ()bJVcWgyoO_^EJ{t$n!z;EaHJ>KL~*Ei5-QnX#h9S0@Rlb=%*xd? z?E*2lUWS@+>BB(E1lG$Z;6LIy>x5Y;MDh!^Oa}=)V^@WE$c(u(aBekIOqt6Hih0~f zF`Y%1N=Z3rQBX8&lz^bZv|bt6P7@wrED~6xFY@zjX@WB6YEas!#g(S3%o`1Y?&|aQ z{VpF#Q+b_;xy2mqBTHBkYl%xe<&R9fA|>|-*OgSPJ&;capnl{MR00GcG>=@8)RA-| zL3#wb0&#*JRD;p*$~txcl6s&WjD~YOphb{roeRZQ<{k_-f0Owbkq-eKHB*Yv$~!CN zYIwbrh@4(T{iQjt!9@xJNwP!w!E=%?)3mPWS>d?DGYsG1dR|CT0l@RoG%RsnDR%vT zbfC&|!EoW%6kwYwB@KSbPR^iHJo5RHX`5e~EV=~e3l_jjo6uYP!(JG2|8Gnd3;M5v zxs&1C?r`pRRx^waOOG20JD11$Fo3`jhF=F)PKH16as$a!AzO7CV4SSgv7`ww zl5Kmmd@#a@8>nzFsvM#^b&;R%W6o3oXO4!;3U38bEvZMkD+_ZZ$iOKy9S=jlq~?c_ z5aY>k?$vPa{ixHm$ID|LJG)~63*^9?^7mDs@UA`|$9GmYM!u|oSZI20yjtNiU_093}J;UZdSkogt z2%Ep>p+`;_Gz94e$(9GrLN&vB`qOB6c7uz+r-4F;2`C#wfbz7v+#9dl952x@sIooY z629xtPa`dD(1Xq&W7T6QUg=7%B+N-f<7nJj>)J-3E@#Z1c02cg#<;!Y(0$rnx;a)6 zVEqH1#Mt1;ke1iFf~WtQyEi@y~!l97cu>6fHs%QS%GtV0UOy(NSf3aCOck$@|{;i3{wSHPze*_Zep?Og{l zX*s*zKYk>31kR#&w9X>%w%zd&t)N6|04O55$_R%v;xORz00 zmkH`p+n5QYV3;z01>w9xi#b&@B}5j4>K*Oq)kz#{=UZE{#2$El1P=V|CK6NP^RC?;mFZ9slL|ZRP1D}VNur1iz0dZC*(<`v1TEO-FHXGq`YZqt}Epw zxdAZmC`!Dcv3b1^YUnTXksm!y`6g(_SCRawT~DZJpEV zasH3IaFP3nBYf$;)`D3)Ib!*eCjjt|~c<{7#G;gZ<&)JA#gg1-#Ps z0aYJrcrht?jOunjOdTGPbsp2QDYEd zsEi<@Bp!@8x@8ywcxz0towIj2*)N~eI+LAXC@z_SA?#HURvsZPn76C z?*+~rUq>U9@iUc8+Zu78Rgd9e=QuiL@1A68>VMP=Ra- z7JMioZ7YJV3}Jd0njWoOoGHNMDw7*ce7Orm$zSB@9dkwGRhIti%5OsVWI<^>4GOx~} zEGKh|*M(Js;ZfgYbu^oXMx{B)rb6V&>tP4e2|oe`a zzAiQo$xCq%%#*aBXD8>%bj6 zEU*v6dY;)ioIzgQ({@|B6p8wMpd%7wDkc~V=QOvUfBP(qXfl4sY6Z7~t?55y%*Nl1+q zV-uWD75p+O5^Gu^0~_eIOQz$;-L1~Ov09&QU?Vl~WR(5<{cRWuB68)J zc``CF+B8Uh$IDO*kfZyDN@&R&N!HXdJVP{)?A@2L#BZGuLs!zq<+9GvZhzjcOPhUp z6Q{kX*#1m!)VJ#_Er4NWkL1hbLho`B%CMhrESbS#H4{X2e#Ugu{BCLH@~MeC^RJO% z5mR(6EQf6@FW>yj#0}0g8V2fKr!!@{_POSDs^*I(nP+9QV>&3R%ArKg2D=+RVl{1R zWjIBAkAVh}rD_Dx3%Le}^#vIO9RmzhhsY;t#VcS4dAPs7yx9My^FV*T>g;WkYgoCE zxaJdx3`xE<>>(^I-(d8geHIzB)}A~mf6u!=&t)u=w~hSIUj3ih&3`=~xBlnztt)K~ zc?B?*Z@m1U%cryv-|0O6*~@lYDRMgR_N_OGZf4thdHEDi+RG$1=eq>ZKcCnIE1vJ0 zPmzfgWqkGdcy_Zp_lLS1C{YD9?-QSPutw-HRO+F3aM~MTw7B9a6gdJz8huOdrfi2S zYx|4)`|a!REyGzlqN1f)cUQ~Jle<0PU|MaOI_o@+mbFN{i|x3eKD4Ae7)TGs{oA`EGAnC^5kse60M-yq+@gRcv#>;U zNwSg3qhskLB?${hJiSMh%}zH+PgkZ(>8Aa~15S)}4jfZhFpf8k_q81BN(4TuP3*t+ zw%b(PAK4-UrF?nq0JBWGl6{2$+(cq&>#e0$nYdjI=+lPoC+Y+-rl#^GKgC~Lt4jc? zceig^fg+Z@MSk%;cCU1wmXI#pL}xkiTsXL~u#!=rp5=uo7a@VW0anL0sZ%Y#cV$`N zKpMRDK;f}6ETPkSquPvtB81(DxUn#*JDDI)SeFv%|D?-GXGzaJ9=sat4i3qx8)D#V&)h0*w#jABgXG^lLZ%J^uH zG`Mu7ig6!7m8XpFl=2JA^bMQmlvod{dFNKK-nC^~RyxD^_514&)*n87@o?kTy1~qD z3s?Gh6W1|uj+xA0YzbDm*cb;}wdSe3w&q&o^g@PBc*JT_bIPV{l>u<$OBvmWF&qQf zQNnE2HseD25i~)0ak>1*Gdd^miiR10J&J&#(A#5}ktSXj9s&pIRp8Zmp)v~^y`HJ^ zMl1c6v5Kfy&!1h?^DKqc%B_rqy`m%_r-tKreAao^U7jU{J<8y)8z_xx*P6GR9S>YYpi9kG4x)p%|=n)B_)Le{?bU39oDrSonk(%anbK0HJw z@AgQwivlWyhwdp8Yinipa69(X$B(&lOnyde()|Ooty>!p)}#y$5AXA(F1NG^B1O$Q z)Mo(c;Wow!6P;+(Brx0CvNDmv?Z76S`$#|1+H;&!1Jgmy&W{HUwY&SX)=ezzxW{6$ zg`lFg&3v;yTz#-Ex(-c#Jv8G;{-wR_puNl34_4s1M(&-5tXeqJ3l|qEMynyf(S{3lfner zi2gxePJgmMv>1zb+9HC`T) zD~fN^zT}0RQY*|^BB+?k!C-Dtd_~-aSzLiadf30Hr>3Psdj4Pj&x#O;?P;9xXZp{9 z-6^DI+U+d{cxQk4>gK=Zx4xKNS$;l0|3&-x{Kxq(u67<#uIHk@_sVF&yt@SEP6+t z#@?&l^wT~&Qu0RoMRt7rPb9K7Qwv-5D7|_Arr&2XXzx8xS5GW|M0)FEs5*}3B^z*o5noMxdd8w`&$d2NYYwow#7)GKv}iX_C;`q^)D@5KHWIFa^>u$G4`W@lV&cj zS7C19$OI|db%;UMUfy7AyYv}!0qou+AavRPSn_%QSv7W3o~3d)xtqAy4b1#2H|Fn`Jl3xx7b`DbX z#Ysv{qFm*^Qqh*+Oo=oSl!hMyCzS5mlEkGu(xIJrr3&_)*jaZVc7|~oN^H~=LRn22 z-^JvVbvi|}o1QX`pO~^HBe3|ai9XbMsB+f$*$nDb^7}_lqt9?%*v)x%Ox44~;dwXc zQ&+S~t_z}1NvsDd(i2w3_?u+R!V2=b>MBdD!K=WYEW%4Gf*>CbP_>kFK{Wi0AzGr{ zm-o$j0I2Xx*>beCntVvzt!hQ@S5el$NlZgrE_aFO2`iRBg7Y?Oe%D!JQUSoG@B?ox zGib8~h{3?E5awofd$0qsK%&BKBn;6RA>*Ok7kPkINV4pwxLx`LgiMxz2^7`w7b$G2 ztdgQ9Tgc`)?$5Q$a)nts_jJ~_ECZUu4E{{tp9+-Ylq$0UT>e8Y6|A|-tUh6COm$=c zmq2R54mLxx%h_f!TWM^o>?twHK&QcaijQVrrNlIM_B-5qV`fDf^-aYhg3L?4!}+CS zT@CnB4FaQNg<`bNqhmSyNe`K_5LtKRajeAdL2{;q2{}zPy#fz+wlm5nw1|BTINb3S z<=4%}PP0v-jA3TylG24@Tcw3Qx}ZcsuqO|Yy_PU!=BJ9l2%D0y;vb15Ci>y%w*@tb zO$d90M_LwcUBfvTZtosp&V>%PKl`kl4wNPBOd#F><6=0zr_y#ey*w}CBU?n|kj;Ux z2I4A1;(NSF+?$x_g-v2&j}dsGYaII*SK_FJ?gOh=`jcDJHaU~4Z%vO?W@oWkClQ~z z#&E!Q@YFc__~*glo7vX9YsWzmbWd}6Taaz%(t|78x6745n6EqRaTMAXg2ISw`7UxY zsu(ObQ=;)bqw!^%yBH!IF=)jDvHeI5GGxXF$~jX!jSmdS(Fi{u=H;{nZ`6M!&jfE(8O*yK%1E)89;kyKBE|(pu?E2S!B|F@F1MtJ5fSXN z-&fyi*xugV+vCPosB>dAdbV1(+?M{x50t0{H)MlBl0LFojNzQVI(h{k94pG-g-EeQ zC6E)}&C-yPIpOGt(6DIV24=R5y4?+gk*h5%`gs(RBJ}_1RtP9Ay#EZ*wy22wX<#07 z38*!o5bM!CJUGGlC(;F^&$#w2vOWZ!GLG_ANZO_`S?XU8cCd>2bj?zpWK$%A57-=IY#UTbS9Afo{Bbo~G*Ngn9mE9}RVq_0UTWJw{DR-(Y zH36hbD~L_;&yhI6{AgN9~k1G%Qwfafs*pp_h@8#>)QidKtoSrR?Qs?jmt zcJi}_Tv<}*S7TkOj1jy>vtNy6CmHM@V(%(GM3U+){oLzu?lnw?I`#8(UG5=&fkisB=dD+l5joB54Kn&SQC!xVg^I=|W|CmY_LsgWv zKVCK?syU1Y+cSJLlZi#(6)_%C_3Ce*qT%3U^g-2Y$W+Cv>U#HeqPq`fKmT=fH1h^K z!ptEqmA!rUklz|1uuf6GSzpI)CqGvCfKB@XVo9<31Z-N&1 zg>|2&l|6IX)zFu6Glm#i5kzx67WdX*HvdmpjFc^*JQ?ujV`-rDZYQRb${RwOFiF|x z_i7jWSe^LXw4l+ew`fmxJp-Sgy_g1tcGG!7dCEX4qHju11JMTz4i)3X!e=pJMv9!g z(n6M%@wT<}ZeInWrI*C>9`|?FIjx4gJ8zeD!FJmfR;g#lEKCSf!xIj%;hyONJ@@FH zEzN9LK-NqP3N<)u&EOMt8_)E|=1>lMEBIyTFg{`s@Ls^ZsyJo+LZfrci^)rl^a4k@K<5hC z0b!@VZNB2#hYd)w`gHZh59`0$8~B8HJ)R!6B;SXh_)j*$Q3N+@e4_sPPx8yqXGJ1W zT`T#KX>7b7Cim1s0#U$4kX!3H7@MI+?DFgdR*s|o8oIs`HaPqcBV;i??G}phU#0>9 z+W@vLg-@wzgGc4*jiB!eT6mX@DQlpXG!unw^UTLOTOYS#J=(>m8wYFH&<^`W6WV1G zpn??Gr@*bKf%QS#LT?N-P6PO?q5(-8D7u>YY5nn&yBiNPwVrvqU%pPm%kN*l8qdd$ z(k?oCS=P=gT~$w&Q$y7UeR*}Y+69E%-WToX*7ycNVcQ)d!Ny_Q%)jDcel!>X!yN(P zqLj@>bZoixKU_?!PXWl(r*DXLix1?6#0Xz0Df-X?>1%8sRV_(ordfRY3u{K_=oPj= z_3)EZtZOh{I?}Q6?SuiHqPj_=Ac&ymnCo-cCVz~R-C}rY^#)#K(b2dJkvINX=V$Q7 zGAXzao?K4z>BqX0*MwPBs3Pu}c!z{PIvfe%=O^uqZtsCQnatTyM;l4qUQS({*VoJ^ z+AkX?Hjpri9$v<}I8z%h3!7?XNlvfUj) zdz(^^t}{0crLm3hoD;p(8Nz_3yH@GH)9v*4MVIDTkS?}Xt(wD`-N<1*TmW#=!;yRs zgCUps@mFECxRLcPyDB31fTB&V@-xTTR;%;*CWKjzSd?eG;V(3|tC=BLb9wky`(?39l5AxJ)%J-ab5aC^K9_Eps73}$zx>cd>||EHP5f4X5g6w=9+-la zS{FzE84qe~50EK4+$FBZ_i8FNdr?~}^`>H@)6N4T?gog?(cbv4HmKb}EVCs4-Q>w_ zSt^BgcryXVOLO8WFoLreV+B^t7lb-?kXXZYNe9E(Lz@qUk+(&>t&TG zJPhNTtyPwN_3`?uW%Ugkqpj0ncSn2!x{Pil^amBPUP7EmNDsbKOz%Uo1Zd}gT5ZBT zt?3l@@)Ev<7)T@@^W(cImW(JW$waRHv(E2GeO>P80gZp=CoD3a_It}}>F0ga-DmrP z9kSNt)BPULzozG26nxL8uZb*Zb$;O2_>C@X`n4NEBoP@&j{I?0Dr5q6X16oH@1I#8 zlt4OIfn=4k*}dpHwP!5tDT7WhwClB+5I`;=uqc`k4u2q|Eg0!}HMKh}-sXGidZoJP zricbFk1*i0gV0@AjQKoHSlZc($vQ62r#eY!HEFbYw!iL2yII&xLd!&CL?MZoAT=)y zvy6DINf1*wp|P8SB3_AnYZ_ECnqErg~kaT`L;L*cze-yq3DgMQAH$C11K|$VM zguK7;$}A3Z-TgtU)87N(?OM!M=iSlr=XqZrj&^qN!=)y-KJOgv1=zNDJfVgb$Rm9zAo1`6Mwb=xDwi^@_TEz zNe;Wekg?4lF({~!M-EN%RE(D|U5YTdvN%2%39Le^QAQ#Qe=tO`J}DOvn^FL}5I?gamdL z-AGcnOWG<6&Uw@yK2>0ky2{Y58jBVf5cp@q`TLE``KR7^VsZq>^mhACv=g!a5QOHH ze7!l-m64tyXzvwcs77fUsXw;cyyJIx1#c`8soe;a+Gx&R)W%^-GPK5G$aI-`ZRE1P z^#|WA_x6&s5FD{TxLQ^Wt{=k$GZBC_LGtOX)Mi=VVzpQ0<^%#ImIe=K!t@mhJ&2v& zRFZebd$O;E%#rcS4Oo+PyJg*2`m|)xULh6fX!sF~WT~;=VcFIv`aLM?ruA+f;;5{e zhA2cm7r$$UqZdtpV+VjNL4&oYv6^h}nTS#^+}+DWqDHZzix$FP6)s1BN>TtO%>dY| zy2k^2qoV_~why)yZD)>eZ~&~c4@cPUa+?>*Ac2NF!KbQ%cOw%{_V(aVd%c=)vO`@C z*NomPSwX9FoE{%5L4T`rvbTI~f%KNJKIyQn&N{!OMYK8}_$9P!b$;&>hwJrU!NPEY z_uzf+M)Wq?_UCwf!RrVpjFNRqEUr-+>OmB~PlXoh7lj`v>>YG>3^StaDP;=1v&YM) zdr}qFC^NOIEZkw%6+CV;;3SbfVa7J%R%ex;snXowu)jA4y1@6Rifst3&c@UWhtd71 za);QlK%e?O(L=2_uH)QWKJHyP>2==GLj5Ivo}E_bVZD6V8{qOC9T1ZFm^WFlwE1jx zX6glVha>C(clQbjRM*f~b8aDl-a!u7+}Yb&`n}uPJm&9*F`0%aG%i3$+0r!*EwP5L zM%{xR&T{NSU>*5fA-aT8szowpa- zk(Cic&e`7(UR3-u`6hnS=^72=v^vk=i7efWRBE{hA2x7r@=P0RqUCj3Hk{1mc`dMk zVEL^zm%sxO9$3&n3ycmBa0%WCjLNt1jUV%^K8oVBQI_$M=a*TDpPF+TfO*C-r8|yE zis_!b>aW{j^)+85(~52MA@*oB-Rxug(>Q;K39mbox&&=K1I2>%$KkM{Snm9 zwqA!ALhfB?qHZ}e!A6#152>0X$q#}~I$fpL{>fh%kawBKBT)%+ajomS!(RVGG|gyM zO{Z3X4;@lh5oD#LMbJQhK*a0HZ{wx)aY$K?eS|N$hFSXbysu(A2CBU%ie$=j7huOI zMD_eB4|g&lC6r)nd9~#Xd(p6W6Z$!BBm(IX(vym=b%bS-5&%RBXW5$=;0ye)ic9-Mapk01XJ9if@>Ll@1eua0Z;lHz`Oe;z)u- z&c2(Zk8PhNUiA-q3TL~~U<}QsI>h=y)y-S?h2xH+Nxn_)>1ab44rV{#f~94ormQ6Y z$uEEJ@@1jHEa|)Y(oe6NjUtxDNl3|Gh*%TJN{4W)a``@xqAU`*O7*LOPZJ?T!YdAf zAVQgB$yDQSiVb6DTYl(HP$Dx`7Dy_}cGY|vACqU}D)t+lsgzP?(b)WPOLn=(v9aIT z!+@tF;qvi>U_NQ)j$uhKjgd^PpB#c9T2MJBHKKvZEoBt8qJivPh|z`}E<+1X)QIn# zYX{RdGRovaGv}i^Q=gbi;IG_*$Gyx?Ccj~xg;Eu!=G$EsHHAADu3oe9Z66)z%pumy zpsPhkm7l@z8!1ta1}IW%i?B0iEK|eAwFjti=2eD5DR&x#g1&0b=Gq8wU0K{9pq}KP zNt^aQYm!ws9JD%#>Wk=O4VnO8c~}}sE9}uX#Yxl%txH(jYIhbFlHq*&ZRe!ZzvAJNnz?F)1X+h$xaX(x( zN)%QCgQy@MdJUuT#3@i;!^c02v4EIDiu3Mq;mD~>jrhIDqK3kdeTe68 zN<|%eof?*r9?JFyvBHFd-`ZPR-BU^?U;Xz*jrUEdE+biT;2JU3P>!n*0ZB+4r}_Wx?_pM72s6*XmvQi z?{C&r!nh~Y1HfCouam^kK#JM3SKOoZ1mj1!Wz&ZR@p&-Gs51qI?N8L7MKSi#CanjV zt1g)b*G!um5>>AdlTAW)ntVL_4#tO;kUH`9f)9L8_!<6aw%dpC%KCT-*)lzun0fZ1 zX=kLFR9~-;S3ZnUvi)nG_=&0=df0#7!=l&?6U+;5o2%f&`oK3_{J!oRPe&jA-*C$z zQ#5#h*7mS>R8H_BlmOI%@&xm(x%9R41_5^=z!<8XlreUv*G{zOJlB%qNX~U4v7wym zHZz^XU_E{csu++r`Z$t{_V!xS(0yx(0ntJ?J%T z0t>M_L^@Ah+&4~*11>i5?A^V|XhoLfd?>kxjl_$VH8#TUX=`b`P0EiMyufSYv-tC}^FvW_?hOZ~ z{}6dp(gI%iWk{W0`1vO8L%PTBJpFkMr884KmqR$-E-ZQOz070m%a->W7B>&Ja!;Vw zF=JeRC%=*@Zg=-%qNy^X$q~9#n9Vjuje!+xo8)$f{ewcyX0!3roOGwOhrI~t!%{nu z1i=_1`zImQ@pgq&2NnXW4I}wA2dJa~AZ_&6^NMj+M09Ee-kiddkb7>}>D)RKfvt0( zNieVzI&RWP8fiI3k7J{KHV?xsduBvE4 zYhx&P8pHIQS}ld&H?PiHq*PIim!4-qp=?^;+KGoNQff)f(Vyjz?8`=JI_qZ9yCnoa z560=7MVjy?2VKwhKfFBirN3a)BvmMFzYEar_Y2TYn@p07;lPEs<%ppJhLeNV&S}ab zze#0HMygQ_0F&!eU&4!)IHR>zIiDSKuHapi)N_JMta}_^O&WxX+cDJ%aKbGTMvICJD~SLA_eyq)g^U?XZUpLtcw!p(X@E;G0=z zK1=V1J{fAwY7&CXuX`Q(InR#Bc`_n_EMO5q>;iQ;C|q?SutA~y?6a$qQa+06UQOm^ zD1eR;B=lSis?`+T3QW!sAOuMX#FHwwd#cgUDh(TUqx3qp`~{6?^UjZ9^(K(Hnv~^d zd!6I$y0T2bJl1rjIN^^O)#VXdwFB2kDrI&aKjU62s(%BxnpEW^*ZAQzaE#6_47nG? z+U*TH;W;IbflQ@57K_7#Emm`5Tt(2$*N#5Hcj$y2Q_aUHLuVl z2h?4+D-wyMpp~cHSEOO3&_ZLLk$meerS@>k@^2mNEPvSf7h+X8Ngv4|o8Ef?M!HK>*ul3R9CWFA+jPObDfJw>`pZkCM4 z&pUgJmI&t28M^ROP5o!I=lS&goF^^Yp<^;|AHV7nHga4&%uz<*kqT;G%v6tx&AE6C zti0@~xNodxP3~dYi6Zwa?=rT6jNECpA)h()p0;$e}ow zYM7HSo;pucm9lin0;^J$H9)~6TAe5S@aBnCLV@pc|NEMv)D>rwFSAn)x&3`K?MIep zlGq^mtd;Dx)jJ{jeXvb-P0KC0#HEv8Zbe@CHFGk%p&+NT7fhrDk*4let;2@cn`!_l zUMD08;&So`g8roev}U`y$07oFKBim~eBl}NV^CqD@blGWtzw`PoqqJnZAO0o-?3i! zILg4BkWidg#uSS)ZNWr^)@51wQXmJAKA4H#7zC^xJ;dPHKECu1__kyh}Zgf zr(KtJYG*HVl&58F&BrjSAcaWNXc2YKu#?s%OD_8Dqbjm1pfZ%0@S@`By+%>3r2_a z1QLaMei0s>TjfHujcm|ua;L`8wTa{(w)Zp|Wf4_|3jF!!f_xmPD%Ht3ix5f6gnH}v zQGb<^gP_4=PJ^Hvc|O?t51L=2dqD+5Jf+Q_nOA}tdcl%1xEP8| zeq=seQcIlkHf1|+eqEw~P^Y<;Pogxk`K$x;5>fZ5FW>V-6cs+BJXYqH>^r^RMpllsJ35K zd$MCI572Cj?s3??-+OX+LU3?3^R_lr%7F5dC+MG3x-0@S?Gg<#Jzx$b8-!3p0(~-d zuvZ%q)$Qb;Qq7cgtfs+e6>#F5FatIQn$aJA z*-XU5SW`c<4L}Zd?8ufzCR{NC3x|)X!cR8zm%wo5~0m!<(;1KCV;ti$(hkV%k72JpJZ*E&3@D+;i);b2^Y(Yr>4XQ ztntYgX|tc(BZ)-D2+~ZJhSjo zPgoS)N~~yRek2s6j?4wn+ECbW1B^RSa=+Gddhsgjn+82|Kgsz6b7S7_oCMlnYLtD3T1l+QRQZl+U3hwA9xU>pa!WR^VO=ACtSz$&7s zcY;CgLA`@y6RbBpl|i8FCC4TeomFMEK4*g}Kq_6g_e<@0`t9GUGfu~)vG&J=LJW@~FS$33W3shi_iG%ctRCo!6u zxRn>fu7@dhnb$&?VbnoMplV*qC}YiXsEI>>V>>Ahm=w>r_XU7bsFpeD&QXF&fA2^p zJO4z1E9MeMFk&3Is%!)EQ*8M!jk+9PA<>78;ZyzF93>*cTp>nzIJxN5p-`lm7~L4+nzv_7rKpMn zD;pLqeSBYceQC8ecJgZZ9AH-ICNZBxjP8>T{bJjN|Gp@3Cw@XV&3zI=iO*1PWaGYU;+wkW+rE9RnnWUU{( z=O}ioa~sMoG96Fhud((-iPc?Tl6iT!d8k&UqMv2WoKp6zbML=GHti5+MKlSBJY;IP zG4j!pPZQAFHR{+%Krh#-)CuUjoijTJ0;#I(!DBz3!@V1hsIiwMwi`&3g*3_wm?d$< za1ol3c5~+!qV60&bJ61+sb-OD!*h$=7PfF76&N>F{qA)2kWjsP-ctcpZfoeFu^kSM zo8|^toBUw0Y0wa^siDbWpgT4*O6exe5>gTcAvUfaAo->#yT)buEu}i_BnC3AP?Y4r z18EHGwLFOmu*ljjt&uM!3xjbEsR^H!mv0}f5HpcKar;B}5}VC!I^u4Z1eKYg!Tm zo6ntGTL?oHgi|^rD3}1` zVtX2$bc$pNILGVf&B=b#2w}nI&%h7_BeB3mP-ZB?G4t}Zby!PWHVrOCxVilgV~be^ z1sD?$1NF(vKEMVm+J6&a&6Md@=MNVH_3M zD9=|=PQ3e(cW_0MQ|n*-l3KIYFQ5KovlOo~yMiuBZguW4O*c=x<&B#sx`gO?ov_Du zeiLl0$P7tX-;YxPIdZ$ZBhF_qc_`ZHHnUvv( zbn0cThR`SfsuG~{$GPaNGx&F+kbHK@{H*i<{o@Hdi^nvXkj0b;&SRc$q8Z%67L+-v z4O$Z1RMp#(K%ep^*NwoQN=%=MdQT@f{1C;>F1d_Ius=wVONc5{Nj<_u4`rG*i42b$ zEGAE}E?2zkpjM?W(cj!j5lU>>8na85#d9K*B&K9zif`nYdvY;N%>07$7aw|)=jhR_ zr=yUvhLM4s4zN#jRh(?Mn_U5N92M$hAX_&;SMMYs1BJ@l6Cl)SVFVca$= z15HRykceQ-rdwq3tscws*TM4p&Yyun!l!d#kkJ=ob*16dH-#HS3=&}*dYge!KmAb6 zTNL(zNK=MTQ1WQHvVd_cuZ%(p3sIbu{o%lsw`?==E!q}god8qb(+7H^GKXzvcD9-lUx)m;s5F=}{`h`|i6(|O~?53a{Wk2`m zr=~5IJW?%a(-r7;I)TwiKXeEs-dbEiZipXtpn9bVlyH$jFefAygURkl7cSR62`gSD z=6b5+5L(mv#UPrJoj7}GIssP8MRGuzJJPlX>zZoUHu=-h{<5Das@#vF|A&eG``1L1 z;4MOSns)+hpK*rbFP*?*bC|e`+-Qbc2TB+?3@ek-qe9WCtv9t{&7{zL^zPYC6NOB| zTYZP-^GUIhd~hWqN}8KyGoA`3r5$>S@jRp}M7FOc#c12Ty=AkOD_iO>`;fJhy5 z6HUd09mqP$!h#mmI9iY4h3KF6@sMzqB@ur-}QOxE|EJh?Te>e^0DwHkJS zgD%0$xwdVw2{M-%cd)C9cbguzY786~{9zUTn*}bY788bRfdkFk?S0RQb*AQ(Fjd2* zf^D)y9$Iz}^Hn;r--PQzkaIi!xq;;;4P5$lc0A?9HQIN_QX<^QFiJnx!`b30z1PZ_ zH`34vssN)nc>N>gMO@H{72L!ZC{ z%_yT-nZ_G9y!=0RK#?Ig9#Q;d2Q|GO1rbK#pY7n_x^MW}32sfGef>{eeA5(B5sVUZ z4QiU%uP`SOc-G}wplnew0=T*rkDNY|3_+pW_!INvrdW2S}*>-Y&Z@0z}r- zQyxW_YL4hnGXCXL`g_^w#Un;2dw$KcczrznN@$86^GZbdlft%T-@W0xWW2J1eG1HY zWnaX^_9&dPPFeMxChJfhyOQV}m$sj}VTfb|F#cTye|ApFY7Hi$BuQ3jb<)>`jp)I@ zV=sXEQDEwlY7MmR|oOdSzWK`FTR; zn+U{7L-nCe2<(XlzPLagOtY!?`g=CROurAAhk>0h$@Ur-=@W`$bS2q8xgsSH)K>5U zbKdLYX}eV&3o0;NV3K;Zd!!p|YrR%>)oE}@c6^q>mGMf{u?pMzGeYF=M^Nw7aq4GE zlj-VGsK2B2oQK<=)JmS9m4uL0my9QmF4)3lV_67T86f#ZfcBiln6#o5S-dK;n510; zYo;9&*k99p&m||nvcF1ufv+1vc$L<4qTQ%Ilgx8D)@y0|7)xBT)d8K#_%Ru2S*Ij^ z>gVC8*Y6l>OpZhycIgj17zl~K@)g{!8~{ng)M!3w#B1OUjpmoV<7xBD?(wvdAu-KH z^UFmbmy{Xqg=xw8(_%A=^Y4hwkG*FAQz zK6!JwIucdr=kVszfBT$<$#5CW9of9rI>ZClA@oN&#H-`wtN*eQZ9acLxAn#4tAoyJZ~3(K zMQdr3R1$cS%tTF&j@E!6!`5zBdO*u<*RPJZI$i3y1(9OA9hw~9gF`cIb7a3o4Nj7B zRchyz{d1sy$z$n%xGjdqUeEp=9NQmz)jQO0@_g{`8~f(XqW%8T{{71S{o4M$X8(Sp ze+Tx@-VS3LACTxm|6M|W5Mrh!G`&6IU>tFvog`l$BR2uCOMXKerMagVzFAo-B} zJ+isLOzepx=0m#N>KxfWs(RLWacnNoC)ngREiQA1`+qJ{F^SCFh4I{T0iz@Z|7 zA-2WQ(DIeHQZ?c**g8gJ(D%j9BBtlo#)C)QgE6CIW9f(488D#|DMi+Z$n0IUq^PZV`FEsm+Vdqj*8us`JNOGWKyrjP7+IoNEQ;Xmfzih{j5g3 zZ6n?twBH}kl9BvqfH5?8Tv^Gtt9i6j)Odo4#b4cd`r!VV2+dQh1yB2&w!&R`8?Vvm z;H1@ALu+Phar+qI=u4|T=)zEURWIob$&$*qmRKcph#I$ zd|r(%4!oRoRv8$xJs>qt>MsQIoa83mzUimtMh8UQjP z>~!sRA~|FrAUXUHU9UTy>e2+{ax6hph=AP2vA)w!U&vHk21~x+&dlw1#oJIf7^qG+ z^GEo*oKFI~Bwl~3vkcP1CdxhFsJrV6W$UC{C*@UHHnO9+=YHP&zl z*cQY$ATHb+oCk6)QkH20?pe*2&oP!}ODFU_C9mOdVda83Z+U9KsCHZCGP2F%bTp|$ z6u}Ot%+JpgsExaRa5&qVfvUm@a7Do}*J3FEyjI@tY@2U-l@_VKc4@WHeI{2m-7BvSHAOmbdz@wqhcH*1;!`uxf$9=sur7 zAbdOIt*m3L)~&w3d?O{=z5@TxF_p26GRH{ETL0ws(32dT2dbh=JNwny)GW7HokqA+ zqDKQTz}5;F%B%{@&4-Z^94RcJz^*AC2@JgjYdnNn9S(F-vlJT>6p#b+q$Lw(KGQ-P z+$B)bgD3CPP}m6n;DCU%3NDqT5TVYRpR>V|t{(e3$35@MG4O+%^!qvi8Xx!hIPP=Y zR^u`Ua6uytnf-ucOzNmRJW?L}$NZLpL5@WKrO58i9e#WNkKz}FxA}<(*w?Mit8rxc87Y0$MnoNjPNO0An zOvzhum8dF}i>Pcs^1&{QDFXpu71qyF$ z8j4Wa28wB7m(>SwhO!P@U*b_8@=^r?1#?X6m?B&-4?WfH`b0{|pKD@!qh$ZAR;4c4 z-#R|?R5*yTKIsyM%GIY+T}>|+!@n00Z>loUU~&i+_Tt=GQC3?FWJ*rcEr+wG;Z-bE z_y>uAiD?*@$##%49|}U9tOaL94nj);k?v3QT7fd=#$wA)L=mM-k>BH!`GFH=CTxq3 zK2Y%S9Zxti>8?RzsdVp_dGpD8#icTw`Xfn$Teq@XE;)HXm*T51fyGQBluQh152-Y2|^~F))3t*jtP=7PW_>0nadh$2#2OV7B!LsE_+I+$%k! zre;RNnPa~rcqW<}`xQzloN-{L_1HJF82}Ypv|YwD>kn!q_S&FN^%43qu#+HKN3v1O zTf!CD#b)muqk3SdnjKBvA)K~ankGS={exQwO^Vc;JU7{{4!hQXW70mN;dBhjr78!E ze=!06WYY4NtmM-{I+Y9J);ehlR+ho>VDPHndj+RLV6USBHs7}CdpblK63wXLsBgWS z3n4H80BD;6?MUMik~12}`JP|vr+I;Hvp@+$gXH?CYMBhxIRfm|BK#jW7Ri_@cXIYi}&1!Wu;JJ?TS=R9HOyDsSn8NwATQdQfKc(_^Lno~Er}3_Mj`l?Dl|o5gA}P$X5v8YM>zvCca(8Mi0(v z#<3)TRdD90LT>H}VgV;Casq}%^mZ`>yG}DqYfJxPLL~R|fs>_}J1308aUg2qYjZE1 zY|oB|vsi+&A^;?@Rkl0EOOXwf>aj9|MpNgRtsFhPiTOfFCGs?Zd14ec@in(d*HAKM ze!BVKcq@9fZQ)zQ;pI9*9Y%j@XeM7miF~|?!q9#pgPte3mO&^R!&`j3gS)8r)Y65R zjkmNFmWWCF4ZeRkOL>_|pbvFZ(?CboV+Fid(KLvzWj8rvV>123+}O}TkN8!vB!=Z@ zCO~U78@O6)^ON7Qc@wn=F}t7?lR1sD0mx06<}ED+Z;XWO=iiU9X0vynVW156?$-5~ zDCvi5>s2)YuzUakDmJz6sEz;EliZ$%t!(_^&6%O`j08pbU{4DdEUp&&KNpyz0Byz)P|h+sMmZ7i>c| zzn9gx8N`z!ZE%q#ccHV7H%@np_6fM@KfpqY;SBe!j;A?X0DZw8i4T}29M1jcGS-XJ za4H8%F`vs&&U3QhnaUdnN1EC^MIvPOPs+<%T3C!jqc+x7E)>YZGnFr<%h!NOfVE>s zZs!g?z?%ucI!!8pTB)3uj}v9*z1)k*xTEcy09J+VO;b!ZD;r)nxagcNM+nfUZA=6X zJtW{xk_S!M82er4dCf!l={RopY<_){krGw$CmMQtaYKI`r)a7=l#;lZh;fDl5g8uF zf*R$YTm%s1C?$;HMO`RI_msU+7K!AASlQlQGJ%{h1ErP?i;o*~h7-h62}z9JPA=1b zV{r0{q|w>X!h}QL&UTnh?Wz>zY@JVvAc2uS)JaoQsaphU26v0-u{g?FTNb#UCqTd0 zigos)vO?DPQuH)obO<{ZnGO-~!e-Z8(<0Y)yRV2dLl~T>T6o!H?ja$3(jQV|b>dPq zmOb>DEi@6LY1>=FcT>7m%nmjaSXCLyvapcEr>w^%aX1&~W>sh2v7qIfKu4yROp-Ot zF-+G14HuJ>u{u!Lyc`zb$T-7@5ER;Vtxsy6l?MEYVZ>Y$n$>oa4@0jcbrcOr7eIBo z$n5G3CByyHw@GQ7m`D=Snu1blRHI#mW$8pg{pOIQ)9qQj>MbRc&XHJk;*p~>AjAqi zjeafmiy5{zJkkzI{@G`eW)J%)Tcobbq8ogv#^1toLUD0ZN#7#9PdSjD+1Os69P?|& zSLjp1b@k4XVD&X-?gy%7Zv&pwffs!D$>g9xop6gNXe5~up}3b8S87(XCE^guDsVt| z1(|Ks?8dF((cUX8Xb^{7cq%Mbz@cGGM;Mczn03{L?LL{F%Qn1_ukA;n-*owEaZK$? z%{07J*^jS*PN>nb;a%R!y0$+8XpH1`wUQ6ei#@rQ(;riE9NVQKg=GSeY6_Hi-4t;4 ze-Nnei1i=7dEiV(GHkV01 zzayQ+OOSSjjrmLCT%WQs<$Pyl=SsH(2J_A#l0-nZJ5R;VVE=VBrJ(`{a>D&`R{GVCma=55b`}Xn;&T$@3YZ<62sX2s*3gJDtcmS zT$I4!c2cCLV^C>)I(mFW?A4}X{ z3d;5Uz|zkywHEe2%;^7fUtarP2DQhlH&4=8>2$e`eTD0{59i+H{Kx!aXZ~7ek&_dz ztEO|9jcOBBQuY#oCH3~JXcs?|vXm28!*)_%5tnn;`7NciE~v(j!&<)F5HuU2M7)Fh z0Df%t-Ur>}G8?@j#_d=U4^N2WCs=HmpO8h0Ff~6u=yj|lR0R-I0R2Q@ zN$(^v_<{(FzE@Q0b*)OB%HBKXSV8oY^pBj=(Cxycv*~SQh*tqI7HH~WB~J^tP#e}%#6_4z>JocWWhCu6KDcb{518m{g13aiYI{jccNt>^Vv!$6qvAXJ ztA+o4DFM6sqLi`IP6K0Om%6N;x1gn+O0|*+t;5A_dt{x&igaT-L9Ub`sVEUa+_bQP zAgrDl%Zf#(d%V+C6i!reNI=KycAd$$P+~d`I}@WTSEjbWAcj;DZ&oLy-gtr)G*ybj zI2)1%Xp_uuW;-fqf?VlC7>1kFVjb9ebKY@m_^Mn^L76}!V_cgI!})={d+cH?RImDP zy6kpNkT^j`YDxx#qWb>w0YTbf;;$ij)>t&q#*Ku-+?ew14kwWYk;Mr`ebP12fD^Hy|I1 zIZZ6jw0|*8E$1hQrH*yQ+WX#N&4MiNAB?v9W1W~IWi{Z{(uN_Rw(xmNrXTBW-fQOt z-Pa+sgTal!!iG^z5bW#c4u7H|t!7H*C)NZxIh>i-U|cQ8kW_iz=y}}6>@uqsoM2(k zY2Nh%Ykm-J5@Hw3Yy#fG`w=8;bFBoFj&QIK0xPupTSEksSsGZM<|M9UnjHEH%i&N7dbMnCqie z9Te&Hw>fe7?D1VHI=zXGlK60)d_`tE3S=v0Ba3EZ1HjNn+rYx~O>~750z~H2M4+~i zWg086NthWo=8q}ypoSNv-P{M4davP@Jmr&7TkrF|)m9a`{E=UIFGhMn2-!(fh8GA& zLn~U3TEAKgyjB49q}@I(R^sTy2k7?jZQd3xVMSgr+C*=|;)5YC4x9T%v@2joe3{It5JOb;9#xO zs-+~_{U6wF{(nuf0Gji^`Z52($wiq14p^z*y5cNTVF{cL4b%0Xovy;{$o|bT(CTC>l#R)#4B;N&s8w1ToEgG4r>z8lPJ;6ehQ4z)L9Esse z!0^eb;BMRrCH9gej3A|6iRPWRty3F4(_HH-?-2^!?kN(!*T{YBn3m{EstLb4o~lWV z+;p%kdBjYtnTn!Kp^V&Qoj98YP%T(4bsI;)0hyt>*OxF5B8^MhRgr15TYH8KX zZHSO(;V-kNF`HzwUBJO|PC_hnfJlP`TGytTtWO}O-HfxJbRZ||UVYV6L?@T&gG@_n zAhY`&6LE*8)TuE<5QC^i2uyR6*kaypiMDrNKje1W4@dp zp$%8cR`W1Y1z}z0GeGRFE=dc5DI%GqbMI<$rp8lDXX4Wh?xCuE3WAjLoArweDw>BR zPXnQn<+)@US;B*Ws{{|QVGK35W6#>Q15asIBd*PRyBti}I4hDGm&B4t0o5De>gxHU z?vM<)z~?;ZPE@nazy`dKE?)Or6AYf`jg{?EjFkjXiZv1?9`sT;(M6wDK}EZM#LCU7 zW?*72)6|bOvaxH+pS#dv%r%`XGnl6+9>K3Ao5;*W(hlq*VZOxX5<61%173l!*JT*7 z3)0iYF+MEJDG~!P%ME64^$_G0SrGTlk&QG;ZIU%3F;kQ@5*8l3PbNr{z z`!_jPxzh_mJrO)sgwMPubklaUnm|+>woO zA%E1W)HTZcS)+tVzppepC+4DH@Y2rhlW9#Z(QU6;3vEms651x$R$jTpfuh-h3QAeJ zd6K1xBK~u_Zpim+Vm2F53 zlp%#&HPeM`8fl0Qu2xrc5ZsQXjICz`uH9ywqhUd5WHpfFHhw=i+RD{nK=HXm{`LEI7@N;6^T>do!KYpT!J@XPdW%tG|)u;avNvRA`P!;sjo*Exv(3d zlcCp+4_vRU8p`Jo`M*|{;(5)}l-@N_2?cN$RQQxl$URxLwqU2oT}jWI0x^ae+cZ<3 zJ-qm{#dAJ;eDP=J^>+8-&*&{YGI>A!g-CVNWFv6#|G=^GpN_)Q@`nEe1@73>59^Oy zAGhx>Zy(Lx?_ts{Qoe3`%2#AtbPP-UclG)B%2iT3jtyp1se%3tIUE^O|M2Kw zXgObr1*nZ%uSe4GJ^45!lH)*v;kJ}Ljx1mt-7s3v!OAxI>9X!l=7dm~tC1b*5XQKzWk0A1m^j0zl$Uz7^nVh>2#ugb6XhNjkYrZ4*FizFP`P8SPsXB7LTh>>XsPIuuC9 za{Jq<9kw=hW4{m(?czU*FRuaQHhdW~; zoY5!GZ`)R3B(Bfa=2o=q{Wnd~=|z3e)AM!+&#<5~+k=B`Zj?Wm>GipL8{KmIWM(!2 zq74XSSFSBeO6**N!`Ye9-Eo&yS-!!h)#`+0JEh*0(b#i!fA{6eU_g%8j~`pDrQc7q z$CsBES6Z8`E7W;A8VqM!t@f4H7D*b(S6-BURB;=N+uFAEr1@TYYb}JnL;~>Kg6%Nm z2TR3CvNll2<+;HPQsRJNSC(6+tt*FvE3LDZ3|mG?)k4~E%JBjfbV3ZyEz|4fXn>6! z;yt)>B@tiZh-3x40uz9aEiYmsk0KII$56H18H{i6*Eqv8@6_kGD}+Dmygjjbk>_IO z3BfhgnAc_Oknjw>&f}5Q;pG*;#A4ey=h>^Xo6j#lpMBnb{>Aev&*z`7Jl}l2_55GY zPoIB$e)jzNR{QGCQK$9%`DJ-qe)oQ8l&p0}umidm1zx`KpnLd+z{?LR`1wSvuHC@_ z_OpLbAAMqJVhF3%isYdJufJfBaj}Iq3=ULMTcqPSSUsFwXkW1+S!InVvVfwm#W$i$M?)t<<%ed;P2aGF z#Fv_2^6X@JQ`t4IUHj_V)>h|NEGwJOj~2dKSeVnFZ*D*TaPwP!eR*@;{#_$R|8TeS zxYzmqWcjPkt!*)^2f9e%Dk%N@=*xw#zvkaB7q7KC3o2Onc5R`> zU8LB`SHJ!0D}Hla{`rUXZ+N$O`rUw*a5xEBBMH4z>D&Elepui2chF_^_$3mWv|Kd88R z(_nFJ!RmbT4Z$Dw(U;e5s>&@!O?CV9YsUE6yZe?e7Z(5dWvjE}U91}vzF}~y->%Zs z;;nCO7T;=IUw%WyH(v2engVDH^vktx1(+|t0Sp80Q^gC5UjfCpUhp-IFd;gA=b!(R z0zO|=KVQ=i{{jmBrGI(`AiG}i>o3*RKdDF$OoflUx((}VMzQ+sKYY#rch9@~)(bS| zuWkc>a=}}(2CaV@wtP#wtKa^UihxBWUwGw~ zsm3@Bo{P+I0Wc*0-iEa5u%HXF^*{e`%SQec{{l|~)<4wrm;Yc5 z4}A>Z@WsO7stjd!&8&uguG zP7`J!3F$#Ehm1VviPDL+<-j>e-2#mCI|!09Kpw7K$&MY0D>5i^*5C@n@Yc4}4(OV* z)*ntR-qbJ3m@Ur*-`Z{m$20Euv32FwlTK^r2rB=h6YbN(T_|zZ0eI@af_r@Y_zd&+ zS?66Z?f_YCJ-q$HtrkoGqBtBKaM208M~n#?WIShr{7>=@IN&eFGCDA(&M)3OJuSb( z3btYf78{eON{0Qstr%THV zygV87cYBOzSDFG2wC&Lzx3%yaNkGxKFZK~<4#{bvFnSY`ev3s3c>vA;entC%aPqxwF5Qi?41AM@0OFK0Wmg1hVX)#cl1|LUEfj4M6T}Dg~ zDRro}2}Vbz^O;D>^N?~=G z76aoR0upfmO-|qPdzq;*2Q5v&TJjo0GS4R;31s5z-vAu52gS7yI>R)34LD&&E$$t?1FP4;;U!JPeql=- z%%h`N34iT5`pY7}3(AwFQ$h2zN#M!hSb8KAs>WbhJRu_HNyQ0nJwrUfE&FMnac@3K zO@Iv{_tK)NY9MbcAM)?Vk2K?pDQOr)No{?X+W+h`ikQ)jRAAp&f8V9b*0JiOg5=o0 zv=ADZl6XoT0yTVcNdW`sF@PHjO<#2eyZN~EgcYUl23c;P611Q(xgbDXE7E$E1q_!* zGb`EmLNj}ckE>^WnX+l~)$+obNSBo2>bQV4ku~R9L#Z^-kNqYinXjF1D^yCcS@l!A zjEd^9*nL_Gufz|q6+;uYiqpX2YC4nSM2@&# z3Us{pWaFXVJT+@ST|FAShOcT;1_dLHQfN3{UmQ5xwG#v^Hycs#Ob}~#Y zM2!U~h)AVD1r~1?QTg!GmW_sVa{k~D_7)HXF8SdKiERC&C4PA)OJmp_be}9>cYDql zemp@#MiPM4Ldk4cSu@|X+3!|70)Q-thi4jAaB}FBC_>g7*{i|!9^+_ZsuOP+2I7=v zHyU6kLs#@$5bS;|U4s=IHWN6%>6buBqB}n$Us-9O3nXAOqXau;W+2t}ECb`3Eg@^)pMisK54@h|L7y|t{1@1gM z=iav7mv0Edv2EruY|RVck{)I%os9vXhp%)28H;1o-x$v}=p>)vV0hG5Z^OY21T%&p z!wDppqh35{kyFm!VyBUoaMI>uG3dTqdOm zBHCDC)~1ZGmFesHP?=ar*FTbVoK_7KYGT=dfz7^KTxdwM^)Fb8HX}viFwYST#&?HA z5C^`rIcia<$=;2Z2?HQt7j(}$w~KX*@dMc+olq1)>2e{a&l`w>i%SD3ge!B%W=mQR zBaCLytLY!CkY0glvh~$prIz{w6gF$g{b-}$=QU>T_C0QxGd_aKz-gB{M-N{?V%b)K zg${lhZ`mIhvwMT?_5oM#ZI8J`=8QWS-t~vBDgqc5+{7mi3w->@Ke4xyA<&J0U`Y}> zYhFFGw~Z6F!WvCrs*CHhtt+Jh>>OYpO{b;^;|ZDnqJ5byFFhhHIefrKu(`o5k}cptt-$mZ8Mi;;1t?jVyU(P`EsRDS)hTY^EoRc=O{Tdj^G<`4&0Cg zGCkQQqvq}ECOqM5&;oZ662^-tK}w3)=cRollLq6Od>b?{?d?iJS~xQ*3`WpEIH!MyYVo z+e7OHt?}G$5RWxZ)^~acGz_O$$uEvj-eX>{gafezA5j9)aFClC}}Tj+vm0)uoT?>x3@#tNZt))p*BOs#Ibq^<3McfsKDjqeFf!28||*x z&pwlAO+zn8_I`LGcW(7$k~Z%OCMk5CW|C-zI#OfQ$ORj=$Rsib&>(3vad|8|_w z(=RP^-cU#a^Tq=Zc?fTJM9?7EWNBHCY*{Igc4brf_%We4s(9)EB8vE8(i#h+Hd1xD z(UiQh-XO3o_DV5Quf7;zEP6tz`pu~YEj96;fJ<55aZE=Y((POi|sFNi$$dU;J=I+ zKwDzDdYK_EoEgnOYogyqhnXFbBpMue7i^M9iNVg$QqMd14|Z8yt_}pPFnSeFj({qQ z!H*#bUfC%!(t&UqEzIL#yfm{{Fq2fo9yMfG>U>Pu8F`Eq;vj)j^d&h;L3YCoun4|^ z<;Y3l)^f`$aY-N=>qDrLUv%~H#DUfVa=x*;|ODJ-kyld!ven`Lo%zNcgZ|P2t*pZUQ zmP{BRGkeU==d)zkvf?EY(LfmF$!2HE08FLXYwya^I6uv_F_m5i4uQkaN^02zI;dqL zldKnrG9JiU(1DW1#sEhzVY?!=$O5~UPVJ*4oEWiS^n)aJEH$^>_}yalnN0#Jm4;r@ zQjI*&Unf#>Q>HpfgrdZjL?H3eg=COb2~KJ64eB{MQ{C#La3lk1uijS6L?LMl{W3J` zMJd~JYr6#j^;#c4O4v8i(KB#un{A`B?Y@%oqqse>E9~4XJ`etumL~HTHUX9=dm9LCDx2I83Ls%WR5rG%Y;IA( z1-0mQ1X0tpi?QWSjQ(p(TKxZyxpVJp>pJ@V|L0Z6YHFieV4So~BLaLJVmD6h*tMOc zDWO<^0L35?Bz%d?dq3ab%&fK7-V(l?w&!`CON{njdtGMDn)}T1Fc_C$2NPN0bwgj8 zza=wiN;oI@&t><~{uP?B^xX(@qgzqW>OsXneQ6xH98e}ZfwQs{NCydn>!Bqh4q^eX zn!1)5*bIg6pf^CCJ7bz4V1B-hj#11aYVo+ghUM@FR8eJY%FY-SAkfoqZgflbCOY!0 zKawP#%98i??P@X`X}|N!yRxdUc9v@o(-#t`4oAr~g6#y#F=Pg694Vr?m=z$ueqB9N zZb}hjeQxexa9yp@{Vw&vf<;%Y8=1%MDwZ-u1S28Ddu)i~x=x=IOF3GDLt=S%xocgb zMgL?<0JPj5otyf-KRrLGJupj3?aAq2+D{J>Zp#)yEKj#z8ZJWDVR1q8hkGPmF12+x zTJ@pXSaiIqTsE~3DjGU=A9%P7-Q**`{qxW58#_9;1nFGo)P7U0J0Kd@q6wwDcM~KH zhW}k#hkVmV(QvvFTDx}JD+%pb=D)&{d+d$r$o|P6#@)B#O3%- zl;k=KEvzz~?e>@L&IaV_2R~c2jl02)l^b_bB23_AbAdwkrM7{`Vu)pS(P+b|ode-N z)Qn>$I-vB7KB)x$%4|;;rJ<`X=kdWrmn%A^{o+_cC@e_BVT<|vV*V9K-TJTQoYyeP zsfkc-e{KMi}E| z_t&7V^*+EQH>?^}0-9oLb&dnp%R8s52^?9ib`&kW$P;^EMAK|fgUOm1(QH@P`GJzs zJLM5FJFRRuqv>}98o=RRBo2o{y^fI)KF_J72bYV8T14oOO8=v;pfsW}i)aSTTkU?A zx)R1{-n@7C;|exFN%|p3B0LRW56UA$%L;e~_v&Y& z$)3c|1Xz7U0+x~xZ~+uyI(O)ZvISP^Rl>?-4-F6pC9+(s`;ayC!C>MOO&`Ao=vF)4z26l;>e?SN#30HkF+juOTY3u zWss;=YLPtAQg5C%*kjUCL!V}_SlkzU7z}eIaT3d2P#PdttCILq3+<>^lE{c=f zV~@BkO48g5-_uXTX(>1UDazRR)m_;IeLOXYD#*{cD|t#$`5uwTmaQ`P z0E)Y%M44VYlrTbgCHxe>u&Bcp)N?h~vsx+cLbD*kZ0ZByBahg+3( zx@Wd&-O4ubwZ1wR2|5peOef3nfS6|_M+(DLZ{h(fjN)_EtMsIJ=W^$KljV>|2}cGw z;=TSf0HF}YBcR2Ps>8>|)qT=2ll!Byi)ru1mqY#A(t)HJf%c%KgnPy0!8UkC0$Lk$ z8J=(K46R!mH(1-*8yjaZc{WyEE0y{)EIaA%4^(cwLjQxo0yT~;MN@QD@Ifci*mOV}y0%Fhbrq?+@fLQ8pr z^g)S&atiS*VV_}07j{-Wt1uGlSNWpCN6fpXSU4N0+Yn{UWzBWn&k`^kl5M2Gx%XO> z-%yttiGN>Hkby+O<_UM_&`TSr#u}DxIHqhH6v%7YAw%aSz2Nd#e<=p|I^8MZiI7;K z{fffaW;`R_gVELmCi@|@v_ER3>{6j2mNX-|;^nY=ceqY`NOaPDDsxHqH5o5v$$KC# zml{4I(NQz9e9ZT2lT(v7E#66u04A4*#kr~1I|CLbV z;r5;3&UT;3hF^!9bv@zNVQ2H#FnRMyuWGx#{j27=Q$At^=CrV$w}9l3nRnKXBc??pxVR#GihPgO58&DAmR585r*FXv_<%b$>u>EW7h_8zu+b4AC z|JXe12hCXsM-0m;jV`<`|Ga(s8Z|=Cv`+u0zD|(GXV2UEIvk%}$1Nav)_hGwpk`6(Lp*iq74L&R7y$v^x}lujZOybjvI+@t3O)h{`{Z_C`oW_ zP#g#Wryw2H_T~?U1HJrMtmFE1W&~zPAU%o`D?P%ANDYXnNuoY4hn?ymmr;kj&=jke zI^`QPeivKG-h*Nc=e6c^uAmGo%P3PQ--!`7vwSoDWf~8y<&W{u{$E+@pM3ltC%J&g# zxIcK379S#M`EziP?D=9W_LUXK9C+_WrOU_n#kF!AaXKTGAnw49Nu|!Dlcb5JXO0#q z81_XHt;>=_e?P!PAsja+$NET@(%B)x`aT_>I{wV%*|wjJPs!qx8qn&WP`%^e4OV-i zrYx=Ipfw)Zs_1#C>d##9^^tb#9 z2E^fn>KX5mPo_T5fRR`of!q_)&2`%iQP$%V57z=$Ae@tK8Ow&Dqf#HC8T^*^py+@7i+xXj zg{mj3#AbVfh6Qo&Ii1I7ACe0VRWZi5e?ZbgE}@0+(s-Hr7d(tz%ym;pOti~1JcUFy7RG1WfOPZdqV^zw7jMvFs}|DFyCV-;o_MZtm4+0_4JaCG2x(EE7Li9^>u1@SKE<=^%MF`$RwuV?i{bzl*bU??=7~ z$eunU&gMkgc-*^!iol_6e|a!`xdHG|49cHy37f^P>o**p(4uIH;7!vT`Z6xkJHZgK zH-6o2aVpGgwypxV8#ly)ydT5nh6=GSEnUh3#1fI4#R%hU=Z=Qr*T0NpE#rBq#nPo> zzq-66)=Q8r6j?ct^o?w7G$16425IW15!$!*)dCgq`gQ+^2r_++>QH`OsJu2#gu(%f zqLu5hZ{HGHP_zCK-GbXiElcedF&fdud#F7(C?Lsfw_VpgR~!vCAp6xn8xod*!kZq1 zD$GwpIZjS6opZV1j>HF7Uli|92R>Lydd+C*XxNWEbto>)PqU0DOykA@y|)L7~!V=Y?gg6}aQdfT?^{2Le;2iRw@W$AN^m%?p zx89AEdY+|8Nu@7-rYV7EdK^oMIM3)u$~^CEQaYymT|rxV`|M1?PA{>lVN2$4rpEUV zZnl>(`J}PiYiu|%0y9Eb4u*`>^sBpw%$IYYHfcD=bvlo zl|!O@%QAuuWD9)|tlL3v=v?APD!I1=BugVn9DPISd0KQSy%=TSIL(qG7pz_?9cV+B zh4sf8BP7<0F7+mSBYh~$*K&VVN{)DMepPZ?{L7O+v~LnSUN#3GZrY2E%|qg}^`}3Q zCD^;vll8BZ^kPvnnswQT??DYDJ1r2mHmwi{56C9*JMB7;v!S?BFyl+8J5~&TW20=*dIWI^WNo|1q_to8{VWb=YfY$%T~7d7+tlUfq!o?>qzK!N)C@-h zH;t9;jCdA}?u)|b78+023KR|GFsZ#al3O$|{V=!CT%f{Ja7bXZi9FVCYhfe?nXueQ z@FiuHKc@W5QYS*Ibk&%Mqxs1}4U7h`=JJV^j2=#G0x~!;Slo%gU$#)fml!$uAumBt zGdJqB>E4?RJ{P9c%ez4Ro-n86FKiT&{1HIMqpw30RM}HdC=up3Niwy!F^}0E#Wj9$ z=WLsf6-O+@OK;dJ6J+9z@nNoqKnCZ=SF^&V{=@lQ51I1QTa6jYq5H`jP5pX6O+qCU zEf{cNyJ|`jf39Cg1X|dx;whoFqK<*fQsemZ>(6nkikJ_BfUT< zOI9Q~;}l|Ec)WC(DY_vP>Y4t($C~_D<7K}n|(f6lPs%S7n3*RvX5UiK4@+%j`DY^}E)45?+Zw zSl@8n)AYxxyL6-c#*LeVc1K|7&GwhKOv4Q)P}N6hzR^}`rBz#a zzgvf6A{G4m8|EU%UNisk1Suu=q;a%QAKK{k_}qt{je-1G~dwVhBs^|R_u=3S19wPe?TaXfUAot{6j zz^QoUqFH+7k_(-y{CJGC=Z@79iR>f7Hp zd$%UV5Z)@!*$1&kF;d#QT?KV$S&41S`d<0nxj2#9J>~l?J%A|UZ`b2z{7W1Jh@oE( z_?H4^79qk<6GAAfrtlJeN@|KiQQQPgxHeQtp^2ht30a8O-X?h&(=)6!C@&w5Jj##(VUu?9?-IWmT4sqadyxa8*Q>bb&9X>)PrU{amtL}}qLWZpg4Dp_Ob5+Pe|oI9!ewFxkyE%`g5b z_@BYF658mXF5`vh#&ehU#L(g!rePNYn0VZoX;yYnWUL6&uR{$M1@PcbjmLhjh{GF? zTxvyuwiU$&XhkeuP8hy-|B0!Q!-+dxOyHN+?F9eQ!RXv1t9|T1%Dl+#bX6TqDJ{qJ|oH@DVJm_SFv5bE<>LymQ^amI6Q<4X`$8pvA=(D{W^L?(REc1J{IXE z;ah+HQxBxNKVCZQ!z0)g&Co5mZaAyd#e`gbIOSqdykN#PD-yq^BJr(HKiA&*NnxLD zCh6q5e2MC)fkcdJComA#yZPg)<|dOx>WV)rcON7T>=}^Sk?V}`r1aT2^>W;nRW(F- zouRTBSbTpE5K61F*pV?XHk3zd|4!o(UGz<+! z%4^F^*j6LSfsVfK$nDdC9k$Mq5 zXjm1J25i63V&_`UH?(&76uK~Baf~7@AlH@P^7kJOui!>bMhgm15GM|fFFG9uU&~+Y znuOLL|Ewjsx>v2eTDa(Rji_U7D2}A}3~?~BuTwn}_KAAttUOkX_>t z3B?Dbj`DM!vR;{oLJ6|_CEyFyNw<#b&g$$Awj-U`%YufSd(FH4_4+3VOU8;8y%U4? zkE)r3qMtWEbr(UYaEYp|Nid;%ncQFuuI^~o4NipEN~-d(W=$mMp{6UbOK`7EJ_#}L zH0z&|*eu9rkc`zZ;JnPd4aW-xSg*O5~iQt-` z6=GP7C7C0c7-$z;nSguEEY_7qoCBksgGB3W?Qq#Q9g$4DU>XiaB}oF-g+f(;(m<7E zK8q>gbXFkZ$y$gJj(av?imv z6CDpFm%vmIAv-CE`ecP;+t4Kw4#nUafqy=CSrUYI8qXv3gS7jOS`=_RaTySYby(Ds z*=I*SW23ZkAS-*avX8cXT11}7YFhCXo1%e@=hd17zdN$0`BW{pG=Pc*0T+fUS$r;^ zxImJE9dwIikr4oCr{lVIz*4hVsj!X@9l0xuZ=PcrQejpk9rNqgZ<+Nm5)cr<^b}^` zUp5YOhyURgoy5gO$DM3nBR5MlAn{KnT-t-IJc`$1e5?S>$082GvPIkLdmFt=feJCv zs#fGR)g{+W7t|~ngC;(CLt|)>h@Z@NlzLVg!V=pka=uvX7_74eH2~ zOsS9=>}R1H`j8Hkw{Q2xQl%5z`11<5@g`Fxtx3F0>s8?o{YIS?4M{9DI&{mTsDMmT z*-_Gtk1*bfTJQ=DZ>fChJk>fz<}j<&GvXR?fx9NkIFdyQ=2urx0E?)@OW6vb?gQ0c zb_vfO@xatAT1h-0p$hf*aOq51fE>^*qRHY=np|~tsI8x5(RnZPF*K5I z4&wSDsnkN*i_>Bavx2`&hnw3wz3pTm-&uRa{RGUNCA(|DIy4O8NW{8AgSMN5wQB~^ zhY>kuhZcx>nLPdwL5>eIIwYsGKxZS=xvXYe5C=yN+aK+Ore~TyCSp94_z4J>&}l9a zPK1ZON+E0}hLT#}nbD{?5F$QuOYpQnbNwoWhGgqt2=O$LPA9|lh(9qrMs#P<_3Mb& z?Uag-kxiN}{r2q|Xd5;K2rEE=m;+fkz>x>ooqX*~3$uGOH_oE29vwRM;FHjis*r0% zL=n#R(cg*wPKHFYf!YM309!@}y!1tglME|Vt{YR(s;_@2j;y_%A75q`fwNp_%vq=b zgqFSDb#0F=VoUGWkR63nR?KJ)pqLPNsmd*aGZ(V(>3_0X8MksAC}Q-XtV z#)v!_E$$LOSYAk)sD=ow9nvJ`!!g(v!4yuu?_Z9;HKBRgiH9v}j` zZQDQb24Vps^TptqzgCr<%P&Okh%d6QZ0{|OE`<|$OV!T8=i4V{T83X4l_&vN)ZxJ2 zN+0wTQAm@4=VB7RMP$Ck?bNo}T$I6B+%f)aoL4A$aqgip2uVlmpo?EwxY2r>BBSV@ zosA*#<2P2#P}D*vX`hI*lV{^I#ZE&_SPv}<1CR*EG*W|6aWg@__ z<64!X(_?L44UWkhCY+7FbiVV7wbfU(r9}P$oN=|iL>GpuW3g{rfyN1$%CF|}LL8XN z^v-F^rk-(-I{6Tc^sYE6**I%N3RWQ4iwU!#Y_JRIN&1gC@)DV_GSD)}*_IN~aV=s1 zf{=LL42!2h$%YXUIcu&5^QSMwI4RIqRuBJ$O6t1AMKoM8HrQVFSW1~mrUYYcS{9b0 z5ld5bhgsR-t3R%X#QE?Jfg*g)<0Oh%XJ3&Qk}c$qvIl8}MFhMlhA!}>)YQ#@c}=FZ zfQ6dHPn4`z+n)D#Z6SY6Ak$wfhsX=ulQ{H#@(UAfI`D{CCC6;Y;g`hv4 z2$)3{yb{<`QP%3!R`sx}Ibi?pMr}ehia(12g-OAKMu=kxkYZB?(0MS3l$FLAD?`Za zAuw}~T^6?x2#74^8J2HWqm)yQ(m8iOW05~a(3~ojwt{Z9PA?|Y*507iZ>0{{%s#bN zEyiY2JSRgscH2Z0W+s`=+}4|QGnFwoo4RES1$-d~jdVaoH`{cOgvB%Ha{q#y1aV~x zP7$XA){8|zCxscfdhff>#v_kp<&jWD!s$)OQBt|(g;SGBUl5OOm_p<^ z(Sb&_f+$(@yGl(*7vWuo{M)yBq~O=`saMjmKA&1&j^RpGRV1mIEs5XclgvJ=svK~L z+ucX@wkdtB-IZ8E4}4kjb~@!bNdt;M71t!?EX=DGQG#ui?d1SGO&j3+Bq4A)5!Cen zChHnqobC}8P3L_4&@NQu0ulha_zN3EmlhC3tbH)B)$-P5IJ=|F`|THAEngYyaif=u zWLv5?t9)#o%B|w9BR_AH$$faZq(=p3AO=U17!;eJ_}xjr_YtjHLGNw+TXq!*&(Z8qX4d7=(k@ z(WrYq+F2)!oKydItPFmS2Dqfee~!m!pvp@MAC+>o1ra25M#B!3T4N1J z%24pPrOae7Ss4=$p$1H|KN3c2pJA1hx=NR!cJmwXl}FVGMfsrah*)2L2&hz3f4_23 z*!o#F^l>_VYRq-1Nw7p9g>GQ`mQlZDkTtz zQtA*K0H&Y5nj`}8Ye67p8jM|jFDU>k)S1UOF6Dv*5s=C?ksu8jhioJ0qufLO;^^5y zbfj#4+V0M5lZ<@$k?w6lG}H|y%WsEaw$-rqSllelf z4&Ic5&Kj@1sot` z6SdyJyS1g~oX=GK zZq}f66$_NfcmQ-`Rn_2?(=%f|jX9}SohZF{aJiy}LToQ>E#!oxDlNgwD{4*$sST65 z21LaMOg~lTi=j9NOVV>Bj=4tj8#aZi-Tqb9y@VpIxfj1Q}sD%>`~h#|D_r=OvDJW)NHtv z%x!iyEf+Z4Y3*Iyu|JCv#^hLPt9QvlwWn#&EjSQRA z-q;5uHs6PL0ouBAQ2U?J;Y9MBKq)g~o@Sbpp4zlVk_NZmJ12D{< z*-W3-=DQIfb(8xeL#i~J2((J=Crw-fhN6R5^`-+i9OS0kgmAm9z>e)!pPX|AqR0|F zKM^0?dy zhKwGy;|a;8xuZlWXdvMfCkWX|ER2Wl+jNSgN1^*$&51C0%qjkDL&U7(GZCwHZmVgj zmRi&-$qqpWAQRJ-FPaCO;XZClJ+VPEeL$7=^RA)rYBOK6 zF{XN`Xcp%lSEAS$jprzBWZxK8T+&d8L3Cw~&ozpn!YXx}5G_hU7g&n}^R&q? zXnbk61<8Mei%jCL6i%8t^H0jZ;4+rP7j8v=8{Q(cQVLSi0*YHsPK|78fV9wp@}jb+ zZgnC+ru;yKUH6Bgc(#~24Gni!?AQX-#1w(b#%Y_|06nX=s-(d>f7P3`Lwd@J>f`H4 zc_t;!6em)z{ioqikS;q`K4mMzY-b6|$TTCs##(qO(N4s(aQd;;iv~!HtDJAGxM%4)L9Bd{;#nf!rKsU(m5`3N8{Fes$!PY4vAhr@oaX3!vd9{tXvx%L%LggT zg+rH&u$+U)``ag^wI%T*e)_pj53Q5AgC|JE3e0Q6WOI1BO&4^qoH`v-=T$Kw zpsxWc_n%K$3o*}dn>(CjO4M;tVbScpQNAG0=ZHnkTWuwP7l>tMt`PJf!Ep(5h}vs* z7c(=8nrxYZzxHc7e zQD5$0jAhq_&86qQB;zOC4K;f}APqav-g!1&5qa6$DJ)#B*)H^AsffdI#`q~kJ%GjsQg%*yQLZ=z)FQL z`05;)Kn49QqSQ2ndKew%J+S~uQ1_V!BPfJLe^tnIa}hw+Sa2uJ!2^Yu zFt!dB)er&{!kW<_N~@a$T1YI-gAaYRCcZM4m4ZgHzhBa0I$uea^;1>6fH-S1@7s8_ zsc)j5EWkIO*742fCQ>4u{IZCMueROGY+QC``DUiz5s<9Uz$m1?{AOD>!3MJw1FRR( zi?ShD0Y%x742+(G2FxqLBE$%Wmn6xH(tFm`K2)|irkuDiLLb<78xuXUrK-l*5U?#= zU=UYGwO!i5>x+>$=w3UKsOpZ_6V0{3*wv-=0-1ujaNjLoXC!Eg+*8{8fa0CS`;^oYO;Q?Ftqq3hgE3`I zqD_iZX|(zEVtB4nCAOSY7qr$Umv@1RvlRxNS_2&%brIPr$^CZ-b8)@6n7AF=6hrER z#`+MQtpEFBL*H1@l-ejs5vYHxvIMne7}=LYoWboJBPw1N+Yuk z)((s@X@wV11q)VJT~Ob&7SL27WU8^8U>oV&yg@-w{mo~pwBoI(>?jw^H8c$Xk)|0p zzKI+nzNAZZyNsZQB0y{{yM!qy`CJu5Ku01_s-^==$i3ATZF;7U-LpEGN)SmJ<;Q%I zG$)wH#;i6G-SDcO3QRO-6wj(HMX9Nz&`oY|M%BW#N5+X#cKzGh5NwPXYCZ&3r`=d9 z7U!ztf|grhRTBrpRS$EBD)(WWLl)Oy3sB>^gJ?T8(G*v<1I4vRotUKry3TV5pc$5U{H+JEW&s za{k;Ik5sgX_7lCaKO1{eX=!$(olQhYpt+&IG!M1*c)nV&c2OELMP1WNLv*{4tuLEH zy0e!ra&bJ1jEOQ^ z%@`x6=?PGE4H|O8Ni*06xq(|478!70pe8X2Asey~yEF&t2m$t^U!0eU7NiVYJ5d3D zBtC`pz8n5tnv|mmh-jM@qT<&9+7a;`QO`0@tkhv`5@wyO`7G^lH?}mYILrgmfF|CV zCqO#exLHR}764K13P5SPUQSrHgT3*=n=r@)<4VQ=dKZ)82rP<-*Y@zXjIif&IOLPF zn^6SMqtk!lXlfr%28Yx7n}4hcgqmKORd;4}Ohu{ekDKi?>c-L~;er0QdZDJU{XpIH z$ac`ahB=!)l#G6cL7-ex2XErdc3b{a zi=fB!T7Zf%=?1k)tj+kl?YGI$QBwu{_={bvuKE3TAR9o;$!n>&vo2T8b@2|=>ImMb@sjwb@Lfe(a_tCPQSU}3Iu$)PZY`TwE{>vx+5TdNj(pr2 zkq_{6wJiuimOXerhY#=*yr^F?C8<5SZo;5Z?eu+EyD)u%xmro_nZ_03 z1pf(%z+{ZZ=P1CELn0K>qScawqEf$RowBM9Uz_6;(;rxv^jI`lxO!Qc#`RNK_iO3o z%qL?AjFp6zB&2kBRiM5=KBg{pwo9j#q$9xZd5R;sDrndv_wHHgMN~QuFO9jIN5J>M z$%w8?)%gRjYqp*=I+qCnREK_6zU!ooN@NC@QgwyY*2rIKqvH+=vX6@Rn5)ea)64R% zOrCZ^s3eGzW^()=WC8DDDZ65Sz1|6fynQ>Do4F~Gi!ULq52^%Ac9V0aT)}tQu_!m4z3^f*<;=2p z5g&B$W{Ctp-@5owU}~n~N%!M9$md+|KBsX@z0h5!BkKWW`d<#5b_uaBYd>dlf7O9i z63{gJhMT0BswjR~cyrbYbJF4-Oso&8hO3plSwrOGwW1_Lzf26I|meDbM8` zxk<{7`(*68OvBn}SYJ}%&&q|9ft8!4d~87MRtE)`UM92E+&c?(N-Y!XFSAu_5>+-! zL{p^73ow6od)7sk%P>5o;0q>`Y!4Ri>ML_o_hl-an&(zE_1Wd$;+OWRiI<-Cl4{&C z6EP`!ou2vBs>$csx>6F8Jjjph$UpbG0;z3wvXmG%4KSr=xl12OHWs^aD1oa!(%kEE zq$pQhH-LpyR<8*x`%)G;;xl3pT|RSRJei;TVEVHpcg1P-hd%ErFcfJf|4@(qJ^@++ zRr=c#Wxc_N*SSqqnHk-M6(#e;An8IN%q>{z&w;ndwrL3_y<|g#>~lKY&h>0dfTUYA z{o8Gvo26YD)ywJLf_8;%lem=033OgWs_<`XFcI3Zs25OylSD*kTgqScGX;23+J=aN zLJ%+STHM)0@jw=f9NUC`RgP^hy%%#_u7W9Pfu;emj{zwudH; zxM@~OkBx18dk>b51E?4DkS6CJTgqPbb$aRZx&knwhG-n&d?!Ms0UA@=Q|w%Aa`vW; zhYX3_*KB=l>P8n6Wh?mVg|Qb4v9D$rL^CGXTkxe@DC*z(RTln+Vy_3%PzZx`#Xu1g z_{_!KyoDtS0RJ2jaX!3EF4(|eEna&h>7<6+&SD~j!_v6?VtZ%9Eerv~5MW!%K?g(E zfg)0F)(md}OqQamNt)bZ0Up(Xua2ArexOs;BDF+!3q3^=%cB$uCu54~vWcXql1@ZB z>$~K|s7YY%UN9vijFQO9_4DdYJUFH}iFA2+JRZMrB7mgibbwQ+&n2Rr6ZvQn4Em?C zDtbFSRg{v5IXUuJ#dDn&6d*Pa>)^>7;ZgQVbwD0-GA($O5O74rY--AE_J<0d)j8a= zaP;@|i<6IvRwjU)?NxW%YgzI*F`I*fdYyU09KA1Yg#Cw#*PQLS7Hu?>YZt6WYt&kI z$g2FU;wvQXobGYA;QB^TO8zYM16{bv5-j>e|LGm=>7woK(fmhyW3G4M0(7pA-{gMW zUVF98mGL*RHGZ2ec9(E%ivh%}NFpUJ=dTBced#2&MstJ#`)_ zCeBidWCJ*ql_bx-*Q~<=B6)uEP|S&{bf28j?JwJ%jb#)%PND)NTmL-J_r~>@zhS$Z zX6gmeBm`t;RvMhx2V;~FoW^a8CdTW6cd9ugUb}4OYrC;04B18t)oRjvIUXLgR=dnh zcR>vf=3ZTPnW~{R2Q#fex+<#s>}0sfpI9#JTNyl2b{6zy%CF7M3aSKsSy@?G+}wQK za}#QG!g*Tq9_Cu2eblCPThp3r)#$G_skz4PexHX@qmyb<3!u;eWuR&W5J^^RqK)*{a5nL;mL_1iib|c7s zbp%cZW>;Jj5F|)bgus@_=^QV(Wesg>Z`K2Epdso6gUic38GX$23^{bHve2J=?3{Vz zLv1wCYSdI(U8* zlEZI-x5Etf#rukD!*($FK#yyC9Jm~}x5{$`;?abSuJ;dE6!Y<(&iz~e)Dc8i8;VPI zDdz6-gIgp)5#!6oCJyV95{acE360{ojsSXTFvp`u6 z@!%5aF5#F@FTfEjbnidNoOb`WFsI%BN9HsThjt;cU`|)_88e74?_Er%9l@GQB1k)@if}ED=p{Eo4*4*i+uxV%jqAU=z54kVU&I|3&z%*e z<1wnPd$4+)q9_crhwwz=9fQg;WZ^bfo_#o)$o{>jPs z)!oyx=^H-^_Le~nV{z2VxIZT=@oa&dWT!JM3biXwBqqANcQ;9w|AsX7X!4N~YWGzd zHBNRt-WY`GAn+tVG?Kj=^<~1=zD(_s2U^icIgzhM>B?PglHdi-NywKq;IjMrKX@LW z{ckys&pzrrcDG7^zuJ?M1gt`xKw5N}9(X!+tg3v0Dah?&PvtWL2Jq$yhq_P8o_%-L z=vp_Fd!b!L26ub_bBZ;6RWy{*IZVj(c<0~mkzT$odpQ`Jh%mKO0_cz?2wKImU22um zeuY_ES`W>g8{@3tvIH_!<#UF7EdC}j3kt|qlKS*8jCFJ-=j@yKN zJd>80GYw_Nm4S~x-EN!Yc0^^uZg~8G0vB>)Ic|tmk`dHNCPl^b^@YR3)Q{N~&=7-91Zsez|~hT`FAL8bVX)jtQk2 z>qeYOD60!-x+lwqR9PQ3AHz1`2i^q(%#q70J6OiYKwWCC2h}n#-d*Z0;rT(6Xc6Pa zXI87lFTWLGyIcC}mcHIwx@&sQ=2NX)ch&Xoq`w%4;x3ud21=`uXlkbEeCPzBD$H{75GM1e#&9t|7sx=WYOJn5#cT@*eD6J{yyQk$Z* zcBWaEyk^2GQoWlm)|9n=mo2=Q9Fx|KRiuLoDHQ#|LUw>+4p)6N(WaGm^C^@oo(et8 zS<976^`~g+x>*x3x@>ER(0qs-q6<59QFGBP0=MppRq=_KUQ?{KXd0_wE7PmrWM?9z zSe-4X`Gucv*OBZSyZ)s5#*8(O5{>&enD>MKAa{KDzlA$K{3z~N5$Mr}NUJ7K_S)Z5 z{RSbEt@nH4wxkpHa`sWW7Y=$&t0&`Ei{CHXYi_#IrFT~}v3I&DjKWD*G>L$pEFIS_ zY`Y;fznG+AB1`Gn-q)sSEhcCEQEOkTQaB}DRCDyGN*>u)H*b7-ON}?a?AO{?6IOfT zEHM!wefQ(;x40|rgXHiBbva!0h|z1LajA_xL#kN@6FeB=foOcC$PORem^~6Bvl#h7 zd@XCJRKe^3tH~_p)vewY6-7d(evyu)m;l7?zbf5+=YwIpl0f5XJtD#r$#{6AhwJz4 zP>N_OibAT>3@D|DimW)5rZn+A6Yib_agbbx2tpiBSR`JkQ;8yU7EU!}q?Dn60fASH z;wDkQf_drI_RrIwM?W9_eE#!f=N4qI4i^*MWh2)vj7R%@-5}A4orr|PuiUB(x4iE& zn};o07mMB=Dsv6$+Wn@n8P|B|i@=6zqsgkhqC4be1hY697h_5Mq$l1jmhjf3e%O6s zl%oRH@^w&oKLAAHR7Fs;t}0>Djzc zFQ}%43tk<$v=A3<`I$;lD%m9G?vXtTad^JIw2!pb^43mMKa6#z`GGht95i`pjuc6- zC0xo@k}@Mg&<;-)ujYbDB&jo^NhutmZ$yrCx+I9$wt)de)DVYw8|y?8QL0cD3;^Hh zn??<64_q$0;qSB_u3gt56f!x$A$i<5El`DQdHVTR%UL%{BNBpV(jeWQxwv_e> zHtg5ZeW6~eoz4MCglMkWHIB(UOEbG_Q&&=E%SLUjc5RhkMsh`;Jg(x(6nMsi6anSz zNia)08YvH^_|ve4az16rqPEx0&Ar;PI8T$y`5OiOI~C=UC8S?q$~89Jb9nHKxP<|s z%FSVN;F9DnjL)DaLw4^iIs?`6=_M6@wk>EJv(QJR(^kfGEvsadOJlt_xgL2 z@yW$>u+~y~!txjTpR?C%ElW_SetJHGD%GvE&Nw9)TY9T&E$oZa!7?N74|+gntwm5# zl_6S6L6Y&c0A!NwHUvp`it(9@HeK$l8(~)c4TFXKd9QyuR5qvm_|dLL;K!7SKRfS_ zCRo=`DV8B4yC~au*s{ve+^m9YflCIg;tlAOf7rk%Vz$Y&Qcqh9i9JQKzuA6(n}6UL z8z$SI=voD138drVtoFpQ zwfM;MF{}+pmVC9XlLo3t0%tlN`;>Dd)+|`2dFmRC{P^*)M?2pyWN$0i?&k}r=xVWI z3j3PE!U3Sl6=e&?#qqqNZQO*A+nMzEYm(zMF2q4w3^wR@s;l#y?EkWOv1JBeG6-``; z(5KHnHy=io0Q1=w3+Sidtg(x0FC~rO8UNL&sw0QH_qvTb*ofxqKVI3YitWUzN{6Ot z*XF0H!vsMjoizpV`_sbF;vZuGp>fTQQkZY+qjA)!X_Qq)jQ<{@$8B$@JUf8Uwg@hQl-J1QVRLwAyJWIr#<(wmUvF7$(Ywm~7LM>GON( zUvBD*d~$x;B9u|y-)KvicE;c_qwffH;t+`?CQ-Zau&I+p&(~$0BrpfUEm}p$&ar~a zT~-4*{jHo2dcjFdxIlDEDJrPE$Dt}kHasNsdvK`SdZmNpCVp`oF+{v(Vns|tZrt^& z$~~FHB+Xh9*EV$<`M%>M%6C%yP=P~iImf(qF#_yRPmQlf4w=XXUPToSs+jd@4YPy- zeAWzHlqh0RA{6RluQFI|(>!*(Zg!Tqk&rFP1%jo_9(0iN2+iYLm(oqniG!y2-l}{Z zAC9AjrK;(PKKf)NEanRzS+u6>`-#3MdZ{IAv;W#)vUQ88NCambc5cPgBnw(1y!gAm z{>V6qzb&>t{5@MA{$W%}lG^(%$u;Z4->%);`j>7s7!eU(9 zsoGJF%8FPj#F;-pkCh$H@I3O$qk?85kmndSfbOF^pzKG29NL0PrPz%`{nR_deJ}G z^>wt)PA)JLa}|vhp|QJYhUb$h?Uy>O)4}w3OeRFxUA`YE-}=+`BDL+yxbLUe{=_f9 zx!f_W8v;lmK@|^QO>OZ#B!YUBpp0d1ILy*5Wcl_Im3g+1PLh1%&&fSm$?;64qb!FeB}OZUMU}o-EsXtu=|Z9atz8B*##i z{q@#~g~YdDCZC6hK_`aZkNd#BuLyM4-sbi!Srg<$x3L?aIi|u8XQ&hO+ElIP4pB1O zS+;Z1P9UF!)nZXMet)wZ{OcQOy`*QWA`Gcf>1)lhuw+z@nexmsgS0b(9!b|Cz&24o z=MzQV<`i@|oxznua$s2rZ)y=C&G1v*D?@h~kj7ae)9Dg-XRMQ%S_A?jy&agm<|KU7 zIcq>oBnt@881c%3CyWA}4c8nNn*=NCwTmSA8id_bT?f^jnU2sq7;-|-q;_zOnP?Rh z)^6sWsi+JD4aDno_%30&Qle}627cSoc=_BfbW(b7V;)?jpeewf?aHUxRAMfF+v6Ld zPzIgzU2~QzSUK4(H5V2@%Y~*OM5qu|QSc_Bfx*AkhhUS%{LeACiX2%Igwg=3%+C7vDO(9<0kw>9+FVs~<3C}Y*Wy!q0kdkVjpBc| zM%ZBvha+z3^US5)6Yks|kapAV{)4-?YVSR`9-FEa4sUn=*=V3J;@uQmJUFas=C9jLra!Dp_176^!elk6zqY5lG69lX`KsOR*I%>E5*ExB zNGdF+n8SE7*T#AEuJH;qsQP>0VR@Uo``9E;N0WBJUM6a;JZL2z!a?SXM zYgr8y4lq*&Gnx~Ke4$wVVNPbdwp#0R>?NCJ z_K31-)iMsvkUoof=9yIG*F0=OUlt3izW>%lWAnx)c(wNZ8sVr~ zTTh?d+kN)vZ+E{#nOGkUUbVg+Q4g3(7rt8n12$OCcP!xo}vadfz^M z^c^N#U6?pLe1kR20?;9}97rdE%le${LD}S1WRnPP{W4JaC1l zT)$pNKy>WQ=#CK6U&h=;_x%4n?_Bm$^Y+1jXpIx$JlSqlQ-6HfBZ9E73O~?kQ7lG6 zqbm_0(9GxRsMCIY=h@ab3V9G6&DiqIP3jF7WiGBEbFhb!63A198!FGL0z@rQMYwYGy@YlJ9|j3W(tyq6n14?Rsm^E(mnW z8&4e|HLtLHVz6X6wf0v+*6Pn|MoGWGB@lp=n}1)D!YT zJ0!DZ*r7@#)@cY~hFFUWlr_h2!0&O?@l5E{QGDf@k`65hOu(-IRxSHsi6j+`Hujh5 z8dulSik9D%aIn-4Zve>Ox?!@?jaEv)&-d2!Td9kJ{0ml9K;kuES0=YU864exeO6wD zd&QV%eK}P}?-;c;K5uzst}ifKr!wKLnNMmhELJTIPXll7;@RmD<048fum(v_xH zH;A?NEOcQFkx2RS)VD!=Y>C-lv5)|yE+t*C81EDgFS`yY6ozbhJ+OlSm|+$u>wM3Q zJ{t2WoLE!>efsc(K>G;!lnU5BYVUM9&J6jjm`i=({jn?D>3DIzKai8f!{M3@)I-K8 zD0T7=xolLS*gr#J%IqU1mMNf8$7oz`7BpanDpcPDehB-)kG=bMjRG)l0<|9(6^Q+m$&(@ARaY>ZKh6s79-_+&afTR$3Cy60eb5fb2pjzObonzn6Urm$WV~KDBFY*^tT+%c;&U6ZwRg1q$qXCBbA?ni zTB^2_lv{)Ug7^T|+YVQpvxyQIIbYS>5X818GS=ND30G;0UOvwcW9f}Tv^E1i<920qaxt=(( z6oWG4ZRys(euocpcje|Mq$24XSsM%KvaPplF`!D4kK0Ay*v~Ickg8tl|CebtY1v;V z!_kY1nRv7W!=4F|DHOHiPQ~ZD-vb2^=2}oG5a_&m2Nu6%^HJMV?>$wG>_B!3ffk^7FFYBOy z4W$P@4x7GU*41RwF@2}J7xAQ8M#*E(7xGwyr{O7fnPm%j8@wvy(dVDcELe?;d8vpK z(uEH_{MU6635Vw8bD8V`aeWO$6pjOM2|1PO+(@OBiZ(0>FLd?I@2=%5C0iMeP}6h5&khYiWb&f+ltl!&SFFGim|0P!4IB;uUB=OuL9jTD?<*ng{)kRWSo#K30J(4roh5X>x{DtfET*hzO zvMexXf`g~P$1UMw;un<_!EMlXzYEtxoF!BR+4ppFDh3-Vf!plwp{HHe0#7|L8-Ov=KY^yu9@g*CY zljD`vdY=e5=uszcY z3G|&y_t!tqEV!~CILJsIsvs~A&I%+frv!=Y#S1{v^-zG`%-sTT#jZpR2~~oYe7kp2 zj5OzI21Ts0Sl(w?`Q>)<^$carLdJXh z)+?g2<+PjN)gF*64~}7$sh$Yuf-XDepPi4VW2t4z<6}4vY~flkCM=@QKbUn2N^{~u zMzxC8!c}kylFgj~O;Pb_nYV1|(IEj0HP_*~Zmawfs_yIK$IY7@#C4m5>`ECZ68`L{ z2r#LmSdor-7KZj~!qBY!`+IkPfA1beeSf~VckkZU{$I9k=t+c<5H>JMN5OHml!(K8_`}bR) zws+QJ-d=^1Ep52Zvch+6rfxt6_yAOLJczY~`vRl1yc6KYHr~;U4?r&i@3Adf^f5H{ z_xrr?OqpA@^?WcARv&=Y&&ecg?G0pmS3j-iekPxHu}@B0&nbQ4_5h{UJV#jh7^I3l z_iZlr{e=;}0_`9yb9A294J|NzfFv7`MYAZX^nE?>j11g+a23EyXuONj=5`#6E=~vM z@cs8!teX$m@Fox&-_f$L82(ai|iZ+%?g)gY$y~dKfJil7c{4U_I@%;CuDk1%!Z*eZ}Ov&d#Xng99)Z2g7 zkMD6tpKsx7MZ(6QdvAaJ>6_ENG3FB=?jC`Da*W-#AS1jkQfu>RJ#oK4?fA-lX*6F);j-J!=%o1a z(p}Me2n#O;)5UB3?=R{7i@9q4K79$qqh-a5q8A|RNGSgwS_^?)_@~~tIniq2mZT+} zp7z^4ckEPa^3Vd`t#Yk~k6>sWkA+dOwMFC2_BP<`wb%3I5ghgn5jqI1uHL!|gYC`L z7;LZGZ48e+@DA@`!s51kNSvEW5L?~c7EW`TgwvpQM%4m0d)v_9xi|F{K24zM?C6d= zkC0e&VE-#f%&h%bEf9ULda?8ap z5o)#-`jmtjn$Us<7OzimEWw6WJeTmfp!%2bAW<*`t$4x}z>J5?wX=-`bQ>9NMBS?r z8`F7PG`Xdp34D6z%-%gMW78TuV`iSav1#A%G=T%67FT_j@Rl zozVP`OnV_2@CDcA9rAL>5<>SXCW@n%Lt?KwKe%ev7k*40$Mx&GW6=i=>-#%G4BZ1=p?4vMKd=GdJjjG**Vi_LIp8^d)zWKxueE@@Nek z3vw6ydl9g1CB&Q#CRT(_0@K{7$+(HCQ5~5ACh7EO3xHxZ)9syVrtev@Mvk?l)0YR! zk%B?=nY+Ex?R-^gMU+b#}IW9y7>$7;tBV$$UhrWzh|?8!~mK^vLkipHzB7?f0jf2S!_a zizQDPw{mwN(_DA1p>DcLS8ILWmmed$JzzMoe+3t%B`EoD31>*s(>RJ_PaxRj>(C-1 z@rZyqYc56t&0gR5?&5UMZaC{K$Dcgz*olw?iekbAW-8TPE#MoUlF-jZfF#eie}}}I zVZ??k-!>C|W!f-fM~4$u^>$V=C9<5uc(ABX3$wC5eOx!7Xuk0Ds>km%KXG~9%wjsG zUsM#TgjH-fkW3&{<96CFE>v378q+G^SDv34yP|;Pq)6GYrK2}I7{5w-1N_7TGz@R& zUilr*wx@INEUrVbn{0!ZYlOp004PibBO9i$fLmi{f4b<5zQHMpM3nIq}yUj{Apa5d0+TyRWrH*c7g!co4 z8>lPv*1yH6@3lv|8@qgziGL6ClG`f-2-MH<-)T$rKo~IT#ZmGYl_g20mn(<;&GU)!6%yS@{WmG(QZ9QvAU7H(n^ z{EYSC-|H9=4XL>!H}G(DF|fVB@WuRt2w|ZJ8O~w@WF~Flz(=IUQ9|GbDZeK~V|Nkg ziAEMedYyalonMNefc5f!>G`Gr)!QbkLb8QQSR|E+@R~W*VyEKxb&z&+)fu$Y?;qxD zFY$v33939pO->#R7z#F&9Sm9lYLxQxA;nZ_qxi=-&MZ0;N~H;KCNxuX@!5XU&yEgC z;s^oSfWmzDEZq${h!V^__aQEkekuznp)Rg+E7ZqKSJWZV zGfGsjfP`-5V4Nt5-u3u8a4UR8@UhRB2YU<=Ehk@4cViFjt^luWM^Gq50?#rS%%HSyM z+E4{v$yug_%p@y75`2#7`;taL>+Ppb;3fi>AM9RzGl}qm2o!3)yW=ZEnpQtgQmX!L z>d-Mz!ZmVwSaTO`F^pSK$zC4)CKT=_zntP+cDQ!$1PRerM4*|Aof{Ym-5JEU6@$8$ z@knM$L+OM;nN=As2*#)sY?;wj1ea2ZGJc=QE5F#OuK3-B{Ao}yb_=CD%y-^U_l*S6 zhpBez@7P?T^W7-EIUaJ4Yu5s+dT>-{(;}#vnZed%n|Z^g%!iw^!#RUzs2}Q~hYDYT zarwkT_y_mqT!fK%+&d693OR)N(pt;T2-gCHhK;0wHYS15NE(=in zLn5a~kSQQ=om0-1Fiof~Y>Ow)H$Rz;b3`gQYhtN&Tw~5F^NX%Xu2X;TSdN_UpFBXf zvzzV}G$=Mc|E;uO3>++6h;6}z&+*$YY)+zxaR^`0g+{EVeJa|< ze%Q1{E7;lk(Pyu8t^7tU$r@TD)1R{o-o)Q&QVtB3qp@@he>+_NZNDpH(Ty;A~200plJ787vPnJ^x z-}07fe)6jhF-&mdvZlsd8m)_|0g@>4Qf1wFy5jS%xq-W-w7Av{3o1W&LmJR3gB`Snlh*K*kO90^CvRG?ejNG)UH4iyEN1uyY2$GN@KFM(A>v)M5R5Edb6d`IxNlpt@O><; zY;bx~e6JiR!JFXh(d> z+q9Wp)w=4#`>blgy|Yhic2%EMAKquW;QaklO)anLIlZ@@qVsT@6ZQQ8{a1~vA*ep! zo_FQ+q)yMK#~;!i&5DjFMZ$yV1KdV+dw(b1uIfe9%4e}pK->p(V9n~^OD;2ys}Am! zsHBkyVYgf+bAQ8Msc z>1$=iuBd!JWL4e67Mzkq#U_jEw}Ioq&6`~>aRPR&5Q;I9#JYig<&{HsNXjuvE}_2AU+b7#VrPZ8*+6gwz8APteH2Fn9o6EXgx@g( zbXcO;WC<&xvz{-_W+s0VvijTtwal;tSivJI5+DO``||A_Y_LV!JysP z;{x+%!vmxuAE4r9JLFxVbM@ckcNLxDo#=FaW50}0qnYWr@IG^dqE&uYwzb%=G=4$z ztKt=lUh1fNV@RIQy_jD@+4N<_2zG!rpV|XdXaBNzjbsoHYpgOpOV9F`>47gur&gF< zr@V&QN&LQ&m$+CI0v)DR1=>mJs3=q+hKic-5}7zqw-hSt@-7M+N1m2-EeG1T=_pk) zfDlo6N#O_K+k%(kK**4O%IVi+MzUE~tQgqR^Y2HBu~U>7)rSV>rxaPkt0q>|vLj0o zA7oa&nn*ltd6;?Y&}um$q}sqra=%1YRlX8e9t>8Lk*xLR2R7PWGO(D-JTv+*nAfG~ z0J@D|XI!O<0O&t%?a|eLbMY0s%!;cg71~l2X=F|=|IL1RGWb)&CZjB82c+4QQ>%?&QGbgs$j^~J1-}q8PwMs?K;FEY) zZwwY1o7bszZ;reQg;s5AEl(uF9lRnlzB#JXYA zIPQ-QP6nQy#x91|?JRKa^XH?1s+WUj9%nt0bh+Z&cXgMSm*pS@PGG_E$|Gwwm3e&w zG9WG!lbS=X`fE#%LmjZbG%+)S6m>>4zmnZ9_@tXX^kKJ{0_A1Nco3MB0 z&24{4$}jc)lSr6+!JGiZEJ)WVx%@E=+p~>TkPew58G}hZ`1DV!t8Mug2gK2&cAd)> zOtfg|m)O5N;ov`)RorE$va?n#3C*fLOh|$m9rVxNv^Qi#Qt$qq(#I^Rj6ry4a@5s* z?e!0i22~fVqZ>FWFX}uUNy6#3+r6pY?MHVj5nZCnV|Rbmc$<9 zC2o3VUUS_o<cCuM_$y-b2 ztlrVCU;S%xY47C;Ns6ySsqMYo<>qtcp8894GLqbd(>+Qrky}p4H-=<#$5W2p8`8j) zqqnzv*{y2w_LAEHBIQ8Cs#?o7n zJyJQEZrcp7qG>Bu&l5+KRauC$8a{f{;+uo-OAcQ4(VF!#TR$#F0^MXw65=z3m6Q|2xL%Mavtr`9k>9b;j%J1zS^<#k2CLOJ_t=WnH`2^WL zKg0O_`c3&@9EF>UHgH7?zmUvnG(Fm&*rf_6ec=1d^0Gea0z2O>9+xd0ekQ!JZOvlG+A|v$b%H{0Cr@#=P{l(c_I(JgLxCw73@U6$ z-POJUYPD~&XD@puHw(i7d*a?aqC%3__+CV$Vrt6}khpndY3_Vs=lz%cbsEl&hX*7$ z5fn!O`aoW}l9?=Fb|%0rk1hIi+2j<;nOXYoul}`4YDCGB$ufJnY&M&TuF0r6i%-5k zv;kp-N~`i&7tL_%ThBjvvotT_2a2w0^FoRNmYPvs`d>2>QryZs|FAVwsrRtP;c&C< zW0BJ$R-juP`M9c3q!v$k(fV4#TkC$x+ox1o`x^@F%s;P~;@=i?1t@~*@&rhYA9w+o z5HZnht3Lkz|JsK|Nml*rW|1g3v$NQxw0!hX&f^Cg_E%mMgTsF6^hl4yRX3)sW` zw#*%!VHaUr7=M8<=;|PX0V~PGF&R)1J){1pB5)8uD6K^|SK^S|8{h&M4{~7Ta?Fh` z+O?_al868QlLHBCaur(qGZVXDbGA(gjf7p^+Wyzi%PT*>`gx+9Sd0;DV7&R)_Ro`* zottm{pHG^vxA|#$<;rU%+@n;0dn>l2m6{Wfic}BM;TM(9+{#<7llQD^&!9F`d@5IyGPxE6u zL)DhNcB;YQ&{bJAs>t0_+<`JUm_ICDJKO8C^LX(HLBH|0+f24itq@rs-UNuLDKrN>5AhX%Anl<5LNIsASEkb`!0S!V9j6u0II;W>ze5&Z0hm1zZ`0;EJ?ray_DCcLit zrGgu!^q<}+qiw$KnGa6@D=edU4Pv0o+SCjRRfk|v%zOR}8xMZg2S0Gazbc=~{bo+X@VAVsdA|#5u_qF|7djE*MfYA*} zc@@6ZyHUkH-IsW3|5-k!dh>58ynVveST9#pB2;=3ieblF^8%ev!RT~s)Myk)mP=Z|f(7k@`C4dK!9&7u(Da;^=UYUeHfv^t`r#dxt8 z6Fy?wELK3pcA5$L7wIIScef>5Mwm%%)D#Ixl8?6l{G7!{pxeoFR~6K;8vq8>Y$X)p2kvq zu8?VZbWuwb8ZZFJ3JfNF0VV7#%+qgWJRTlSv5ur;!O+?geI2YmC0U66ByU!GHPOBYefZFx5_aaeVAgjf?*$ecGg}IbgOZ(Rj|t1{AK?lYMs)!k zv-jOpm>MFqX?B!xe(nqVZ`!V1MYWR-b8&Z-)Gb>dkqO_}5+%NsO9oS1 zpa16B!w1r>i~Z>fX%ZGV=UU8LBpbBNkSqCr`PC5L2lTt+8(eMp*ZKH3#b{cQ-DE>QwmyT63OVf30jG zJSrqi%7Ntr_um*zPO#NaKmG9k646k>;z8aB4vLZPu=5y^G9nfa2;~aaP0QK~4#1{X z*%M&9Y^{^IYLFB0ht7<#emayAUlX$nG`kR})Kg{&SxSncwbch<990xysxIYH(w6;ABWu^54(e7jI20;x})Rnw};*SarfdlN&}07{5`4nvM~D z+pBlTZeAUIP5By{gU`oO`{(U}y3XMkdnCRCo3=8*O_o|7H+GvNuCjBMg`E!G$Jq|dDB~(FQ z?L`4)sz?@R$LpbT2Pv3Vpz!?;H!r?cf1_y@&s{R!L@C#5m1 zC?6&P&&tdgr$iiU-F_YM}VnAEYoJ@0e_@D}K0aT$;Nq1#I6uV5V-V|Fq~rDLH4-ii?ZOa#91LF6yAqd%0t%xANN`yK zm8PJB;S#YHJHdc#|0^?$xRibotvL6bL#x}o2AapKX-orxqArXR2E;{gCPo0 zk?RDlG2*SyS?Vz}zCS`P zGtcGiE;n{_)A7MQMc0IxQkQ6Uha)ccu)Q$WIp~o|WoMdukZnt=PX5#tgTv*>wM@D> z=u}5FnYy15FvhoNm*#6A>F)Iu*VGta>z4=vEiSd1pmVO@xV>`wM(g$I$!M~E<9Iqf z>)pEb>eZ{2SD&qn&yQ|>y1Kf0i}ul^_gYtvENXcBi!Z*oWp8h^SUN6dULN73SDFSikY`_Gc@fu72M7%gP@=|MX<}^Oe8+>5nb@@AB%(=c}Ll ze|d8IkALQE{l9-{)!XE6f4iZWFim)aJi~FKGeYeVOHVk}O(ane51QzIW}+BBohtf| zAdCK^po{)MhuHrrD5F0x(&&E{w9y|_#4$der;d7Y7G>`BXAsR^Th|;>swRF17De?C z1#rFmU$xR@zJ@JO`{0}~z_+NZJN`KSI zc|4{ke@|x@=lXMpI|g*z8x#7ruc_P{?5TThQR%ktoaw(0ZNP7By5C;tKB+r6)0o_V zOX%B=_NV;!yD?YHw7(wg*QZOD7Yg&VZqMUS`sSVr>zym6i~a=z22gWG;8Gg?U=Si$ zRG-R*8v9=immV^v|H-OY+~mGX9lh<(Zg)TZOPBwCwp#qR_H&2a>hlQ%|IMRA<0V)! zxvpr`KUv6ChQr@0?$+eAjnAZ~efeoB%HAf;G}XWysGW<{t=l0IQ%9$5a&+s{^kj4p zPd-ae&Uq3PJ3Z39%$TKQ7|vC7dTnLHP!bosy0ga}dM}5j?e*F}62M1N6ju{I^UE%t z+7C4+F5USD*|6gyXnwTVk~*&j{uHU^^zlP5?ao6W{`evL`3BB*2g&4tZWHUZMRbB+ z6i@Apw2f00PsM5z?S3 zcDhS#_)PmILP4(X^@nB3dv$-Ub(8xrEs?rHw|lEa)Y4D62AFI5Yax~7iMUN4En0C| zGApM0eJ5)c%`L`0zOa-HSx=EE(vzZ#Y^l&aVJB};%IS%znqf-=Z=IA&FXqe!LVia3 zx=hW&L{wpJd#LJ(sefXF99)cV7I&s0`+m~;WFM;ecBEC*9=gFt+D4_3Ps>g7mwicH-xG^sQ2~wTre~RL z^}QNK>x-4u&@$xim8hMw0L;VdPXu|@dt#w>oitYvP<5BlO)>R7GN-+k=@)f_E3~iK zf2`V))s{+VfLL4JnnyeiyIy;w$eqR`LK=8fZT*jCK(@;m*rm`Gguc2-V=_2+jC6?h zl{SSd>orR-u+au1XdFWVrdIP^@w9R`Q>5eR3{}T9UYTP2xBcpja!p5fw_p9Pym0X? zi*jbWJs^-T3jhS~CAQw)v18mlJ6@lC-TT{q_g=|;06kFPI2(|S%t}k}KFOGN(ID%S zYzO4zuZ*|%jt(yT%cK(hcw`2_ov3}5)|^#b71)A+!swS zuo%R(xn_>!i_zuyiq;>C)59zJaiL_=*deC*%M~9ip5}>8@aYwwIO5geWunUHV?UT& z@xhg-c`^4vb5H8iV&Sj;FLQ6-*4AQ>o`thk|w48 zQ5j@Q4M-G$fdIePet*7e&EwVo?|TZ? zS83^Q%{M=J!Sx?*dHeTRjl)Rw2u95Uve(r${?#l%_(hm)mQI=lT%uVwbWVYd75hp3 zh9GwpaQ{>n=kw3A{&lr=Dh4!t_Uh#;4suN15g^k0vau{iGQx^twu&mJY{ahE?)^Ml zzi=rfQKQZhMu2 zE|5o2t;GE(93fsg7Qp0K&5Agu5A#rD8i5;1gRIDMs_M;!C12&_N=1wt)zVVm_9_zG zsOBpznp>b~qYicoZT*TOrz(Lo_}KCSqZ6l?>CbS(ntuJ)LV%SvRm#Z@0t!>6Bx-g$_cjSeje>WYBgs&?B7vkp zHU_R%KSxI-I4$F~94`a2$)~+f+9d8_khGHCDXS_CalJPeTg2R+2!u)*n>srjb6X%+ zq9&OAJli`QlZxePyvHfoO-s-GkAHM_yj*P_nDD)IC>0f5sIzmZB&Q@LiZ}LQoO#ty z+5cCd86l7cZ^??3nwo?Oil)0q%pQXxc#kVCx(+`QFdZ5EG;S_>2jJ8s*0``8Xa{ux zbdn?S-e*t5_V8#%&cCEyS;h$uxyPSHRXL(Ahlgn`8cl_yL}dd2E;QhSJkbRk7J+_q z^To8gewe&cEKNV4F;fa@$~6PZY2|Dx<7L^QvQgdJ$YUmU0<17OCQh?_1`;4Z9^(DK zbE}J~4B*7g&Hb-l>=p%OM||m@nU7iv{B9gf7QOe%uhDxv?r-#7>7UCnAzs^$#}7`@ zDV@i5N=Ki4O&*Ey-sAD>-b-%%vuS}=nAPa8A2b~ zOgc$&rI;$`BwgBxvyAAN>jGKr@W%tdVOt|!5+R*JR_e$s4#C*$3 z!sI2+VZP^79tko#tA}jNFsztC3XJuTTiTzSGNV*7%{bYm?+XF!I*~*gNY*k4HA@V= zW_gu6z18nI-_csC2*1p`f4*W6pqx9cm6gpBay+`j@-ZGiH2){AN0e6ygNlfH;AMl$ zPs^T4pJ zjqd$FZiLmq7Q;*8yy83jv9ynH@Look&wtu7k8H6NUn)mJ>I^();#S8C&IC1gV`kN$e;Ol;1`2hiqY-`5ORlxB< z%TmplArxgfc%?Vo%BpnuiWghZb~oHi%b}!}d4S>i&fk^uASY1$BF%%r`uZ_Rp#_=Q zeP9C(gpnpZg2zj#$ZvVshw0_<=6(|_V#dMikv#trW$1?hhZ(`Yq^|JT28_(N!KN6F zYwIrfcN5|e;(k=Zr|Mma{BDyDydQ?Z!0fDM@)A@>HtndUsC|09M}7n02ZyD?y+gYg zX#<6MMCJyrPi3UI-p`h}mV5XXPygW(`OXxLC;v@|I`P0Fje`Y@BS)0!UJICE_#CgE zscfFUO1el}Z|{hl3#1<859~lvpF;f+4at547R4EzCiZ4B-yZiwaMZ}MH>IY+>FTG2 zOD1(hVFlKkn>23iIe5T|!_|PQot2;#hX7V{TZx~B1)0)&gKBJmFiA_K-3`4#s>4Od zB#wA_F;O@@;JsSs2>p@pkd4EVbIpKmzF^7t6LE>d9cZ?b;KnhRQ0i1NdO@V}4DdWj zKja^YIvYPAS=Wq?X^P&%ogIFKNT8Tt({1nD{r>OE#6wR~h_lv*IVpD^`oq@K$qN6& zG>(ARyPQN~iBpHS-JW!at=*$r7A#9^{a2)#4Bu1kzq~4s`UE!4uYI|~kBC5#*rf(6 zx*>+aghB;*;Sun_hfucvhM=qddr^?}{m6SmlF}h!nU%+Yh$!0hznZ9=3%w@qhNklM zzq6WNB4=p-XRmUk-_QJ|0_FPO+i!)z5%=|yo!h^dS|^pe40n(9U@kONy#5dN8EJk&}SJlfYL* zRdlIb*5E1E5Innh<*A^{zDDEU{(+w;IsC%+4$IuQxl!rHhRVA;pZ!@UID{Q25@~FE zZ=qO4c3YN35rz}3xQMizkMNRl1lzY6KM62cHlX3B)5Ui=qHFltahh}*vkgp)d zvX!PQI&H~@#;v7Y2<-0vpOuxJdj})(?+$O+ET_})VRyaz>1^FdZAk9Y@QwDIV(=N*;P6chx zs~s+2UejgpmH#f=AT$SHO48#>lzlopFoVGd;EygMUg|QmG z87bPPs!XLJdWSLU?imU(n})C-$~HCVVi_fCgOb4se(Fh?Keu2|qT-(DfKgUhOj*~W zHzAV5zkn3Y_u&XpKB&`=`74BsbIpb7DTBuwLI8ipb4y)qjI(jSy zAr5~VAD)e{etlYjL8Q8BC-lcTB)qO8ZgW4mR6xhVfu)M-PB0G+@iZP8bc*tFH1bV; zsC^Gzh%9`ili}t-SOl=c8t>Y}G1tII=%tp%BB575gU|~lR!c7k zEgpv=HlK!7(X{^F2)TjMR#L>`-@cXp8EHOvBSD&yj^!R$Uj79I9;1ZF)4oyreWSj9 z@NWB1gcLev9hYxtVIK-a$K>&UGbNUJc<8a<4_rGo(gYz_pEc z!&gu4!V592ICUSG#L-+L>{V~cfWZ)-7}0h53~+ec?^k6e_~uVW@sk7&zB0n*U!`Kc zV;ZTe^MlUZ)|!^FO-Cf2~8a$^3foS1LRJTa_!Ozf+ciB)A1;~#iQIcVn6C=By! zl$P_ON9N}h%K6=11$H1bD2Ouxwj@NM0o}dDgv~m&(hvBj#yM{ore)`sVd+={=bpw^ z*#`ccN0a5#a!}WcI!+YO_HOIgAlkt!XuOIZnI>F-K%d^d6$$SJ-*GGbvF*}8=55wd zzV8Ch`U|JS>58$c8XO{Ei_f$f;jk>b2S=`h~fnV z@Uw4BI~VzJ9h;f!>b(w`;Ksjj&P3Zj=kXo=fCwyO8uUNr+e9$^M`EmvL%w`eR= zTZ?gXf9-AUF{@(e!u4vB+*%b=qlYggi5i?LHkRfbcW3ps7u!fXGl78)tEwhZR3#Zi z)@_3|`y5r6Fd}s#Xk!lH4>XL90?y_$*Kx2BTRj2cC$oZnUj`27YYG1?3<#1uQwWFn zvl)2XjG&&zF!T?Ald4!3N$=qRXD!jHZYj|gI$iwQi*TqkzxEgUnr6GONi!(AiCYHj zs7e+_Z@Yf!xU`ZO@5MYPU7oqhLkTf+*DFr^PX(x9Afm zob)?gi#&CaS(L1djNhM#u_l2NXSk;ISmwqFHTGw9~uCpI`gf z^K;eV3mhlbWyKhsW>rP4KvE5FzN*U+rL;QT-d_cpYQWV9cvF3Bb?*RC)shSo&DQE~ zOX^?xvKrr6w!;8*v2_xYM>w3YG29_r>nC;NF_LV|aWC>o^=L!XwqvCN2~jmuE|3sEf1E zx>iUQgb#IXuAv=Bl9EFx%e%UPKrz~=@xX`!%@g~aFJ;G((&X%mAu7|b zbzEB2VANL{iC5z{4C9?K^#({1B%BvD;8ju^fwM{Le6NKj1E`#aK$7!u%uENRF%s@U zCZ5)rEdWD3;cv#8UI0rM;$LA$`HJ5$yUgAq}XGC*BX{yzbUKSn|FOd5O5^s95KuSI*P1pfMR>?uhcxIFo zsDdEn#c97QCOEkI-NTMfYlh9u7W$ix{(v&8op6B~vv+|Tcs{bwq7DeD9hgPUpIy*q z6PQ84Wr@&c6JVHiV0QUiLacdCv3X`wf4H%;QRq||I7pJF<53}xI=@CHcX4ie9}Vev z%%4@n@O04JxoaiL5Rk=f7VxIMeO%L!73{?7eHWmFHz5*-wsAk%>-m@J8ItnNc>5E2 zey%^(vkBn1i;6GyKJoa>AH#mH^^gYffZ|avkgk+o;73AC6RrF_(8>%$15ulj<5G%< z(Ap+e_}H#FA7r>8Kd8W3=v)rYJHQP{M}nC2A%X9Byw+#}wdEePmOrb~-yo?Lgb6IE zxL_^5VJ!gXd}9p#o3Vxtd`IIgyg(Z}&s&y?3}Zlj{tZ5}{wL$@C%Q~zdHKos!HJ8=T_0o-Ve)_Tja?|t(SmX#_WnIyVZZ3tMzMt`H5TYn3ahVhDy`fr*-wk0C*#91DwI|d zyfORZo#U~VzNFIPONAswTP+p1t9kw_wN$aUxn`=j)<#vb#Uv9}b+;LC3<^PLo)kCZVC!vmU3lP7oRHVtM)mpi)jyH6O%Ua^#W02s zVhH03!(H1Xt$;jQW)pT&(^?m40Gg!D(v*`HspTR(^c-c<6jJukh|Y7;L6n>Vzof3h zLO?G|JH8=yDh8RWR%}zd1k$;=W}Z>Pf!Qe9!t9GxT%|^}t$;79ru}r6h;sD8e6wMmw3_(DhPAh}-pgEnlW z9LzAH$tFunq^j9GX`)ogU?8^^N9IDCX@13Ph`b5cC9<)n=WLu_VEheZiP6pq5*2W@ z`vR!uc~D`3%ad#hF}x{BWenC8E}7%13>c7=Q%iTx=GH_%`GL!R;KHv^0}n{@8I<~* zKC$b-WuYSTUn#tUMJtVCK`v6(6-7}9hmbT*hcmNk1~M}v#z`CqsNXt|hIp{^p_Rxu1@1*p_csMdUPL7}o(mTa3% zFqEPkt8S)tYDL@)NB$N9wcK@>JTxKT!6)yAV z!M%WRjB9}>arG2)@=3tF9wD6u%0hhnaZWIgirousspv_;eJp|{HES`%Qt&0|$_&a^ ziizl5V^cOO$W~`ihgWMgoUx~kPSVpg?E3(=I&zgHOC(!l5DQd7p7T(@=S z>k({XBn+?6IM9H&6QaP;kl^IiXcWO)*uE9#lvslDQql!6Xq~q5(-~VxdRnQl;Hc4_ zAv8YY7s*1+sc48xB(?*H8?ZHq4^=+^DT%YO;+#P2GF~x&c(%)$j9ADqu1f65@Xtht zki@mMZ6|TiFp)d$sIqB~QUXFnVIF_%EY&K9kgGA3YML~cfN5gPB48weUBJjNmH#Zp zFfiWu^NpW3*UO-$florO{YB0ut4skHx0jyCO7*8ng;c1i9lnh`z`g!;8Ipc(7awy z6Tw~nVHr}XQG&G;Fe2UXIHLtk(GaZpgERXk8b&{IW_A6$W>(j%XC~;s5Q70tKVoKe zQ*mZYyig&+&Juqru~t#ouLf>=jA)Nfjj2x<6IJ$5GWSg*~1|z;hxXlIueT91~acE-_sqLMbo8k+9Ewn7M2E7*6UwVSSt+TGXq*E&KJb1TT-v z{}GafC`vKj3^>ZG#_}7C6pfEumiJKR)>vxUq*`AHI8SwGD%e$B_tUDxIA|N)Y|Bm$ z`R}f?hBDSV#~?52=*|(mh^H&y&0*Fv>!gHC?FE~iDI+(%1qQ2{DdT@7+fb(la=pYP++7gujdc31CM5>Si16HN8|md!JE2pz1~s zqr-Q$0S$iThkgS6M3JZoh=(BZmN8c0h#37oNRu?N>ZAYR4hv&tm$SpXVc%hOC-usw zJyzopB%^RgTO}t6cePca+np7)4-&e4pytP;+edSs4H`?gcEF0_WXea^1dMeW33$?sP z4zId{u3dc|4ioTf}4 z_n!{zKR^L<#78*y&nTG@{>o+a=EexGO*anZ8*=CbmxkmWH=YDEDqx5|6P>bUlF8Ss zBOQen^Uwch$^LJF13ygUcMn6grKNvjFb^pOga7_RpLV*dsh9go79Sh?XQ8Ppbn;`R z=f8oZLZ~43F2a-?HPtXEv=Wmr)4aP?P+nO9^wnVyg_|}I)z+8-acT5uCJa>x(VH@2 z=rP2D;$<#C@m=yL(pPj#I<63jVuN7$M{-ir&YUy+7>~6v(H8BI=I)V|Y- z@!C?<3aiDl1XQ!VmvDt1Dd4LO*diCj`G2w{8u<-(uO-)*PdEm~1zz6?5|>>%_Dnat zHR>PdH}xmccrN!uG?ldSmypN6hi->&GCY66MkcW1%O0PN2S?G@kqwiYN(t+>+NM~C z;NVKq+C>O6Y;wW~LRRXhJlW`OVtz9jY5DC^$jn1Hq7bvqaZ{q?Fybk)oB_ocd+1$^ z!%v3e=#yIdJ`jtX42Lh{@w9N%qNza_z}LK+uL}j+5B|v=gNxfZhQ|ZcErb|>EStv9 zJnxI5jCc*+0{}oA{X=_ERAqvW~Da|p=e0I^fN3b6S}`JOXJ7+TaSjqpI4lz*zWR}t3W%7kmIT*d)CsQ6qOx; z9Iz5w?07$^%I1Mqc2R!SR@W4mNSd5bIYm?A6Ef9eJ*$XX(YAp(ep-}Q5TegmrOPFZ z+&Vhcm~$Bq*5O>=@x}VShLTC`{Fch5Bx}hxrO(2rUnn#L5FSn^+|0!W-0SR1HWICR zCIXCR(n?1uZ{dWov}v&IxLvkpo2uNxa4C*-$$lz%7e7i0FKZJ;kVlc|ke)*Y?uW?* z3OAskyvze`t0=TIw7dU;Bb46A{?so+}}!8 zD197r(1;!s1=98(M?f4XQea=mzCNs3;i zHx>9m0JvElq9Bh%Q))*%MXl4LQ;}F^L)(CS=ICp}OeLo!OcL$*ej*uvZYq40w#yEo z)*>R|yTJ6iQNax8AMvcUQGeOmsK1JhK8&{v-rJP?SI{muB9}WZxJQFU2Dqee?Q%^Z z5*73iT1}fV4j8qk494)rrT!3HD4HJd#>=~aLP;l=my4s?OSJ@hA1yi!0o(XsZ?d@3QGb4dB}2>*fPOP*Tk zGIMv3`C|+%_m;{|t1A@EHo8x{Myfb2BoWh(>Omd;Uy|cbmaF2(hhAnV+|C#x%nB}d!`(z~4 zH9+h!KY@=Ff;p~&g44aQ3$rjHy9NzKId26yi9A*w=2YzOc*N0QM2I^G%M3|S^2pQC zY;rv>A8FIFA+vMh4hY5Z{d&+C6;{F_L=5y%<-72(BN;o$Y!L5Mae?&aW|t{pnK1}L zcZ{A@LXs_JRZ@X6-ZPwM94{OG{T8PR1PSR7m>s_K>)#odi9+YNw7Xk}hNIiu8$9?q zTuJ;VxxnVWQT4Lge|t{MKm2jaz@FDTFCUz;ttC}8u?c%`(eLm7-ZOKFe7=ba6dqik|LW59_Ph{mcXhSGzJEO_(|7;dhtUl`p zXQxNchgSx@+1d0&F-C)TgEMp_z=!$xM|254$ivBS@||t*5U4D#vSFbRTx}hT=XlPp zfEts*(9rHN@UMD7Y>=l z!^hA^A@zjYCSW)|GESd-HQwV~i*}S<)+6*>+uY(xIw9=bc3qiHO{jOy!Vsnh+QE*Q zg+P|WoD7x?6k;w_Nkw@=Zfq_NVl0dIdf;tg01^1XF}r01;8u5t1UM#y6#_ex5O}*O zEw6TLAaQ(>-NYNLz=-(bvz3jmQ<85PJdfomm#t@(2mt2pqPd*>EXt)D6e`vhd5d)m#){n5E&|i6o6OS8B_5xw08# zytZc^{XlK8bvxx}2U<6DGSAM+p;c__nCkn{I=2jUQ4`aFD-l-2bv!M6ETYP3SJ`bi z#K%E<{*{8Za_+iW!IcT*+H~1q*^<)m z(=Eb2*A6VL&)Q_QJZPSM;;WtR(!-_ojt+Wtx1nS#YMr8T0>M+~kgoJF*BQH%+N8r(IG-OKv72?qU&-7;27)TD zLC#FjlJxOyv)$llq8w|Pi;GEYGqHq`c=7gL-A(R2r$Q)4_-lJ-Cl~Zkw7;G`o@i03 zCW>d6a>;m3@357@AWP^232v-`w9K7(z!o2NIiUNnsLj1lbL)^MOor!)G(N{W^!b|` z){}(FVB+y;<@toJQG0lrZzRI63*-v}${R(EFwdw66#VOsj3>IRB9p;ODJl!eAdNST z`zAd^*8)IdJBorrTNjF?5HZ()I6o$pBS`UYgyOfSKqu9W8;h!Dm6#8=7eMd38N1K&Zw|LSYor86z7%aW%uXEMj&a;#!`u} zjO7aiVJJ|7Okt^(@jHkZfGPqYM|2>$#k@qOres|xd=)&m*9=1j9ZXzN&r zJHPY(UrOijz$zqUihfqtZ2g_k35v4Zv>s~mfJDAmYMy+wzN z0?~@oxa)XP7{k89uW|f_1PbD5bsa3!mNx`k|6ua(m|B}Ql z;RLNG@lS1(CQp?@kv7jBb{kwT&IR}RBSsEv_8E=+Z17jodI*m|8EEMC#{kY+x#JvR z+{B@8pVM;^s%T7NTp#Wv4E19=@v2294mg>ct9|NhH;gTBB#C$lcEzTZgc}Fy*PV)M z`{&SA$=*ps_&`2%j2%quQwo}HU9qy0ze&_Ze$cQFRU3bnM{ z;7eD`IHAcCyJQvBk2Q_vO)L=)NnMt@*&`j7R)!mQ_?4DJh!o7J0fF>(D|jMDRvDB@ zx8DNljIP#ZXz;cm^^at<3d)Upydx8^H~p8WjIP8e!2y)DG%w_xbSj>ebLw#H@7u-U!p(xHf`YwsSkWeeycYIk&@Yeni)D8Symw zVnY?UldP7ry=L9l4N&FxRr+t)pTaG}$pgBD$JzwNCGWO^i5Iz;t)BZyaEn7B-SykS z`4Le}3#P%y!Zb2Ytb4nKCm>F_-wEw?c480nlT8<5KAx_B+3&2_Dw;VrMA*&pv5|1n z;)XL6oo=Vc7f0=198D+h$i;Cu|G|su1CPnOtAi(P^%pn)ys9osHCD{k&P|?`m2I|^ zVH2-R-EChrYjiutfzc$=T1v3~6@vISTtR}OsSimbUtXS*o!TnpOJ?38sh*whWl)Lj+mVJ|$JhExGxw8lsZU4~FW{IbE~kbVey( z;PQOb`|Bvts^_ECzmCFfn@@-1ccZggT_+U`4|B~GmxKmiePjw@7VA1)b%qd&aTe)$ z*X~(ExZ90#!^2HtO}KMyOQAW6xq(_^Vfcaz9uHxmkGQg6a`qjj1XOm|PRO?BIVfy= zYlM!xnVy|Zk7ZY<50+{0hFfYn@~Z=BvZ!JnhCTe~7EcQ`Il-JsZnxMT&fVcpFYU%t za^A5cPFBflw?oFcwaHRS3|1kTQn%dY4=WE@*n9hI>E`;*h1h;_1^A>Zv zX?AvXG!*jT>-8&c@6kXT0SWi*3IjB^4_=2Wkg_e#&!(MpR^;9fqYt+2pP06=V6-+_ zK}}<@whd{&J?j4j(M53LXOz-Q^Ygz(A6(bozem9-hnsDq_pj0Y&(`+G_qp!$Z=-%^ zJxM6(7K?SqeZhXqb0>Ft-+#F3;H@@H@264!+oSb^+3dUXqjYz7?<>B)7NED34ink^ z?V0AfZ4=)TNVji0+aH4rT+O|BljM=Jr`UUp#E-UUFe`r>tzsuz{eHCi)rfJrMRCE3 z7*hP34?b`Qv#*@5Fj?U;E7Zazzbs;aQD*p%C5=q!B@J0$jfTUsq##teBikzOadogz z0^3LgGtw=G28x2>$N;?L3`_IymO+qLE`$n1QvkGGBhwkp14TAO-n^tILqa}Fqws8) z!AQG;Et0L450j>9BhI!|r<>v8aB`$MCYrmD1v%RvDkBx!HR_Nk8kUF6GTP`EWidzv zW2l8XIwD>}7LH_0PuIC|?ez0^5YMDPbzZ!B_PEo9v#sHe&oz%{FE9+~R10Q%i8m{|CcK2`@dpBS5p%9sKl7e2d(w1UAb;_?$l;z{>%{*wtqT%qPIXU;*a7d=2;|9jQB+0)$Fr# zhz@tHf$Zqnu4~M_x5X|HDO8AO7|avzj$?VZ;#uEtbC zJq|$@=;j6UrfN1xv=;7bDVv1Re9?pj@I7AX)ZmUke?)s*g#ngO*RRpgWSKaInRZJM8diy&25nXOY)Q5{d`rERli9Ly~cQt11qLxweZ%O(1A5)ll(1 z)`&mmRiX-9*y_An+qQS2$mj)MGFum)(m7el6Z%SLnq2lsVn+fcF%aNOgueEZWo~Mq zHLb)dfi@_(8O`n#yCKHEvM>+~9eQY{{j?iD z&ZaxpdXSV;He@wIYXE3E_x@}e-`*-c5gyFOF@a)+dY^Ia8S2Az6XYTL2se~zTji1P z5D1+vFGFJt_imW(206{|0|jt;YdP0Eo=~?F${)sRcAkCn?f1KaE^4k4KG@ZT@#XI0 zA9p|h?(yfkgEdt+!8JJ?O^*(Tr$wn9*`~p^YCGb;0+;t-Rh8XqdD)SgH(^HQrlAxO zU>q~?sO2_f_Ir@3H^}sTThnNKNbVi}Lx$CBT_STL4cqZ~^nHcv%tPzf2n?MHKxn^oH$ zOFP(yqy+n52~|oL2`tS{h6m%fTuII1Zo5_QHg@6}YqW+=14M;|0Qsp%tmB2r*tBlv z0j3rgI)8W6*$1vwyEtXW((q+MELb0T-2Bha;iv=-9w05)@n4{xOY4aYAg3hzC zS!=&1J9V53ScT%3g4Y9E!CQlnlH6Q_k%*)7^<(6KCseGlb)O z4#3iXIyPtU7}|$um5lTLl_P!80>{3?jnS6LrFx@vM}Mlj%(q@p?(0$FVA()Kx^^@y zYktfo@cJ$g6LHPRBz*`f1(gMDMqiZy^)f}1P7Fa@7wp|#`3gW=c9@=1mTfXAU6Xgo^CQEO!6)h zF2~TZsQ5Ip7#4E=laVPxf%(pK?uT7~W)gg0bQ`i6iBiaPruvyCpTOcQ}Ln-AMLm)FP|Vo}L0W-U1uGl7WdqY@1Xno)LOfu9+M zL4B%qQj>%Vf0Z3!4KOG^2mmt8JU1^rdA@F*jtk+s1*py*Sq{pX-p5WM>qohl<`h;p z&6_)?leN#Gy>4zDvXyLMBmgR?mO=py$-Sq@APd=&B4;#(MMF}P7e+lxrowvbSB83- zFf;XF^Mx==ES4hJEv717)Hc&%nXn!K>)`FOeIFJu3&-Xfj{)NJ4ifFlBK9vL8H)MJ zikq7%W=(_9z^-|>*~BwQu|OKh7T1YBNJ{MZ10NRxLV+Ubg#(g(H7^V$(XiE2k{Z*v z3z%xWEYR%imH8O`$Ruti?fj?%WjfR9J#^a6dy#7A zm^e)3InlyaOd;Y67zs-vzg<9SGJ5bkn*&n6T#rO(=JzKZyRF&<3Aw}@Y-5$a?rOkt zY`BGUVH?D4WQ6<00fZsD8B}97SY!rXnT*ReA-l=p^nw(&$YlYR7MdanPjTMGQkpj* z6o-YSLd9-)Yn#C$#JNZ#)UN6n!Oped@UK1el3*CK%RS+I=-G;UK?0^D6IUXnsQgf*t%5jr#s?{O2RVt)Y=$z{(5kU&e#zkV?qAB@<f4^q+93E3&a!<@eF; zg+W3?=9$Qi-8+>=b80sxySBqaJnuA>inMo%ZY0$cb!=)PT(K9RiG#JbCu`H=@tMWu z$%WAsg-l>;`34 zkYF&gDe=W-q+QX0w+MVbP0qy4orz-fw$DMWx0~l@ zbkaQ@g6dxnCtDoYxxVe~9w|+{9lFXTUQ_4B#=l_ai}9KQTWlhp)XL|6ns2%W5cMf* zhj%fXYb^Br$Yj*2vFc53%qaesNELk@*&z;u4zhsM=WI_XJeVF+cc`kf{`ZsZfPnw{ ztIpFkP^)^k{p8oO0@>m6LwWf zV0c#ZEAWjl_zI^IUxY5g5y6|Tu2XmHb8KsXAmU)6Je*#4Y<_vf&l~~uMz-sh7+37@ z{o2X+;AaKIUuP5h*1ZiVWvXH}*!F5Ym+FHriSI&zJbil7r*<%`X!3v1T(&68H+~zJ zW$BZBCJ0KcaCyQK8xEP^>3HRLt^(~%2CZd*vc95I< zV&CXZY;YJG?EGeYxIcJ57#}Ia*?wq|+*sk>d0KqCGR%$UF`ZRS#G!N*ty^By%(oc) zCPPsPMP?B)rMQe^jyvJfV7N^IGmrU~yT<$KW8NP{At3CjgQ=5tao2Q& z?Nin^yuTU`j}Dio^z?Ij{5aF-%OW2 z|MuCEt`Jy)N^+?Da0&EUf@!}UzYFzLa=hN@>TKx+hiiP&w;C}r%*x79Vb=82OF_u# z`oMNv+ub^9&cnoPRt_<+^W6~I-jO)8a=?G*i>B=(TRWXj5qKIMoP*+i?vK$DL#^f7 zIT&wwwBQ7uqcIj$@gXwa z*$5-|$+-XUnk!jP$a~iUYnuXs*8*Ulkf{Vk@HE14OFlQP13O8Xa1?)8!WQZ8O4~MG zh~e}iY#9!0_h#dM9To}`USD5F5|dShX=*-)1aClRUT^Wk$-(Rv12y5eb5J-O6IA6; zH~|WWf&%hdfWja`K@AHEfTaxzcVir*_#hB6@pvo-5y@SmpPk>#2JeUa_!N=-3{hEa zHV*#xM!0T>zx`z2gJdfz-1!alBdz>E17EZ^;1uv9+xVrm4cl5eY~vHr1tyU=LW49LvKh>YRmH|HFn z)sf>=f}up0?OAL77V_tEi(a7*-O9-N9ls0Ia876+saDtc6|vG1{XHz>aBTwr!h8gk zS@MQokr!&n{_0)>ME>B%lQ`rrC22PGEFGgH0D-Ev(BrBVr!3I#vWXU=?M=jOzT_S6 z499j1`PMH3{lb94?25K*tRj=Plsm;6mUA*Z-DTUT<9~fRzG$EJ_NaC$({3&Lkr&+Fk+{%ziJ4MIW=5w zl}+Jj_=w=npKbYtEpF*|p36bH$NxSb9GSFJj3{D=H>c>+46G&~?Um-_n97Fu@xXUk zV?z|XA#SC#8C;}2bc85(NsNV;jJxL@iD}ltl;B>74tLg!bEGOl!JSpZq^Swdyt8Hr z1x6jDCleI9XCt7nB=u=FTj}hYyfmJ1+F+*e{H4Lt@9H`HE>M7VBMEAYDFspNquYwJ zoNtUt|Jud^9Ol~sEWfs`5>kx@C1@{Vwa6q79rue(HggIt6XbvXDKguC+A9*#fBLD- zrP_yf9`ieJTsE-z?eM#^Q_rgtKI(@ohKHTkXm(CA;-dRLj15jcw!!#$(PFjjV}qw3 zXi(BZzY{l4HM|C?h%K7k?~XaA&j^_+=#33Dm;}ZDZQGUBrx~e``PS@&hr>7L@8bSD zoDOG8oT`;VA}7w0L-l?D!IJODuo3euz2SVPZD=Hh2E{iSBubSN(|~Q6O>_W5vkNDv zxSc3H$Qe>0!+L|4&~yr2^VMyuQLR>6g%Wt}&5%GYSs5a%~0=<*<1NUBnhifiaQw>Nf@-=};OB5@D_r3T~CO zgQ#X>^g&xsF*ugcV>Fm$LGPcYT8BQ@KDi!_Z~xh~HokLf2l|yH#)v&`KcS><)A43; zv*2WC_H%NHe_WfDK-wIq8tmbuy>;-U@fCuxR&{>scGlx$eJg=4P_>2B687%oeLC4S zhAw3wD_7Yf)o6CjJ<1>7x(Dc#eFVd{38jAX3+9Cj{T64v_%wEcpFeZw;jK3Oe!~D;q3qZ zT%BvQjh5gB=eL3AS1Mf71Z@^ox1Rd_XWnk+UDy(QP8Q4hY~pW1)9{kp1?3Ud_sp8{ z8k@cWK5{;m5l?HzYQt&#+4DAuxb(U~g0~fcCuU?kSo2-LNfi#V4~~S^5-=P&ef!ny zFAfb$a_}{Du>QS&GdNx2&ys$xjn9V1{_BX%=CbOW*_xAo%!=y584)$MJQR7eh$QT}vnh{Bj`fyhxaV}q<8a`TqrVb(Y@bndfwdxV15bxRKl zl^owCjAV{oVIa-&hK(9ErgF*BmsUkdV3K!e1#}nVS4Jp2h zU&a(ox1TmyPGdtjg=`mu&W{#sm4ph~IqZ*O6Es$G5YgT11U&z4`0nxL3AnyKIvu|4 zb>5As76gst0?;eW_Jo`Z$siZ*Qj)Fl_I5f6>{^=By+r0E)+Xv7&4o2nlWlIEZ5aV~ zz_yX^CJ>!(8?f70w8j@if?qeju(WADHY^c0;M2lEKXO+JGUVA?JnnQTCd zmCSr+yGaI%xZ$m8%(|CMb8#k9s+qg(YG&9qYzz_gzqq2Abkj${l?vZte`Co@^iygN zgbpWF)ch|7r<0Y=yVLO@%>Ur$^?%K_mY&3HDgRX3+11WJmoUj44gnXwGn|!}1Ujok zOm&tJ7njiGmfj%kE}ahTu-DEv&}xO#TT#THmu3FZyihTZT8ZjNW)48B=e9V(70P$_3q z_4m?*gPU<_I^Nlv;_BObZQfkC7gVYuYKj=jAKZg`dC1oCvh@RB@2ulDNDse}6JbF+ z)MRur=Y~dzs9aR_H&zwZs<<=^rA7iyjOdZG-5Vn9~C8 zVtH}r)8r}t1VLtQDlEvU&H}Wx6P8ysVuT;&ldwvD+zGv%CchlN51YV6FwDb)B!wve zf-|#cLu#$;88uila$th1Ozp^mbboifHQ5W1U%Ax4qpisX)R@84;4zeNL-@hge3z_K zjVoGzqm+pdCL3EgJLl*533(a6M`8%L&xS>IuxkLp5&{*#<~c!EltikOt2K5uqzrdr zki~G|iswg%udwy(P9Hg1`M_B5=y|8W+lsL+48xF0qQMvkFRZTk9-48FTdhJRL$sC( zBCB)7A(rNzC{xwuUCqt6WcZ4a#JN+zuY=(UJT*ey0eX+e^{aM4lmx=qMJ2(`4qaehW1xZ zjTuF3L%m_M}v4W+Ma=9Ymq-t5YHZ_ff%}XA>6o@A17zO-v#mFI?`6|Xu4aQrW z!#{M_EWQhoC%riG7iq`Q0B1uHNYn^)iAFACBj>3Rg1TrVy&x(}t#{EK^9mAw*1$eV zI!wb}u#6Ac$>T%1gGL4MCqlq!!Yk$1wWPU@0HXe;ud&#R$VbS@E8j6)c+gnbWpqc_ z$iXnK)6`j`7cw7{Woo;PoN6dtX9Q&2C)qG+Ht?vcyqm$%80Xp;!%h&0tiaM5@2I|B z+mjL&TX09Gv&D;&N;T%AS{AW@8$h&8$Gxg77e?9v<>~CquCcXjhZ!oF*U!GV+8w-8 zZU;=$q_607Lt=rlcbQeh=>r9ES^8B2{0a6D!JC^n;BGBqu5@1Q{`B?Zy#g$KNq?&y ztWlhXSYBr0Zfeo|>@ipX@&-9PeDvy-<*c?{VCxRzXc+>{UhV($6OIJmE?qBuat*Ut zx?TEb9R1n!i|Nz^-RnS*LL`+5m5&3$(}}BT_mMsBl4U4MQjC#&4y3yt^#YwqcZ?Z z-%_SS(pu)?#y^e9lD5Yob_lNJn2M6~#ofe&-CS}1|E#R-ZLIzI_3A&?{qK$a`7}L- z*IvBZT{*eD86MxTgg5U;H}5ZQetUCpd~gm-hQ+b2{4-|!8`+GO z2pwJOvykSX+h$UyV#T`Pan@DelDt^qvLRAIwNHp~hLxw7CNdE-`^f{?JY2bi&cI zu{dDr_Fg~y)TRy*sJLv+#~QRH#EbF;P^{G`ZjFc*9vtw14>Cv>003JrA9L(UM&BTU zY=#xXPaM^W>B$v0miQEe(^(XgjC)BF4pZbWFx4`VT#72Lb6%r*b!C!`y}D?;vV?XQ z_G;94HPTljNr@)l>3I6zyes|EOZ~M^`SCQpdKz9m^;d(-_!YnG6@TNa_|@Rjzv3?> zw_d{pgM_6+Ms&u1HfsTL{n8$;z&cIehjMXqv)QFYs)_yfVuZU?Y`-JCX9Kng66}ld zglzAC)Qnb|BV^rG3lRjK8KC|?ZInN?a%NYR^Bl_S`b;(U?3CW*y2;?^^U2|>8g;oh zKH5$)@*>fD;TgV|gwd8o#0A2Da+#P3-6R1x;z3YgdBBJ3a&K;s9r_Z_(nM7mZVaHk z7gdP8EH6*x|4G<~WZOOk7s`6&Hi1$k%ShE3ySL)A5gbpZr?2FTb;%EJULs0qSD|qg zQ}GSPbRsL!RCx5^c9)l{Znx8uDKl)JqQd&{NNXoP0TzNC(@D9uWx482rfyp;>%=mD z|ANyb^;?zb9ai$6hp6m%I9}4z-o9Y?Tu2ap@kdx#&Sd?<>gmL;*V8Yr_q3{)q-imq zvor3$2oy3X5fCjWNHSfrR`7s{j3FXF8@;BYJRFB=PM&Uo9(r}XjuCJuL@qkn&T>wz zA(|YhprexjVY4|nJ|+Iri7(wdsCzhCtLcST;W~xZ8kBnlySJycioNklBjd!awfvQw z@X7l5>fjWAQLus9G@2|W%* z9~uZw6OU5r>+Tj;-R$I9B=LHOEWvSv;1YoyxTGU_%<3-^k^|qW_(; z`YL*r4*#U@SN(b&9r@-|$-{L18;EfXcni&QGPd-ezrNww7#&fKV6YdvXPB$i*w@aH z^&(!Ds~229b8BbbkU2Q9nZ+&uM7bbR{f%3+f7C_8=ZgiXps{L+n6$U~6TxpsCEH^b53{dju1)tMc0Flt0f`{@aAr0#8Z>m0I8w+{Zr z)hFXMJkD#ZNY=>h)F1dG}w6I<6A0iLXI?o1Z%|u3+^?qm1BHUhYeL2=SVOopu zx8}bb4o|F$K=<}KuIznlc*H-lrSS(tdMus1C4A{r7p;QWHiD2Pb2)GDL0KqA=A&azz5$0o3cceq82i2TS zQ5RS>(JN)5kQCxQbWA0-ZD-AEy*mf8iGhKxlAOrfC=vCV;+J9fr1`%7DrM3f*! ze`^yi8^Rc&$tsu}x*%Cm5f)Q4fU6cCyxv`VLM3ptWPaWM=XBB8|#_-G=4c(>?XihgV?k!P)7N|2iI? z4gA+^G=6KZ^L&L_pc6hh$bkNId)f}%zegK(TcFx97NC+qC-}wY#_y>z|Me3Gy`mkw zrBQaGZ${PvP^1kx8*`@s&X*U88Z4k#GPB5qgP+E0b@CS$hArFEm}FzA8E_0(@;~Qx zy-f*rVbuBOPx=|=mw$SxLr|p-Rr@M{P`?9Udz}1^3(@VdIZZb7h3U#EQY7{(X3nBo zo}t+ja;oo=uYU&kj)&mAWeqL~R1*NTAA%iLZWrc#Jv|v5jL(Q^-stqAu(s8CJHAw8 zmnd~-LzkvWKnp)j%-ZT~s(54~L3sG(8IGFEt02>PFT!hdefdh4h;D7(hc?)ldRIk>kF0m;cSAx+&|bJ3@+v!NDqprORIIUPO!(!CWA*vQ!Ar zWSM7FBKR5g4s*?k-B&`2?F44pk0HqMXqpfkH%%++Z)hqNMmnw$0wBMY=%n#8***qGL_CLn!b}^jCm*X?%AGO?aLUVw zYN=Y^=V;6LEs1x$x9;t}=q1T}PIYeZa~xW}VIl-V@Rz|itZ$mLV10seHav`jwn+xaMbbe7v4!M;W8a5a z>JUA%iqKvFNTI|#HLqF0u@TqA@J%tjYZt&7F=ZZYahD= z1Ze%wie{DMzG33%_5zp`&21YvkI4Mw+tPMRumyek*_<`J^NjQiVC9f=Q3m3rgVFFn z0i>d%#0&skQR84Yal8oN@kMt80M=TBhyh0Xt{w^j8mkZxFTw&Om`w@^;`&O$&y_Kw z?hRR0g%@{JhR0l?o2-xgi>_G{(=ddA>A|tm#$=PjkBH=wdWYNf=$*E%Rzrg*Fxv`` zu+5Ng;tdOjtb1e9nM}Mqg(r8kPaT$xKuU<-{r1I7)$1!kHZDtQQkyR!r<+AYf2J(;~_yb z)Ifofu{1r%i^{}nhriB<%0L!|;&Er9|BQ(_UMB(JKN!uC&bs`E<2i&D+X=HrzSADb zT!cXGM9qdKNyen6gEMen$g58Rp~{kt7YmRsdWTQC%F=fA5W;e`17#wnL5}jPH5E`9 zbkVQwmcP<(OgaAQQeR!#R~PoxrM|kB>iTT0l@{ zDi~@a-MGhZpNn4y=EOzj6f0#H_1*8d{^Ii=_kZ~O>+c_vEM=}t=Ax?yL?m62jHRzR zUaj)a2vZCbyU)KPnMJOM<6Iv-RN<=1*gvYCo^9G*T{_G9rJB6lp+hx!sU|PfutQRQ2?1F!Ab6I?3>2)+hu#YKly3pNt<4Kv+eKktPN#IznA?fkf(g zz}G%N)kYdXh;?mzc1)5{=V~gSF3g=J(a7#JjTDFZFOqw8tu4 zTOLml&6znuIn$|VQ$Nn5SDlWMgY&#(>qU|`4 zl{sE&+Wfsrdz~^-=}R^!2rAkyH3Tmi&m%d+dC#Qv!ag+8WO_SJHtJl2I{fso8?OqG zw(5^5(W`y7;h8lRe!*z*20Bp?x1)|60czT$aM}1Z78cG18PwGS!6gA0hcv=2yaZy$5(d;#&$>ZHaCW!n@z2GQ0;2~hEZ6Z7)Q9w*GXjXp3 z|C4kK+Y3TzsbqL%7R-f(@dP4|>f|aPWJ?O7i4pZ1=_E!;wLSFSHgnn>sYGfVR4LkF zU_tO?&Ty{sUyN-ywvbVSh9-WTyB%OuNK1_->cELE?p)M;e9M zLk2YV;4W*M0ZZ5d4qX+f+Lk28tcT=Rmd$W9?RpY0E`)97bJs98^Z9wFwJKWij^^z-VLhgAg@wW-JfG z#@{!OK~kNjTxQP#xjdDK3wvx|Z`k9$jSN>zl!1hT4J#n7B-&7GySuig?Jv9u>_i*Q z_7x;n!X0~x`WI{|{vb7jnEKwSqTz`VT2|g=+Ij+N4OZZ@kbn{hzYT@QtA`sXAi_u~ zJwmw+JQaGewkhI@kKGEJl6^Huh(cqK-L8ubd8BF*6r!qr@mEnPns@gWC0%BJLu4PW zg&I#q93}FJ$&1nO6N|iDQ#;c!$tYFuYWVNfRRc2Prw&3XqmEJ|yvw6a`~YYGjY16r z*R_a3`%HVxP`taX)406Qxsm-2tao+wRv;I#Z!CB5sJ9wgLIrc7v0wPIbciPc0_ibL z^vbkRk<3Icmq7Jkh%sRTiYus<&7>{fC|A8~*Ds^buU8Q3$;stQ0j0LUX1jD@SsGc%=P&v}VrjyUB#^sF(d0s?MGzUJq}toB2Uj}Xpq0@ys~);+XslYcFiCNP;Yeqz zccN5~L>qQi!lqMcv$W#RE(qpc7qI<%HvsE2<*#)R;j}E@-S?q9*AEKH#J&~S)aQdtDK2S z*~vx~llchN(%`_}tz?`prKyOGiE1ZF2WzyAD{>z*Nx)TgcEH&r>VQ)L%+4S9 z6l-lTB&)|LIVY(nvh8SRuS95Zea699Wt{IqZYG?pbOEoP4aTIX_6owWH94FN=HyGr z*7Fa`UjY(90X7*cb1DZ(+&L`4{?TA|wtLF0ok?P-O!HNLElsNllh)=aYT1d&Q=x

-F?BI}1KEA~O_eNJixg+hiQ7P^=g&^eMfuUs3pz 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 z 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 Jf9uX?{~KJqcVqwn diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index a8b2c9569b82fa6c60f5703fae5613871fccf82c..72804c76586dafedcc499caa9b3fec3abe0311f4 100644 GIT binary patch literal 536090 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%0D)&*j zr$AwJ)IxZzxH%bUP9b>()7*z_A~@sDNKNavrGLpp@hAa3nokmr&|re~$*WgV2$ZDL!wPQOjO30KQD{jySx^lOn!A1(8bMomOM-F8O5ziz?w-+7xOp0eu(s8p<+8a4-c zB-Q^*$OsSBju;RO)YrDK!LAt;ts`RwgRHHQFHLHX)k%~N@)J?Vi&*@0U)G2{Ubq#K zGozvH&*d=1dPK!US#cswZJ+n!FMGD3|DtY;8$Ph|N&mWIaTbSEN9Ja?FB|F^H$Ifj z`WzVM@IdJ_fH;lHcEP2b7Y{ufM}43t5$H(r)HavYZ9Vt~m4!%@)K1O%z^R-jx=*rt zvGXMx@*^YVPl`{LY?&Ju0jeshQ;=ts+wATS-C@K(_9R{W4rV~+YY{{TAxiy4P%#mO zd{5}lB5bUK!}V#j_|}j7cVGiu8Wm8w7gbyv3pW{@CuVSZQMwQPUGLz`n(|0?q5TYt z5KZmGcEX3QqVC6@Ab_pEQPh2uQS1aNNxhUm<+w0KzyJ|Whc4*8{JBFXrfNEgJ8K4J zo%T-u)?PWGHs~oiIi|D@e)=R7F)e!mROl{Wlnx$BGAH)~lEr5EMM-oZ?5>?K=zd7A zVTEAV$y9Bk&S{_199hwbmT%%vraXGyL43RfXh2_;ys80hT05%#A26T|)(kwzTuexo z8{FL?0i2}Et6Fm}_`4IQLjNhe1@5@Om)I!T8F(vyuVf&(GboV$ETuv^Yc=3yp{Lth zLF%(&ah4h=W*~)1hs0C7MYx$PUxGb1873Oa6VsU`aqZV^WcC{J2zI?(_@N}ojP@VW z;oeM7JS(vUat0c*q%nf^2v>kGV3u9EPN120vH*XXN$YC>AuwPJ8o zr$Hm>1p0Z{C&Kea-Ecd9XBxD%ZHW>_14mYU1L~*g8E*xqIGrKL{gCL)Le4r>J_#m9 z@bHQ^>aKlvSh^P{dZOLTL)VGiDkM=4wfVhvL(jgkD;W(lk>SkNoz{Xm56J8olV?N1 z;zuQ}PS6Nc2jZ?(4vyOE5`mrq=Af?dJarHuBkip35C(SY3DvJtOc9LE3{vTk`n$@M z9yF6cncs@NaAbK-H4t*(;a#=1TXn|rOSUnRm{#gsr>{c$Adc%H^0msFaq0DO^=bgq z8@32VAxqtUXeTc3#jR$@icD(pZ%JQzbF7KeD@&EZW;D82^Z4{)^p~I-6+|JhL;N;AM<1+d) z98=(s;qR1zM-c;|Jm~5;9wh+Qy|{hj8`H@KQNoZd*ckx$wsY|g59P|(Z8!We$B z&{=*8qV|YM(y@k1ZNB)v#M;P++)nq8XKx_O>ThOR1Wmy0=%p7DrVy~!o$PL4t(r1T ztcOv(V`TtByM)z-HY}=D8F5B{uFxn1;=pJ_f-K?YNC^h?F)>}Hl-Y>)-`kO}91FTL zN-bSnFNA&-(Dpb~ad=2wh7bh+*iqy`gj{_3`PU3%1=O;3{#A-g44ap-?gWDYC!j+R zQL^Zlx_AKuFM`S?d$a+y{0sq8nu?-HNA$*`bu%7A8;&6!$c~3~MzQ_VR`n4eDV6g{ z{Y3#HyM;j*$pMg|SH=$AnuH+o6yqF*C?=>$g>4EsVh8}m1Uo9Er4#oL|MWYTnWU9! z$4RUILOPo)ZZSN+n+Zb7))9e23mVpr1wG`itHQoC!$TfX^yh$Sl~D)#%bF9qt_WRUOUe zJ`MWoB&c87&{48h;<8mABh z336lkBkX2h~@q#yHw&7(l9T>En`?R3p|FNnXhK8lpI{RDP5_J z#*mv2F1sSQrJ%Mi*Aho-dgP!D*|CWRqmhURUljYbOQykV31=^vQk?6bV5bZ8tfrzo z{UGWIu>oDkM90FSBiNQrH=oC-Z+Vai-&(~jS@QJdNS2=rj3m&)+3_Aw<^Tw z=i>^17?fophJnj1kn`tmEXj_RUZ#zy2Hz8zu#$)-KA?nRrYH~|JgR@wGyW6*?jSc> z@yZ4XeS(!X!obBU!X<*8fBq%2Gn1apoWM)X8;pt4gFfvlBFXwZ8pg0H8_n`}=r6v~ zh-`%r&hGs4lv2Pg5Tc7TJsFgfC7Ez^YFX)7urPOt0XbjRUyH!?Ys>9FLsJz_j%5tr zKgblyjOj$!D0z%wx+Vj6=DCUUSClsPrdqck&tFXjulvKHNiYot7_PXp161U69W4^B z9M=GQVChN*`uxSHyWitD-|6Gxpz;aF$E}+C#+7iNs@ceLW4Z414&~343K*s{2#nss z)E%INvkqIr%@Wb08c?uG8I4NitH;t$5o^*A;fDNS7vq; zq{6WA=&a9x?Wu{&@v1MAn$LRRvTv&ISC!=}Nd<`QE}>ERRYFr!6VY=7HT@4+mRRn5 zhgv`o=UCF3N~mW0JZJ+Fyw;j?*>Ux_W{9_3QxJ6z{+Y;G>p@ciq1W(}ko9{rnUX{EP_7UHLz zn98iEU)~cE@^{<;qYyP?=s4tP{z=N~?55g66Aqc!oc(AjX2&^0jY9JC&%a{0uNHXp z{J%YC{2F3OI;FeE!c6A@5A}0c7|N9VfOw9lENV{Bw8_-EEjD_3@Am^c7we;HX5?ukl>< z+p*%0nGpwy_;EX18}H&;?dCqZ(&u?o_eibe|4qm%2|0agqPFNL7qD&i4;i#cfH%{& z9}si{N%sVQz49E&cR(xQoi@~uXcf1_HDV`$xY0%<|1{N*+SNhlRp?d5PD?DM0@*1@ zkaZRZCHEX>aFY|{ycoPY17d6O8hMQ4wwOI9}tl3JueH@uLf}gZZd`591}>TV`qtG-fF9K0osHJDawWbzzz%pZEQDs!Z^3MiU>X$z&V-Z{(K%yMQ=7|-MgE{`< zZ@5Ap;#2f@uxuLZQ&Uh$qn84x=!5|V&@XXo0q2Sd%J=D8<^8Vrm3*#cxngo^Fe?9o zVqNP(aR*`=jt-4PvE9Hw|?4br}hF+`b7%!9bc#K;x%b z&k1>o`x5t+bZC*)g3byYS^wq(&^=8+a-(#RHkSBdkP?)-3^R>ujhfJziG26@Gl1d0 z%&#*CG5!5-nG=K&*V0`TO52gS;bd~)dj<12Q>%#(EXlGeMOMY4q8KQ|b#|Ty0Vk1` zuj!}=%2v)JV}iBt>KzmK@;=?^ZjO4&ERyO=dI4zRWhP={8V21-JeOONN7giud6!vq4X){B-4GmA`TGIEo+( zu4Xn_$$^NvPA1MotvY%9%0UD(==*@A-xcIOh)CWk2Uc!i{WO9sH`JxI?5zVbBL{}z zN7|@U(~1irQEK53`9A3h>O6yj;rQsjL<*Z@~^;yncs2(M2Yw1 zj4#rlpCZWPC$kc5$hx z_N*5M?F6imzllZ>E3HdPv4}IsKtG04xiq|7+(o-$Dwe(mPznOG{3WwcZV;)~jSCjN zXsOo&B#*}2RS4G3i$_r7%#yw!2jj-bJ?l2gK?h|B+!8KnfNxf z-1$dl;}F|xBUGcx<^N*f+~b~UB(x}n9A!GC2pQ+E63NWI_d!)Cv&t8E4Pfas-1}4g zTF2#LLN#qJT%wDWN}zLxkdpWU%5b1I@5JhZ{3<3ao^wJapbS&<^P&@0%4Wh_?|_3K zBS>Lk@lU}2=7-Vm!$no(U0RT22 zfPCc;frvX0hOdCIm?(@ot*NH&6lP%AG+4?Qq=e0xRKBA0*+%qZS79YkXqRwx z#+1#?-vpUUvc#ctf@tZyXWr<(N9V_E@rml3HArcZ#>!~grT9hBQmH6kzmpLCbJTC~ zvHYCzm#T2=_p%w%6y=gM^UzjCmZ3_c6xP5_pQ(l^SsuUN7Jrm4K zNNxm`J}~Q@Un$jh4wHe=LC~Wm%M{cqD|!Oe55?#cN$Oae&;LSC|5b}o_;~=9$x*^7 zAZ{5b&o-wNj2!<3D!D9^5S1~`gmQQcDFtI>xGi+mMe&b>FLjqW-KaUK2L2WnLY7Rk zv7v{V&uc0^J+55e2K?7;Usbl$)qzL$r7<~E2j#F=p|bA^)7#Tvh@g`r&VIY|EOQy>xtVRaw^Za3b?Aw$<(uqcalm#tI^H@Kj;xBcFm4)sp%pRBuIdHKuzkc7T-JD+L@eP`l1p<_v1KDFVKU& zKy^I##*XE;-2vfG)>!z%^p-Q*DAvodsH36Pm*zk@8;5DSP10X;6G~S%L(pXmK=+z| zn@dYD$S|MiwG5lwU~LH_FpMcjvMBk^^x=`FrEr!8mqx!K3krh#?(*2*ARC0rX8%iY z`LAcKzPdC86#X^=!>T>OX(=3x(${bd6gWhj5=dIHY~PRuCJ=% zvdI5;k<7lV`&Fye%&e_MW<1p%k4-e{6Bt@N1aj0{{(M3tolmxO{zJhl{eMeUKROO7 zs{6lTN;R?vP*WUOV4-lSuh*dl_vLE;FF_k(_Gv}9_~r`KKyVzlc~m+(mpX1(mvJGHd>7 zX8S81?2HzXqxjA5%Xp2)w9qXNfK)nZ*k9)73%a>FB8qs>&q%d$_{Us))_7A>@YtKE{=S@4yva_rJ?A|Ht8K%Q}3! zai*_kutf1;bA_a~a6OD(s7*hGF5S^~-(qJ_iy-s)JzZ34uHra8+I%Dm*ZDUAT2u^y z90(qMlDG~d+xetu>G5`*mUMiP=1Fp0`SE^Y=xkRB*tIF)%&Msh^q=lN%IB4%xePQC z!B_h(w4j{Qe`F+^L!tY|hvOCk+F5(wwW#Q%tDsrjB!^4~b5YP+E~VU(9=kiEgvRG1 zg?EV(lc+0HVc#Vw|12PW%)?j7yI`r7ALzo)Av93WiSbuXiHsl~LM!?pd6gNi(2|Xl z1gEXn7I#0<3z$Y+Ss~!l8IAWyU@&MM5i~%`7-6DM>6R2B%$0QAo;AK^fJqx)p;0p2 z(%oy)#oec9VD0#%Q0mZ{%gQ$t39wFL4EL{qy=+lPsa)cZ!d<}f*Om?(=3?W4&r~8I zHO9B&iOCEjjr8?Z@7xX#*IpyA62a^MH4(b=@Z=hOF19R$L`p=hJR}?RyMyRAwVijN zaEf)9j9i5&qx-^5GWd(y&T2BBvuOaS?{L%RZnA#NFi(~*=^apG7JHn_ob>6DE~V)V}PHEo- zIg1$O^jCJ4g!kYR_tjM7K6VbInQg<@(`F+ZDKGFDrx-G}H*Vda#rCM?Cp({% zSCAmkYdt*W@H-)LuN}BHUK#+Pz5;+zUIU;LR?C}3m@s#Z{hCdZ+hZ$?XSz@^CV+Z! zz0j40l>x6kLUnDY%%rB7qLuj z9q6Zw*kNU8Bso*Ab+7ruj9#0s=1;kW;8u>%;=6`e&IowLrTH~r@D(7}9=MOCS_Tw? z?58cyYQ%k4blZij|GZbyYTU>Yh$gvNKFH4!;Y#m56|2V;UFF@K^0;b=9d2d3NIxF6 z0-~?JvM%Bhx7Je7zS+TuJIfI`+jYKm+4lN2Jw})!gJ_ajmbqM-BbO7wFHPRmTFNPF zu0*R6)>zgD#K>-&mVP-zd`UVtX4MaWgBC?BRwL7*OmPDz7}KM{uX;U#JS{Oy{5 zp`0IP#JL^h{DBY)>hmIp1kfz86w`_2UcO&|OI{Uo)(Cdap)bT)%bo8_o9Ov?hFJ1O z8Kkwm{J_601F_n$4)dW6*L-9gumPkc4*D1OBd=&oKIM#izU>+~KvPf^NDE^pH9`k0 z{Bba9O-GPdXqo^LpiiFpI{>0TnZRa&L(`E$<$!{2;1hfz{!s?wx~x<;GU=}c1eU{~ zGu>oDz50L15bQ4h!6Zzk&ubl7cRlpN8WZbX(NlREn2C?oaxHv-pm*tQI?Vbb&vkKa zzbKX?fzrT5z1)d|yFPKRAGwn!$jQrarrP)+PiTo2?IlhZsC`U=kle}fH;gUN) z_^!6f)Lsk6GZ|{YTDyE}yjP+YX<%@TeP1P*@mQ}M2$#5BQ#qB*>J2|b@+unXrA_6YCBumJIu`I(d0l9)6*HMibaO{F8Adh%>2o{ z7OB#k@@+|qnA|W}d00*TDXQ7~uvc<-KL4I-DDb~+dP>;T(~X-Dm$~dyegQ!Tnj!>z zy?CH*JlH8tFX51dgiHT{7WC2iiy^-aeEu1YHGe(20xjlp`cV78A^vN_jhT!G=S!+eL)*-H*?YhFXb7YC=@Ze~Ahk%Mn)Zs)gc7=V@3Opp6#tCp`o6aFE z^UMMc*oi^l+!-_?J**1*JR!tnP9@}}{D%IdyP1%GZ71P$&{ur`KK*tXO-<>HE_}uq zq{T?!(tqH3D&TG8!iG_3C?gh}+D6~C*AtE`YuV8d zxaRaf!CaevOvHYJlbm?*(dtkZ^f{f#G{kpGFJ)JJdkO~)47TgeKl3W-Fy)!@OIS9BiFPl?`x;J!fgh$I?m&E!Y@=+4p!J~sAGicb!GlDyLe1h8!zz3j)|K-DX z4`EmaXUjt#+0UO3voN2Dee5tn3;6bIJr;du8;}`~{Cv{DC_BXfGw`58xlf8TvqQlX|%jjYphs?u5HB8$|W&kBe2(o=e+#d02z23o{^HS?c(h zR=={b!H$S{e$WkFd%Y05aq}3m07SGLGWX1^1mP~cfs4qC_>Jsi@jrelrjqP$?6N=XbA9OercBVsUD@XIO#Ji_ZK!4D zeogK7aS96&L!)EhDtNOxW9*O z6CPRB)77HmF&a1ZLWh)CfLtXwyQx3SujBbXqhLWmv&?e@&nDWEYJUVDxrrl$@`-h? zRU0F&qK{IB2Q($w&L!Ip+iK*VdWu%DNI7$b^7M2rQ3`3dXQAnTwe!$Bfd0fEHYuha z#}QuC_9qM<$c=%mwN3?}1a=*Lq9&}twI9q(0C?wE*o z%&?v%e>8BZmN@D4oJrgh@$UrPf;%75}nYj7<#2qZ@R$=CI4?5 z*rrN*Bg5UMPR-2l<;0sRfx&{v|N2MkR1Nw0W~9!?TpEd%7!jLR!Pw=`a@=D@n+M=D zXFUBQu<$jn^|YaGvd~O$159r?ZD4Ym)j#Sr+e}N#7z>b*-(@%%O*!F%D)Sl96o&Bs%HtfXXn zvsJ8r;D@T7<_7sF0hOa~5bE7fmH8MSkq{X$H+C5U_j0mU&7TNHlY{rfi~?1N(+iBb z-p32{c_cGK<8!1Rr_lXG`&T30<6qmpM&9%@u+oGni_94#C74YV zbzuz2;*mMlqJd6a#@0UjCq*^`&OQ@92+x;4&%PXOG{3X2%lj5TF;J{_tf7ukk?zY_ zuycC7WO&T2@3!b{Gq`mLvFz{za2+fI)Le$-VX&wSJ-_Qt`@;umCH_$IaLcS4x$@&~ z%7U*V{L<&bfLd`wL!h8xuk>1DuNO}E%u%a zUHQ>6VfoRDYpqyCza>}_kcg97E6O|s5S>md@}-dCG{S;ukPTs1HOPDXMO)=3zxIuB znGU2n5s)ZqcHb(%j{Ow$KW&#JgJmLN-Nj9hKC9qHBj;1H6&vBFD_B{4adyJ7jE}#_ zgUYOMbS=Gqg!0YV3Ake$lsl(&EOxY*Lt0$}I|Y0LhmXL2#sJT*0{_;(4?YNA2Y7PS z%#rIAfaq@&9`ev@h+-R*ah9W#P*Q?SWpSq0f{qZn7EAXFW6yS1c`oAmAn)?sX6JC$ z4F@t@9|OQ}eFcNIe>#n=w~(pniK82|3+A-kc~GpBbc(Zzk(z^ZvvJ`T>;u z(L;<`Zj)m0fgd}wZ6KP0VlH+lzRC=Y#Jn$d72;OBJd24n-&^ttFzRNwxDrW|1?C_g z`PFeJ`K;~G`oNh)&cF$b>YMc?QFX0QF|Vu#Au~M^+pVME5Pb&k;Ub7+NqDeIo1C-I z3IKJ3K@6qS`c?k7_kI0qbJ#j=tYGRA1U-OB^K&H@jT>aPBC$zuHD&^bn&3xs&_@`U zGYK*$3EToL-oxsdv^3OlT9j|<8bM$(I$roijOeZiO zu7iNHb$(m_Bg4cU#@$rMoVpP3b*1AllmMjtwauHm*a3RWDGOXEDpVUK#tWl)-15yE zRikeLxsqVUt`b<1BVz=}<-58uG>g}JKUE)-0#Ipj)l2A9uki2T4@bJxDvbi~J}gSk z>DYuN3;=@Kzc&Erjokt#2;q(Da2*9^*K{S{_X|hzg~g`9mQTxE+e*?gA#@}KOX z1577T8K6(}0T12Qx6dDJP>sR-5eweI#(-D)g7g^ABOw&n*RU0>Pgk@BwaIG) zl3WQ*d=)fafOJ>*bNY3WbE=V&_LzNx+lyz#EyQxsollKz!`A zKvsgN$FtnEMmtraqkQ4{5k)BAcN}#KW-D(WUAK6sM_ebVs3o;Hm4V+llBG1=E0u>y zAJ9!vadZZ9bg6!2d|rB(Y295Q;-=d&fK{mnN33mOxRZ+BBdtvb`=9INBu4tJ6mlG= zxL4A&P(N1AbWB+Q1s+`XFrC)$%=LyaoxOw>@AnL%huJT-2u^j|8vT7zX){mm8%via z)21$SOAc73%Nzf!OAX_GvNCFIp#RUl`bF9(V$q3NFWMLZO}mUmKURqYfctdP2o?RVTGLI!`$W4FWt| zIsc8hdsH9#HN8J%2z-*7li%Y{@thSjRoUqd=R$6fHvQ5>w*zZm-J$9(Db{d&dSYc# z4l{8mZ;|7fx>2R2K?{4v?5;r~9JT2{n^}xv5ReV-4k})^yC^Fr*Jn=AJ~sUuBoOA% zMA|)^!53F3C^8jfu8gQb@Hca?YySWy=g3(R;OCRwXZ%qm?^Vp$?7;_mr zi0sv@)MgOo19en;&c`==Q-zY|9}JC-EYK^>JQL^UPLDgX>ybjn?RkWuocFDcZ)XCbqOjR>~*Xa`N;4qGxSLpe>3&lpkg|m zOouG@xX~}tiT>)y21S*WUMUwIG7nE9>>}Uj@X|-gB`)Sm@^wZ9=B|2UZiijZl>P|h z9K`+@L_`vD?JfFPH!WE1S_2{jpfPxG{}DujS@hgne4`NHhLczlR8WqJL8=f&anM@zW*(;de(cwUyJ}Vc#IS~N zF56DsbOlFcEWL>@WoGh$;0LV_@=Evf8~`se|7$GGGVQ97PP@tl=EG0+WcdD1dcxvM zp`O3yr04$!J{sfJ{C_p_tJFrX=JX0V0J-SUN1XOkCRl|nY76IqwurE(v0!hy-tK`d z*c#A#x<+_%sM*&XaRB*r^Nk9E5zzZg z7QO@Z1{HYP`MGHZPL(h44JUPWn96F^eCywX<7vR3kU)Y{GJT%UpN6 zpuRx>nG!Lb<>EhvO)$?QNXZBIywDx3St2|WNVAJXHsBi~Y|V1>u2PMv3hgm-A5$f* z&QD#qosXu9UIvVMX^drRy3C1(6XdWYuvm9c%_jz)>YneNlPgh_WhOoxpU3hPqD*=k z4!y4xm%^f8Q)fE39;ML;6Kl=?%IU=aVeah+uw0T1th>v_9urP$?ycf9gqcERRBe`} z@H$_*JJr8MVhVjN8>nBtyHj^!e0-+2=gY3l984={OE>OR#7t<77Mvp1uIL!>vYz3Rn@U=Hjt&up~DrL*q}gzViygBOfX04H~s#;1)0VepIKc72zT9aa5hex2lIx<_WYspV}IWrd{uRwig%VaBHnH{ zocOHBSeAi2PtZ-fyH#NBQR+X&B>l7@yuLuaik#w9|qkCjJT4e=2iKb3mS4x;>PmCaxQzfMlVHxoXj!)CF!RU% zB`(DioiU!?U)STcTHv^&5XZuXe?-{7>khcd$l_d-pJWhZ9Pwib@_ue;StM2vqSTYyVc^N!GWJKB4)eQjbkDB@5-Q zFL#gRB`|h|GdWkL9rZo3yV_c9cCz(m74ESgif@=>nsG)IXr^odS|IJw@}}3W=#mQ6 zkW-_{@!N3SmLR>omqi5nl>uJWP1e8HnXgqr73)8j&uMKV-P+-c`gp5fj!6u2c?aN` zQNe3tOY}&qKVH}I*9L643}Kqz>)ooJGpmo{hBj}hU*vt!p-v5M{V4Y2h=}A3j^7Wm z%MMnM#Lg7xg@-UfoTE&@#6>*8baBioF!C~|`hJI-xw8FDs;br-7-5xsi(h0!Tie-Y%510}j zbWQ{$ja-YfMGihJWRBWQn{J8Y7*<-ur|Lx`GsIu1$$II+OM`|pBC{;It}ng3!-~o% z(tVwdk`yF8S%i;oz=lD`?$yD{pY*MyN1scyoFja_xZqO$=))EHqqUi;6){yto#%YY z!AEmUyw%H-VSlqSt&k);Y$4?)W~lFEEN^Rq5d^Opi`5)mK>Fmn=rpo};yo8e=gwTT zfkB5Fs{xxu9t~JgSrE?tw*VG5a0Xbrv$AB<<{8;j&``9weXms>Sj=JEow=G@J<%!u zzyu@=gKuk9Ph{NIvyF5TS$VhMlxnykt6+OS>l66%fdOU1Q8^WDlETy;n1WXNvPc|$ zm$Q`ba+_uur5l&kB+413^Uht(LcmTppgVJ==ya9269jJjruP&$@$ASgj;J=;*38zK z$n(3kwQ{aJowowOF%JtiHEEC+AaJ1+@I|52)l#+C zQ?#4g9T~KcUa|Y_UQtmTLR+oe z<8fI4U9$sJUtk)yK3Ac<*(JHiI66}G_jeqZaE|Js@KrXTbu<}E{R)H;Vy^+47+mq} zJpzfEnC)mD4-iz=o!Q$>I`mrNtrQVZw=2hm?rj4>s78VFJqjuYMJ4mMw?~)BQT_dt zRYg{#`34h=M$+NO`=j5Q;ft&rVdqq-QOsQx*h|R;dY7_+G?$Xq8-4wl!m^Ugh5qNY zB?*)wc&5X8^&Y>_ClCPVaMFlw{=yB&+q^m`QW<>=RUFEVFvNtpZ+4}wXr zCcOS6LU;)QZj6_W(G=gMuT1>l^DhD9%K*5Cr!zrf4o(r=>jJJe_^XVv1xc>rVC;Pc zVfb)U&xuE!0qiY>6=!|E_YUy# zn0I#8D?T9a$zD*jvwFy1vV9OWcB5{};26&tnEX6=mk*2Mx$zs+3CKmBv8H-u=mB)t;6jb*vRuOd<$u6OZxbR6(4l zQ%|-2E$>v$xsvPU+Dyl5vHsX*QgHytN5KPIBYi_}czTQk9thALwSwmHgqRJsmDcl- znM@jyb}}dmhGyKa1(R8Ul$dq#gBV5>=wJKtD4t<1-r>(Wf7sRAf6zYsWSxE|UOj(p zR6j5B?<~&6Pg#H^`b&Cxy2yl40~;o^)3HPgRT)FmsG!8e>HO!+FDWK6Z|gYt_`-`v zfxQ>|GuaH?!krH-+tVzyGaCw|3(V?;jvpOU@v$}F1`daz^`q$LWLuF~-@^P|g~i(k z1rBp~)SdFPD*6-u)btu=JKJ=-84Bg4(y!FKzCCo1vHio`&!cOTI&1x?j?u=T9vMTXuKv#)8Tz3fy~0DY#- zc$oRXs{mP{TEWv*AYpFdneId$BacDx6|l-4s$pL8>d}RM5+3G(mE=oMUfG&DjxSRb z(0HtoV+VmD;4%<+Lha6fCytlRctk!fW@9W3exMTcz2?_TOvPQ8LI3t68$} zUntW#lf#PobM7wog7;d}O;`GpbLNr%209*AhvT0vS9Gx`-~Lil7;j-jfgS(;@uSyD z(0JW^I{wMm|1WoMwjD>Ygl%6CtYwTa#_K=oZV90+gtidE*k^KEcpkCA`0BsseIl~D zT9yra`lkaLxhjXqm?JVWOU@ks{#CbD7oxBN0x$JfN?GgA12y~&YQ(`?aS+d#AwWMk zRRjcEFnRc6wdJp0O78e8mJ-RO9Y@=^&$w7wZQ_<86F4t){9%S2xW*!wtAEt03<*ze zI;+O~QH!bN)O_QaC)3qtrvFrq{}^-f86>zaXBq$1ACyZEx`9OqoE(J{@g9SM#!SwS zPzmIwF@Md0-0)7U5@`Qde~=humkompl_oeF`Yph=+5q=u<4U?~=i5|!lmr9gHHj7 zMD$0av^G3tFhVKBqsp9gL+avE@9;v!M?323mYI_v`cm<2qXe0-Z0~INO`?hzo;4X4 zTRLL4%Oyf`z^S|kzoDAH2GzKF0~K{OWu!p=)6>aA$%EM+?Ucj@WJaNwr4p+9#R+JE z{xlJ2F27Pg%~P-arMeDPXizpuk3}wFpIzGWGjm%CPtL48#a9Q1l`v@XcyxTsYo#Z^SfnLpNW9su9hXXD`=p4`rp*X45V zNx8VqtDg-qQ`fw@>sl>&U5C3?_W~~z6wec1l>Cbl;tC`L6pI4X5_PWq=k$$P5yJ>1 zdbY=5=YzIIY<(A60B0TdEB{oUtWw7eTp5^ZkdN_V915#hUb_^rFGhGlpIdQZk_q@O zAj(HMGkHKF$SS(=;LOXf)}^Fok-bT5&N4F5x(p!df}`g!(K-WhGjn?1s|Em-id`fu zuWWEfotP{HujGDii+!A$q<j!j>i?^2$ zT>0i@wyqbaZk1UZv$|_q{8Xt_t`3c!_!4RhMA>7Y zOv`#_#Y;*-3eCXsjEl@2R&vL>`%bH-m7H4PwTsI3dlCzJ4ZOczag4;P%I~feg*m9e zEN%b@S0lrrhaESsis53cc?xnUI-spE6eqcWWcky8ZVA0!l5!7!7#g|p8I@PtH$;^3 z?0KkNa&azfC|^-Q!x1$*(*%V_DRXZVB;yU+vozJIQ=p%Y=>P(Uk``Pcfkd{F%xs<+ zFCAS6XfzzJtMH@P&*k*>kg{Xg%N>XIH5vU@kOJN_g{u%rRoA9?yc=W4i4Z6W1sQU-JwmO? zUyXTjb@>DO)fq&iS{GvlPp(#WekjF?zsv)j%Jrvy+`%WlW1?$t7yZi@U_A1VLmcmIr!3|E^r9 z7XO#Re-RLy6MrrIOq_V}7o2GOe|M1{v-?1anmQ~y@P3IuFTo~l#9O#VcG{5b0|BXS zr~k#Fbj|-Gan=98dcvn#GXh9%Z@xxIyi`> zCY=qo-HYvO-lwjNuKtiT9C6#*(m&drkIqAql3{OK)$@tbfkls4r5xaxQ-#9z*!Fbm z!2QwbDk56d?@IzmgCmw zZPS@3I5A=uNDN5^&4@v_VJw<`1g|RcExZz7T>@m&)#c9c7w$6e=cHT2&^h9JQH<4E zQO?9OWLKYel~|32~oUzL+|$lntJVa-o7y7r~9uo3}t$LNz_Z={4ElZqK_9dqn6UlM2MFD?-!Sk-@!Q-4&E(Jn z9jYR#bM+L-j}7yS<|gb_HBX~0FnMDb$ve) z44_p7#n zndfqhc#>%+V@hXyu3{WSo!da#59%)s(lXg;Du+P&PeI^5vt#x~1M(i}O@l9b>(+1{z6^%EdKrjXrcxV<0P zp2!3OZz}!G@`YqSFZa3P{|Y#cbR>^h!E~5DlWLISFia=B^K`+I@Bl&a^Gcjd04z`g zVuF*bma>zVI$7*;#TqnqUA0HyZ1U^N<-NQsXHVuh&%aBil5);PnQ36aZ!QXP0A)%V zC%^dfpw_@AKxoL<41vJ09~M(ANJfieSm0e%tDouF)rSw~qKKTI6eD#mS+N`+Hv^br zMja!4h-)B#$6U6}?`Q{Ns$hm48X)Rz>#&7)x0E9)|0SvE+9@_;5rI<3a=ZrTPys<> z3Ozr4tr%QoWTu2y5iv#X0C48`TKqZ_$%mi;pj6jX?UgcdQDhYhxyrN5u=+tSIoJFq zyn<;LMjwS9o@9LcHABI%_X1gKoXua%Sl3=;?$VG~{x!(dTNEN>HlV|drOt5l4 z8C=r)P@zU$jU`l7^|{zSQ>mnOs<$lMTU8qP!?|*qwY(k{4sN=XX~yWXKL%{-zpv)rO30|wl7Gnehge% zv(ky5YygQWgIf_kXA^=tCn+44yQ89^sCb&ta>r_+8|Q@C!-M=aRKDEMBf)49guMzxS2Gp-1TeK`kF;YK?&ypfR8-s*c!mI1mY4N8_CcFmXoVv_t*P2> z*WB##Oq4n^KB#Cr7<0erX=TgLC0)PM;Rn#)s{ruae9#B)3ec$g)sL!i^6Gdk{LPGK zo<_KB8~&!vwPopg8~yy#RwFvxvBP@S;XPjuVTEIziq3!xKUj(Xi)Bl2N~cAX1B_)q z?9u z1T&ns2b08YDN)Q=%GP)OTiEKdhRx2r6%ky)BSRsNV6t3={8byYdQH*5`QTB=^dwiiXRlwg@z~m*gr5Y5Cl+_`UYWPa)ID7{wgRs>>KvW-9=*p2PR-}wq zFoL}LfS-1TKmn(=fUJ;OOLkRUWy6Xp#H)3dPo{n`L)WTC@hROvD9F%RItE$M!3*Sx z&CvxiZs?=cVh0@u#Lr#fH-f0&ymq(hls!GL(3ti$FRZn0=oLHzUGzpV0g#p>Dcg@f zMKja+EuA(4^^sFm`{TOdrHCe+8JTDzW7b|=i@z-8XcsB#Dc3l2_QoMHe!Xrb-D|w^ zvLaH>V1ob$z5)SE2hLb}pY~#8>u|ILrRNWHM4s&8u~!KVj2-|L?XBeoARo-QwOaX< zYf%wVf_PU(t{;&l8R zxRE08s4XXtu0j2UUn*kbEU1)e`YrY;1_Q_I{J>(o>w6Hw?sc**1EmlVft7W{bHxnq zu88QIXkiFb9%|ZA8Jks4+;Y8YQ)`6jX&<6LOub!==#;;Y$aQT9JUFh8!Pmv4q3i0N zTED*N@PZSiJbGX0k?~Bu{l2z+%{8qcNlCOUKmEU68J36Sescp1^ry{wh ziL`B(`5=}P!X7@0yMiyuv%6I%A^uMZlzDW&d|bG;@jf*QvHh52{DL+tU<@> z3q2YA^YXV3VQXJw+;)}>3g>tZr$(-20CCq-7De*!!D+(Aiaf)*`o|bftDhVmvk55A&{eG#`VvpL#wF-s?y`G8`jzN(mB&@4a5aj&v;sg#9LM!c`<-@R29AO509;C)d_TKrJIc4pHgJIVKAIiC^l&iy>p*a>72-_24wWv6n z?5Ctv`Q(P`3+LBagu4K8HYIJV8Y{e;68RvkhY0UjxmUp~yTVh)ClVUrt4_g@2I5o; z2MRDg_x}`H%O=1nZ3?#I&k2SvzzQEEai)HNZin$CSg3@6wtYM#($CEWW9*#Z;4M{) z74b4%e=!Jz+k^6R(Ml!%NMTi+?{@gA5ydqEf2@K z$fCU79YC#PMw~xXp+A_-fgnF-fw?a8>wN*jCao70kPJ7f zEa!tk;EN(S{L;t7x0h`TIRJurD~@RfyciQn6J5U)?y6wZA49RSF^=xG z4G_>!|7#npZ+1JWDEY{AzFB=!=(J^!d2(ys&c;D3b4;g|SZeu^BEN}cOGsE-e!yOC z*tUGFkE{5aWt5WfRo$PtDsy1MqBM~%Qf6cSz~K5$RI$t9o*faT*LJ72v|7KxH8++x zj?ls+4A!^u%GnuR{WuKcK-1ZyxQ5u{F-TXnR+Kamodc%~SVhQ{-q*WF2{BN4+HO2o z{!BYj>p86gq?Qkb#5MF!G9=R^Z)*~LD2MNyU2A|aSsGhS9z|RUr`Wv;v69>&wFSav zC8E(eNfgp?X30#3Vq!m#oHXQk(wRPG^l@G~ z$N#=>7<2yb`@w6Jzv{WotJNC1+5)2gZ|AnSyAmTUX=BaP z8MrM>Y(Hf&TW*PqgZXw^HM*`!?tQbg=60-(ma}5t%&AKY3RpbeW(Krh~BPn92g{BL?zUD6+4}gaNi(S zlE?ZE*FciPd-WOgW(@xQblFm+X>vUh=vb@R1RcbAgVJKfCiVf!;&*#9OfEYi9!U-7m+R(jG#Cpz))4{^wh#|-PEncSt*Is>f za2is_JrK1*x=3WvfAi&!*X7S|jOEE6W$743kXngyZKNia8z@8nPRj6Sc^K-Lx)Qdw zOQIkohMw~3mtn!yZ4Y}IcAIIaCVCntcT8E9u));oC7Uq+cOL#$ib9YSiHhlfkc&^o zjBS~*ee?W`j{gASegQ!@%-_U77lEdVbEe^A7R-Ya%sj*>(>xs+WS(a=)Mlb8iaad%Yinv+g^a_`d?MyxapQ`L zOUt%HjMMJ5W^U|9>td)*E(@6Y)~so%JzxUZ7t5;#YzXr44E~gVVjf!yxi4uzLahqi z1tR*p(KI1;o*KFpc+S612nlk`Y@|^XKu~tKh8INLl^{$kX31uaWu8W&h7=iqr&4D^ zQ@xOg4WC!1N7vzWe8FJ13;PPR99IQn6G>KlT`<6G(WtFojkt2T!4AklchXc{^n?vnZJPb zcvXSdwYpqwD{EV1EfXY?04kME?CTrL^=Z2tBQac0mSS;M!KShFjt5eMP3gpss_QlP z=mGK_j1T|2AmVG@C~S}&@!v4x_rI%9#zc}ANN)cjA&k0b{Ylyuv53rj&4aU zu*&U@DHO;wqqh)3u325l#zT5p8o=xX{L>)d{9@q{Z1xL-8541R;VW(BQBw~eGu!{oWjTK}oi&fk+F zAEaDoVic}O2+=Y0$3(tf`jR;!@*OjjFOlIwL*-AdmB)6{W7i@ez zaTfWe!O@w%CkSh`e*i;-1BYP{^Gt30un!jKrN{Ad4~-@t%~7~TW5IR+M5}?$P(9Pd z%$cI;x}=2hH#TW!xQ28yd31kk0`#$VwzkA)isdOe6ksC;Ww`vrKJ}lu4T+@6$nd!~ zQjT}GM&_v(VV!*AB9p+`Gd0|niH*jL|Be4U(=(04W%DrHvd-J8K|W_$>=9pUWJOPY zCKinVf1h69z0R{4`a*Rr_oQZ~s5{}pa7l&#a?l2sexmPmFynGx^uY7wBhA+t-|b)x zacx(>KJo9{Py5liRd+DF8LR_7f5n=U{aO9;aO$;K^PCZ~N#S+_dT_jr(iv|7H5w|P zCdl9lysx}640pVC^KPXK-MCkjQBKc^^;YZi8j#RTYaQB8_ zEAo0i9dO)PRz?9o4u$bJyKuD2!O5+@^tnCoauMzYF?y3EIUJ9NQTbfnrqmC^20T^V zT;3(p$gt~5Qfdf^;lnncxf4TTlEEgk7r8Fxt=D}YHFDKgwPA4Itk~{i%qPoo-1oPd zisHsIG^3qA(V{DyIerVSOJk7LqiKbSszOK zB3WUr^j9T2DxI4UIj+Y%CxA>#KdY;1f1X_-UU(7h$(Q+ibzhILjFQLY*M*wdHeW4; zz=^@!yu2sSUzEU>ju98r^||}x0{0i+F0ME4#vORE%+7A@a7=ew0>IO{?sD2}U?lhL znnJDFWJ?r#blyIB-;Dl+qn|1b0=MR+P93~L)j2Dj?J19$Hy6k~9y2?56m|&5;2{aj zp8mh}BKe!$eSX(usPd1@dQNPbM-|)T5nKLkXR>8hJ1SqRd?mon>SlvQ@AWtN8})E& z_RPkyfnlN1n024ek=HReop0{m3j}U>-xd*{NzV-?-mI?m@EDa=0G+^|4;hPtiDt;_ zV@Sl?JH}F)wfA)Oz=%L&`t!rCW6;t@88wyK(rwPEO-Sel(bbvc+Gg`LN;Y_5jTY6^eQi&_X-BX=Z;F$^Bf>$%?VKNuvJ#+c`DdL+wV^_d;}@QM|g*J=14w z(3CAc_JYUmHF(<#s6kF&&PfB*8#S$jYO?3wzA6T)Y=tCabzW2U zkej#y{COGqX%rsmzS-#UAO^!eSpJd008}!1gOvq3U7pIT-Z*G*FVx8r2R=^;JLX@& z^bN~S_BVAe*cJgr?uOxUma|@omXM?VVut6dfq+U+6i6?Z5-v84z_aA_-jg{0h2ZzQ+cQ9wI|GzWXJ&Z$@6Pag7ZQSkb@t-p?f}Ug z^=i|@#bLYf^H-gX!M@y_u(rVuqV{0q&KK~56PR&sky$O0w;CsR4sh6x9 zF6?|)_tWUZUsPJ8j5YiU+j#|3`GO*`iheB2o!C}hh0E^UnCnW}33N?aC{IPYnWvWr ze8GgO22{uYx7)cJWW9CM4y)Ngh_bMD)0+|#IyMszI&T8<=J33{I!OXeNi7C%1*qUa}U){Hhc*O>Q zzTW+_n6bU}kF*5P>+D_o_A}wVbFCQdffb`?54|S*5Tndg=101K^&7!BDDSE!XPf~n z+Xt-o;E{v$fnoLAT*)ntu<7Ovp6$MFL%U5997DE8P#pRr?@%hW{w0 zyTmw94qW_zr_X9G%DtbOfTJs!#g(WB4u0#q^WRt88zp#8&vDSMe3pU?axuwb(3;b7AIrmo^2UO#>i@n}fg<$c2B{nGF1iQn4>^ zBmpmZrhk;aEd2;G@K%1hUgtr%nU7wtmbz2A;X3CTpmNsD*aF12&S+c(@kYa4z9!}# zEZ;~K>O%qQ-Kw0WbJYoVZvj5}5Vd|5m^LqK^X-lYfIMGZ_1UY+2_8frtrk1Y_EGJW zB_X2cU(1}QH~Y;dfO|Z=X$<^iO!E54l^tR9^UN{1(QA5rtulGF%o|bF8aMw87SOU9 zpJW(U_ir@}N^qNiy}MuK>A?@y3cLWfbOm>yKF-8D7H#qA{d(_}aIT-uJ2S&Ybv@p+UF=svq0b`82~*ajGYl6mdl=EF{|=(| zOAcR*?-eBn0GBb#gJRl{#+G#Ob-FTUG|wdJZzv z5TuTvox4F&ko>;8$*E6}grSk834+Qt4*LJ)TO9u3)1*dVXT~6f2OiE+n#BEiSHRoewvsZ(oHrURwlG&_2dEH?&?rzb z1!a^x*x>JXC9pmTdVw0CN#8uW&R^2jP^mP(@(V??icyTcD zJgq(W!i8JJi^Z4PhO?c<&CnrJQCr;DOKlQw0n|8L_my3%K4JFVt*5K&8bt9_TNRc1 z-*|aGm;1D0Djz$ z8EGk7p*t#1x?5Yj!7WLTNrS%C+^Aktlph|=8Wzvh2^(}K3y9OuXmY;Z)}=Mu& zd?Tcc#iVDY3&utq9VX3cXVYcLS&boq7XHcHJ*f_6u#uVZg4N5D8|5H2Q9FXETYZ=SYo?oH^#6D!_=))z4Q#5PP_YBiQxdM9yU^gSPPg693g>Jq%5(ZXxhT+o_?7(h zXnAAWI!=X5@cTCNE>8M_;2_z+%`FGE{?(3^v=8eSj}LPe9-p`tW4@uchmHxL zuN;*!HQ>bIOg<+>moH6rP0UYizssZ`7&y-SfCj#;^b&HqNy?r2%(UsJa|5&A8+36h zqJirARvs9l-hErJq>CRjIv)L;W21(gE!YdBnR0K+*xQl}?!Q0Q>c-xH1DGm-?mSz- z@apHQbtFg~2lOt1jTo_=8l?Z>LN74K8-dAp4HaF!2d5i=J!YZh23Rm{h12T_K4=#>Ob!Z_jrCduJ#J9qR5c z5lG|dG;tdDc<|}T?1P36$381fEH)ez9Ktmnv-^+6_|xK6S)%tgE&%Dhv^m;*h^CK+ zZH`b^h}cMM*N261FbR5F<$6}0plooj{L@2y)<2iL!T#M!kpTQK?LV@Rb$=0v7BE{F zi-O|OxlUg?EPI!sj*g+~t}<&X;cEW>Dd5KnD^t-}mGG@wVPpJtki|h(`Ddr42cL0X z((%afu%nU3YNKfbb=tlh-VXVno=Tp|ZgKzn@`k61D-#!e%hmYAvA-S$xqUDUKN*hW zaj=hpuGlcg)3{aUuzbEd*IF|x8DFBAztWJ$Dx;nsh=Du*3|6q2adr3~OhFL<$bvtT?a{^y4;Hau*sP#9M@86UZ(3XA2e z-hJ4K?&^9Ocq)F|EFh4CzkEN*=B4-H#f-l*WOws^Yy5}9vGaW(htpRfWq?~%B-0ek zTV!tYmS9VYOnu>3-d65wH8@k!5O)u)Ve5^S@vk|ggOu?vHo^L(UV$Ybc9!BZAzAyC zb;NsPZ{O?=0cU^*%W8M$Qv!8uzNn9g%D>LEf`6I+HV>I+vu`_|1s^U)Q5d#n|A$MNy4E&(i*K(Dbz9lX#*V^0?Tznq1$S4s<2PxierLavYBDO9$3@ zmMEs-{{o2%e-gI6-F;w{M;pjLnek7J=)tTV2i)I<8i-Oxq&f(kcIM_r=NLZlxQ6!Z z%Al`px2E8jzMZk&q;GERRvEKjIJz?$LGP3P>}pH7RBBEgB6X{xVe2X>2A)sQ>;#A5 zsfT;!vnZ5W1brRCSlG@D>i)2w9y&R37CwtzYpd^0WuK45Ro66Jc@<_?>eX4Vlh52T zGFEQMKY88^h~WOddL@=>_j6P)#Qh*4Nx*UaQ|}MiTbY^E2TR66b39&-an4(K>cfi0 ztrzi>rV98pXtgr6Rk|H#W{x31v6;=#FL5=H(ifdcT8F|QeY+a`^-@hzwWMru%R?8X zCQ*A8TUU6%e&57xkF|P7h3~E}h<0CFntYF~QUG{cX*9tio0Q}vX(nFibyZ!Yx7@y4 zy=i=F*rI>~`>(wqGlt0wjuk5Hwld+_%~+t0*+wsG^+&s#qj$9%j5-C)O;I*c-!Fet zStUELx<5c;u=NJRm81zi``Vi#-MoQNq9P^+(8>)Ws)u{Ox(l599&Kb(bs;XIg8oo7 zY}XLKO=C+BNE)UB80o~?Pvbu^j;p7RIts)%EyjD=s6A;3mxnE z@s-~vTd@5qGpI6XSZSqFHsn2c=a&1pLX7U7jvKc~;K7aSD>{$|qMbp5xngqSUM!V$ zjY6=Oib8BiKd_m=_?I~;62dnp1{dD|N*-zAQxYuzsw()!O>UD2J@Al>5ksJHVm<3- zk)g}Sj3@WQ4G)Jd&*Qlg7bZSLe=`&ADrsnZTA6YUMOXKGA@~zRY-LdXJ$WfU5T5+i z9VYl@-PR-}_Qr@?0e+(v{Not0_>a{iPwfOTC#;doQKUO6Xs1TGj64e)^|6sj!K;ef z{!7G<1kvvI1XzTbt+s-u4(4tob5 zfD7-;5I9*JPQXt%o$4`Uy+DUCnuz2|fODYOe+%qvTzSyt#*U46kDJ>^>;nIe62fVg z2Eczu(GULi!Qi&z;VdxvWD0E2cGiX{7k0eB#X*`QIc#3=izb_XvF7|;zpzBamme60 z-|ADpEPZ?f{(}}6!9@dgLRtIsQy5VL_0ax|?Hf6i7@mullTtNR=%5AY;C76Vogmg8pgl z-E&?j-D!Y2f3F3-GiJcK?vvDL=RV0 zJNTk*V7)LHIgf^TSi38HpMw#eEU!1jES)8X-Ed==NQ7HFN;;}fL+nfX=(R*Rep4-x+oodwM^ijI?T0& zadE?>{@&*i_$>I^@`+qgJ#S6xMHZ8m#c2?ha0|2o*AkTaqM5jbkQz08Up%|aoWb|S z{CUW+`e|!y=}HGv%W4HjcnOkzh0|KhwDj9Yt;JPolJs1ofg=L6FeI97X)Bt18wnan06|F}W_16lCDiZFdJka7^5Tk-!}G z%e`E!x7)=)arV)O`58MxE|8y-7mCB_Ew%6=qyZN#ypZHh#@o7+v;f^|7ag~MzmOO{ z9~EF3zV?$2o>6 z$GtM`33K|B;_h(!&5x%`8WwVZ@!_(dHbdrzV2*eV!1;VnaT=C8h;dW5NAtnG0w8S6 z#6-mz_23VCpT3@Pa8f5aqMv4Xw)EllFMtryWXgm5CT`Gxm&PueiIcNZU@>}R7 zsJVxRzoPPtgBGV>i6pHyAppG(h8x!A5VrlbOnYC#D^UIfJz2$jhLkO}vcBq!=VF3ezE#}()4-_)dz-Zef}HW&wNG+XKExv6dy-i*1jH#XjF1Q)Z#P@L+w2_Ts2;x(y#J z(#JF=>7DGDy~Q0eS3BHABkqw2OV)(qp=-V>ZE)2|{YKG^xu-^0{Iae>s~h6J)}a*% z@rS?*H&CkE0~-#}A0qJl-5igE7w?yU7J}dR3zc4&iw*#~hPyCR!RNIK!(T10&&L2` zXJ45hE+y}dPo3m1lLS%KY9UpvL@%az^}y!tODqOxAEm*Ue(q|sF7Q7Y=EnCG_=gQA z4^hV9!9N%Z=IATI$RNC%kfo49GpG_3@P|mh;3PVDa0BAnlH%&Hmr~X{$EBTo(z%DA zZKn}+k?NjitMw2TKyXOF*-^$%FyzVr2oOaIi1iiwpT*$f=xvz!BJ%aP_FEQr#3)O-&d&B_({>S+gW;8Qvil0-p8UBf^a#qRL=4D!& zi`9ea!++5Y``?w)nN>$TT_XJeapzSOGAaf1K411-aeeN_m8Owg7SR9kdKJk4(J*#j zDHoR_U+jYOl7LyA-fZ>)^`mx9M|W^JJ5n1mZX^3cLb=wupW~!1{<~_ex0ZvT7uf!p z6vevARWZ2G5vVfnb%en^sc7JO)F$6h$&=;&zIgZyHI6-pu?Vp_KyYhGmSPLuq*tylE8@CxZmRdkCW0CV)K}EqJUb+w=aa-= zqDcpmcLz#4V}_If*M@Q|qlAMwWC<9+3sLLaIOacoyw$vb!!|Oq7 zXN?^sZ(Kk7MD{*522Sh~Y39xd98bYCYt*Lq&4C%C_E0{!;#|avyO8Mr6(CD5@&pSi z6IsY@4bEiz5uMjLiOd^!qRv~D;4RF*2+>HWYP2Kv<5*}6<{oTG=miHr(XHLMWP?jN zEgmXNUCg5|1|QyB;;l3LR@|y{8LyuiK%o0T*OsIYnQs1Vjmd4#t>veS?+@5PrOBN- zMLP^fgm(&vIK0rlrYz9O+j$c{`y-%hBFAaIW|MVR*CfTFLE-2F<1)C+QTXTnGka2- z;2fZ&ZY;h1c#eTFO!piA+<#%Ci%nnCA%Cau>iWqmJHxY}_BnT10*w&X1xG5cm&@N{ zsTT*&{2y1n)IVSNzx7KCeGdQdf2SeM%U#@87^CmA$eQ4T3En~g?QSezWr$qvL+e+| z*EKHF@Xse()8KG3Pk?x0Et;h(VFFs-c~ftGGpW%fFSmWzcN}eZlAZ7Ln%?Gg@hAL*Mbyhd~sL{bMy3lRU_8(!cycn zp(riu`&aIZ-8BU3qxV_$r1~@*=ptmxD@fM)%agm!fLQ?7n=)PuA(i5F%B;tVGONo^ zH9(ZWHUnV5H42F+xSp5p#tr%OBRr_p)kZKinN4TSM~u;=t(1r0WPU8_nE&RHAckA!K%g22I{yY&4ZA$pWOnHrDs6oD3aR)De4s(E_H-VK; zoKGa0ZD!%cFm!bG>Tc0fCj4;wDID(rb6JQZysJD{;nh5e>rur%9BjOGAew#vU;sVp!nE**DWA=V5jvsM<0b=M4Vk~ zeDHR1v1wj)5TC`9TZd^#04j3=*`m9coVe5;C!8_K1O zgF24mt^}ZNrI7mJroMaAG+H>35M7ebV!tl>Vt9Cn_!hH<1>ru;?%|l&oMXmhXzKw_LNafqoXP_^P{u(o8su9< ztGnz=#SU%tiJii}Zz9UqyLD8%l4a>&=-%5uebR9}ZARXHN=Vr=W!PDeMY!A2FTo}Z zj=c0lpgN*{#%XjOsdqNP6t_3$;nsQSD3`ISr0`Zda=u?spmC6fJHep^^g#5=-reD7 z3m{sA7qeZOtbcQDf-Ma;(A^AL3 zx`Ngym_0VB1O#TkTPNV_j>tScs`KDg+rr}ZUdyesg2OtXzh+cKKV1&U{X#W0^)E0l zti8^UhN`L}{F9ikANo(rDynP)Fe4891Zh@P=rq(Jk@MG{N#S--P&qj@NR>V-oOx7# zg{PD7f>S|RWydyaufKSUg9$>`A>8YITsIz-`N)%B%N3mI!g4lqAr~o+as_AZ)_BWe z(4w;41?qm*uC5Iawos#BYf~CE3+Q`9`o|!qz}S}20R^FDYfqQuaCYmkCSD{+xzNlS zpH_|emFKu-owdRetf=`ZpfXK>s{5SkDB8z?3g$lm$_(i6tr)6uda8_y?o173rw1Ho zL9-Z37qIF;t!!HBR0{jG8b{svBC!HJ&+6`9q^JCwi)0OM?iHP)OjiFW9U7;Ov493H z4&n_nZ>KvxeiR@yz(kqg1Uwx^ea<{)eiK}XwaoDX5DUQEs zqwhReu{K!1Gz)V@hz{VbZ?Ec1Ym6kS=k(eYL-RaFVY6z6ju8AVNSbh+Q5r-m*OhJ2 z@M!3C+mPcpm5#Y!fBPXG#JzbV@ZDE-UeOf0OnU;4t48_Y;dzq!k67QMuD?^m@A5P&GcC7_iA#gHafJO*vT z(POLzHutyW@x9E(X?o{@^tni#p!{P{lo{;#ar7wq1Ve{4ijLUbEh%QOAD_tJ=b31> zpF5ou-lMpJ-#$35kU$S6u+i6e>Yr^XFdsJ!2&7sL z!WwmP)dvo%$xf7+9RpOrt!sDZFbO#__<9U!(?kt(QH!IcQ(F6`A6Z%$Xq5?7p)Z%u zBw(k4qj{b;kLpV+UCNpN1TW3bsKHyif1|GheAxHqU-Wf>ADQ>Y-|732d3XMzuaGtc zAw=-8p<-{9{LR?SiwTpk%r}CDcJ>!{${@^qEYf z`*D4@@<^B4u8GBG&B)d>`Qq9f{V*2gJFw=#BQx}U31`rvYzX^1!cIW3)ae%qZ!z7^ z@osbYk{IwE-#TNIQ*SnXz_+&tN(C@C`Z@p(_786CSFfA&Vu+LNUPOs6J9*FBz58_l z?(*WoP$Kh*MWlrky_g6mw>n(iEjXbE;8{vZQM&s_mq&*d>t{A|v_6#O+WUJr8zqme z9%<6xP4s$e?bF)9fS*|WaWAs?Zqpk!dADnY(Yc=3i63Z%9X8^juH)uGzyGtl;F8NL z7$f+2CB&kf0_70`{4IaqufMAjCZWBlq#e`+&FicOE|~)2s#z%-n(K@|Le(_2$mz}aFuEP)9Y~)xN%W^CQ&w!eN3E0+s7mG?KUoE15)? zWR{ctLy1Jh*)PHiP6fOM6YMeAFE^Ehd7M;8f`dy7t8&dB65{~+!8i8kM17jw>LY2a z*9wZoVKvE(jMUtx50fJRL5uq+KZgsB)enyRW1NnHSr4Nw9H`R$v6e`nEKo^}$_G5a z2tX*kh!r(^nMU}98V${ZzW2}B8fx^;8W1Y^PTGppGJMhe*GDZSa^DfE5W*(Y@ThH; zIh9kcM34*brF(KS0I`gxZ^H6MKbz)R(}IT8NEm^7@43K z#wj&4f8Rwjv|wB9$`&#(lez7-I(S|F$#3p7-zJD=QM2=hoF^9_mPWzoyghiC!V%Dk zS+0vXxYU~^oc^Bwt$upzh$KGh(F0%y8%+Q@?JS5+N+UJ}R9 z2V<-?x_9@j15a3EHfskC?7lDOMUyg79*yYQ0|w(Jt;L`l=Qw%!DeAbXVOZ8{{n z9Q=Tsw2}oDpwD;cVB7)5rc*mY<6}>M3i*TFI~X)`JFlHt`pzn@7k30U(wLzoF=VG7 z4bOw+DetlOa(An7q;x=2aoRhhFMfY_GBwAh5W#Z6(^aawH}Pyx|b*?%O5y8LqSAIoQ(!mNX8 zv8lLfRN<`hE!9w(y#nS#`vKptwI5Teus7wZWMcbR;9xezhJLqp$4a8oF2B988gIjj z6Yo6M$<(!v&8fb%+on@WRyQnSDC|Rt{5QbKo*raQNA)pkJV2-j`BrefZW7Tz6 zt)Mp_iT|+McrxAlEy9Ope?K@wXlW~2c1o*!>H1E0K3FE2zC7**&CaOR-*>;V?a7S~ zx`3JKj6KuL54}bNOX0S5N$kYj;2a$eS$}3ToPVroxJNZDbX#0kGp>V1G=H9i*$=Ad zIN*CC)z>;q#`LKV9op*0*hr!K2R;n*^n&FQ7W!ZOKr$74lQS)O+JI$6O*IWi z5V*wyA8ohC5m4}|*Az^G^2i^H1b+wki>&8_u$BMCvc(O`i?6(8>vmEP6+5|gUO9#| z!_Es|ey(kha`Ks?rl3%-7qHoMiO0-$wV3=zSt6&Vpny~8=si#M4AV|8fM&LZfxF0q znXVHRapQ*mhs975xy|C*%oExG!?D^d5w7EVg_+3CrjcW~F#!cwKi;0cKFR&5=^6R) z?l)qt68RKr0Gyy$J$`dJL(=gaF80KM=aefg=@2`}=WfRZN@AF??^e{}NfLcz)lpOO z8^xsW-eNNV*gZK=7XfG-NvE#VGlN1cx!SL zg>e~ilhPU7b;(rLSoV1oPi3L~@=GhwTxlEUFp`%}^z2xYPt6`DqxLxHh`apXo1KaK z*uxR0@1IJTjoOPBiKC)9%6DkL>gw+%B5Up80o?TP6>cWq&on(cDx5H%8eCmc~kn<7pGe^ zz}CKB!9}#u53J0$I+}4n5#zAvVKW$B2JOo~1}lbslrgi`^APFzij6h219@N}JsSMU zyBo_ff6B3j>4BJQp=&P8lo?K%zZ|V5HD-JZBs{pyp6q7c8=CJ5fA2Z>rIQoe&Nx`q z4}ZCA#rR+|Sb`FK=LKemrFczu^2~{AC*pWB!Q$d8iDPDg*e6hCEo@%M*Zd_7{IDP% z4U?*2*nOsH?H!14x2s7t)c{fE(OS8BtLQ-bff5j+v zL?MmyU^L5eQAAOCAC7EN*RO13qRR9brCx%-pi3b(K|w}{))YiAbk2Jh5&M&#;ZN9` z5w>KJRxNIHf~O}8b2}xhnOrX>w=()tw8O;AG(}xB9D6uHPxDi-847ByJG}=cJGt~q z96nf9xZaBC0vLvBk%Z{wgvFbwHc!vv>GV`SfV|bg_c^CBq)ikiCo!K!SZJCIW$EPSyX-o5@F^dmXmA^G5sC>IKG_6u(gOzzwjvW$+)EhdvDGh5YN{ z9E0mQ`>3V5Y9Fq7Ick5@yrF(4!g7}r-ObGH}!)Qm-bkaQ4@Km)BeudA*?8yXF1Jn~R%f4|BjeYN{~BxbU-LzA;P2bTga%yR zTJhoL4R+`4lsDTU%dgFN7ILNVox1GM@U4$a+%(+pkH#xZ4#6yS2xlS0R;ZqRZCgX* z&gjvMbA&18nO}`X5*nZ{2vGpi$zZ4;xgw`Rvg00XP^I%v*)X^;60I{b2NmF4jO~-G z^?s3sx$EwNv_ayqP*|OJ%kBLoG7F%i_mTpxC7ec7hn1%oImD0$faeSO!wQ5o?%G&L z#m5IDaUibvNj_y;zxpuqC0)1rj;t>zIsa4PIU3!wnp#YgjHT0>6zbn@4(8`%=AJtc z_?g!G;c9$|NiZSqFhp9&OgX8(%@&MmHcCa6Q(w9}f2}@J=$kI%f<9)e?c0%JnkhVn z>g9!2f8!IaH|FO9vh5HHf9b4468u%(_H=+BG)_!GMhcz$bjfuUVEneRx>vBMAod5C zq9idCwTLB4t^_)VSN3lXQ^Bda;;hBV{1db3GL1^g-P*aj>CmGvo6^*71)Zpvm&_nz zcYu+k--v((Nz%-nS{8>-P$1eu+9;_EH2;*&70sen>AKk?nK8ko!NPZA(f>85;DGEO zPT}}w&H>qU;n^o4tCN$Z$@+S{suVGgl6}xn&+V7f)bFf*ygsuBbK#tjJVz-EgSVPj z{dApuH5mdp&%Sgl)6>;zrw5NQ@0Z#Nn{*-wtaBTe(`AhM$nJhgv<2KO0jL<8FhWHUq;Nn2qre*1-bopOHMC} zr$Vt5SauV%cYRQ|*@JUAg5lCfj5A4#ss$Voflyi-G3@Lwq}cN8ME1r{+%)D%0p7Lh4PLZ#f?8?9pG{E;v*i40R##A7*{rU~^hDw9x+}Ar zDM{04UpSikFQ;`+rebErjELE{lwrDJ4+Zn?`$GBoKsj8MnroKL+I6YoLuTVfn$$OQ z>i5_JzH!Bkno(C*e?2~O2e<;!pRgVbph95BUWm^b2&A38Yfl+2Jb&e<+Ag?eaQ_?JuP&?Gi$ZuAF zfa+M()imf4n95VK2QM4W7KXityA4vlnn_HR*Q*1r<% z-Lv6;UrhdJUWp>_QfdzN3IzI>yehw4>I>o*qX3H4pa}DFcE2xi9jL52g0ow$B0H9c z7{-&uKMp?YCrXR<1uPL$Wo6U4Id-Z2K|eMl>KqJLL*a$s*&eDLj`zEiWk8E6PYRlU z{9V^_9y9&s6D8v0&GZTG9QP0E|M2axR``h5V`%D(nl(_M`KucCM~>G~(sZ|Smyj6S zxM&p?najUhzorrZp4AHlzR5*#9aK+Ffqov`?2jXUT@m%;s){O@24HV?ALzJw;}QpU zSbksk20_4f(j(D%ZMKwv`OF2!}r!6>%^NR3hy(Z zR_9U&$P>a>=?I-uoR`Cv7r}~j=O-I1NX6Da&mOueOHlBM(jTD0QsI#!3fDn^!Le06 z5Cv&&vp9yV#I8dScFl(nc_DWew?7*U9|Vh0Ck84!7PzxNrNG(K4N%m@Fk&tKN#$Nb z=6ikju?u!tmEG58km>gsMA-2KuI0g3G_GFwp!>(G_OQd zzbI~HR9ak@h5MvwELqPfJhNfplmVcksfFBUC}4|1Ug;Q9`q$&>7G<`#vb>9W(;1Ck z{a?(a`pBXgp^=!IC+0{DBbkip!oIW<&F|UW7u-l`Z8Cv6-;O3Xv(;pqXaG`n7_i#& zw??}z6DhfnSK!9u2nTk@f75k8B2oNj0>MRAUg;CUApdG16ezt1$#Ty`mx3a_kA zTNGvsM(UZ|yZNUo3#cl$-SnePIX7xMT?Y-@Nb)k7ACadD$W<{&uKd9JuYYHO9M&Qd zsf60`m(urShySg1uI>J)YWOPFb3yip;eM~vm@Y%?aGz4AM z4m#XToR)k&IavGp3|I^j?oDJSwIBt@REa@0?XMbR^Y+qcK+bhu*&+{GwKZhiczR7a z!%#wkGM9MPp8>TFHf*oUYdR^=1y7jK8b;o%PPN9$#@1j@>eEIH^8lXo=;P|TY+Dxv z$10%_00HA2x(Gr=1axZgAgiI0bZ796u(aZaGs;s6RGo*oD5^3hm?GjR;Ezh(^K=fp zsoAyeZvju`$OOg)_yn&ctPsnp1O5o`^6a@;Z-6H&MP@3UZs4y0>6zxi$*uywzm@hc zfPZ1C@m?uie_shc`T>@yZ*UDUJHk@=_>Uq9RvLnVFI!?gN1)yb2cVlCb zk2o-&zk*_m&%fqZwHW8dAOt@TZ!HC1Y<~eDi{rNrRM%28{yzohpaMu?_462i9^_uo zgX_Dd7mvNO0s}&m#jp8oAXDxB#5G~hGRj3>eqr)i^ME}E7?ttjk0~*jj}oz*165Ew z*L@YoQT@APM}|}KQvSrC$FjYa$UFfnhB)G|zB6mWzB!#Yq4oa5Z|`NI{;KVv7p5pC zbwh}hw7lSHX{%E~%%H1|}gG5XA-$@Z^G@J$6JUO*eI3Cx(FhkFV%j)J<0sV(IEF z>QmQpG1K|w6`f_T^K>SKp3XmnzpC@s%khynI$s7l_96W+uITbq-K^eV*u#=IMDTx& z@hs{rooqCbpfkYyHggYpKTxowcDIl`-QehG0NUCYmU!ZcZt*=W3a zB9K^upk77oRTy2p-~d5PRtEJM^U=a3(y_-4c&t@mcz~aV!Wj>Q zE{>nt%6M+7mtd81UZH?)J_uF`glv|WL8Qnh2ic=6STeAnc9T8^8msNPks0W-S^IrE zVOAntWUbg=I}B`bc-Mc6Lf)&mkB&ENHsBEHUXS#%FRPo9f*mFavso$%X)-oS)W zjYI@R#21r`T=`Shdxii6I$Z&!hfi0OacXuxT^#;6XK$3s(bfZu^7exdTHvc~kFgPv zl0!J=UM-1rwE(!-)?o5Jjc$~464bh7aAa2IH)Ip6VBN$@@@jrnH49$hgrFYYa>kV= zc(j`)x!JRts77;($j3*~@PCP)Mw4svQNHR?*yKa#$jAXJwgB-39@h}y^JS#4`!}e) z&#bT3W5QvumluYBeR=`n)a!ot`ix!=`2HyWOMJioxO$ymJ=d?Fo@G-=CmarvRy+b@V9aUvzx^tyzri z8BKxwMaP%lYDhrjY)1X6Pj}p}ua67~3c=C~If}QjTJ$xH8I(F2D8ArC3Os0C367yW zbrB;J+~Q`^_njBNtQLNJ=173(n>)I|J$>E(7=23@PJMr08gMS!o9}u5q^}Z6VqLIm z-aaNeb-uAwF)!|i@282M!8>J;VIIW?eR_J4HX2(Q6eEsix~R*+R1dMS+8p`hH&}xm z7f`|_2l|;Xk!jbXYVF-;8J}B`qE)PHS?dcH#&*in@Eau`Gj!SkCxz9oi3(T^6s;Z8 z)K(aT&x$zmX@!xQZ}Tp<%d@sm_iMjw))UNd^icfZ5tMspAz_);c_>;=4(^EX0#08D zQ>M_sA+CNt(z7DxQ+~|G#aQ$1)$*=OQbiHWvVE}Z$YAm%wiV}PWemBDehzb76Ort0 zq#GR0s59R^mqYo{0Vj@YEr>HQ@l6~dd@#+dT)~q1StLHA>*&^JvZQwoe_(!E034G} zZH-z4c|eJ^?+h`)tyfs!{)EOZy}8=V>HHQJ%_Q{~XilA#4y72vQ>P3Ou9yvRv%KKC z=q1lHmsU^!YN);+Lho=IanGBO7!lMJWeX^xA;4tMg#iS1d&C)pJsxP<3<^u=VIVz* zefJzp7qndy;c8TEF!r!YMz+EPO<(J=ENODzD)i)B-vsDw4H_|Bm)U@a9h@8X;)R*&mXaBi#-(+d-?rwY>)<85@B5SSIr7 z00C$Yze;RCG1Q-fj)!302{%0I@|etB1w12{$3Rikanx;o~lL6&C?z^W6RYh*zq(#01BMLNN)s?H&Hm5}uIqe^06U@ADD6e)&9 zIs?*m;t;7XCeSMtE}eXyG+?P`=oCbkM)m#y+C18A$6jlD;L=aj`bZsPjqSnU#buPf z++63%Y6pPrpc`$RPn{d71_kU;Wl5!;MX(uM5;Afj>xfrW3ZwOfn$1P!h06L=OxDJQ zy5$zBJQlyDu;)H-`7U#n4S{ef`Hcg{UL_ffN}D>tZfo_~2Kh@6Wqkw|K4=xs?t67)a5YQ~Uq>h+kDBhX;ZxTBY0S_PpV;{PrlJLYRX`QYPgMarJsr(>AsTa){0 zfc$c6@~vK~bxSTJvjneg`%_WOt|DyE*+kXrB^{f{FIUt_F8BWx*&~CMV`;1B%qcgh zVt%?l^-dtmG8Ld7CHr~)-GN_VG-T-`!r~I-Q$iv58@A6HV|BnaMKkOd=Rx?LR7Nwf zi$cHCNvVj&(zlAplx`G>jY)u{BUDkWlwRiCcmX42V)KDw%p3pa`=E5XWPaJBaiq|5 zZfRk7v|z*dAAvW37@o{kkhME6Bp2-%TmhM!3-W+$v-~T^cM_n-TiC3D?&Z!8bH4!v zjQ8fL1N*|CR8O@w6WJf14Y=?RwIpExS&q=2>HARWWUKZ@dd$J(9_NCEhKJeHumK`G zwt*z~7!GR~6iKWazduesjUU(j(lQu&I8MHi1O3Y%hQ42j;zdO!Fe9_p1Qi^Y7n5u; zqJT=1%>%PCMfo&HJRv0n;~EeK_pA4(bs)3MhPt;hRdrWxZ*27c{1C@Y%N46psjyHx zc3UI~@0aM;ua|9vV#)vD?roqt%3d#N15L`==gB8=?JO|f+?5`lYf=CgNA9wut}dD# z87ft6vbvOcPOYqd)lEun_cII&7~%eMCi-V2*&MfgleWDzH@*Xb^+j5A=;1}&6m*g)Ox4lPKih@7{n2$8z6qW(` zf!q_*S^5s86v(TE!)j|?r6Y#X_RA5d!aQ~@8RxKbPg+SeCsGMO1%MwIYkvjQ{pI0<7}B>51%;HbFto)r%sJ* zv7aj$BkGR+AsN}7R6VENzd^>~062LQgrWLkUHBZ}GQP7^8QvUCs3RP|%-saKKovCg zJK}*aWDXYEX=GP2ALU94T=_6ot2Vae>MHP`$Z>#q-2hwDeu^ifgFb-088KtQ9#!6O z=#nnY7GqBDMvb%0c5bfV?qnxdbvu?Qn2$Q=E84xL9SHkL@UrFUCpHEUNgVoT=n?)g)qdf>6yJCDbwkEZgd!9BQZFQD zCmR@dAJui-bxufL=_&$Q%Iwy+`k#_b?)q`_@3mDttr6k%^Ju|7zO(mvj_=eZDSJjB z*nkjbY?Qi?{0Nf=#$r0(eMcT8$cT3M2rBviA&z8M6h!9o$enadvG9fa+bDu&H(SK{Y<5rKE{xE}%FV zAu(%zf6#46UOnvrS&zG!Lo(NdRny)W-3#rAr3yadHA8{QDh@8a*`wxZH=u15?MZ>r z_Frjm^%PK<>p8bgi&JJdZ-SVpBWTCT;ZgQO7i(Nq{ti1AidPw3D$qAzls9qt>1?1%4%^_$wlPg0 zy$nKmb;h(gy)^PcHNT4Z@<6khHp2t5+W1Hfei^t;yX*#tbbe+7&7NM_wyi0-8UQbl zJ-l>JoKtXxG?g#L-%$`zBZJu^6t1MWoai__vw>sqFJkQ01FzCRiE_j6k7BuXAdmc| zASNxGyc56S3d;!LH-L$1fBF5jQm5*%;>HB4jN(6DeXnO+>~Z3KS|=4HlxEW|Sh2HE zb@-u$+7bC!1KdHJ7hpRmEuxH{)zGrn!i>diWVgZQBY&iUj;4{VwblL=ZTu!)^ zto`D=*yp&|B(+84l=c_j-Kg!wK z!nwl3VN8)73%Psh@v@|?IJOH0)=vC(xem^BLX2;A^0|_TtxMi_N3;I~qvncCnG8-! z5CLTwQ zM7|(AX2&{n(Oi<@3aPxWBcT~gEt4t(R8o&P1=0k+kqwoRDwTg_h^RFRaijl*L7_-U z9o?6w&aP9%IT%);KUYdQRwCIrhVn#OC3VqUjN4h!ule)qa6jK`s!0ITAb~D2Btv%F z+I`eH)#**Qy=#^4E`sUt8@Wr>=ak?$Rait3>js&F)$G6T29esmA2OfOs!eP8)_e0O zJ>rKaJ+jK0CvwzQL8A)1k{IOO@DAhbR!SzFu`)cU{7&f(GZr0+W{wiee0wm*f_dOV z_ZgDy6Y+-C@^SOZqOiI3l*ZS=9xES@g9J5GrBW2L`hwin(+FKuL~+d42>oi0;6tA= zoVeA1DKw>>t(p9(eRO&j77QC$Y(;z-i{;a~(m6^TD4f_$&!bO}2IoqkUSoC~b1A8$ z=w@k=r>S6;LK)vMS^`-SYUN)U>j^THD9u$0IyD4;Ar#hu0gefR;Lox_E!$seek$fA z_XGTUG~UolP67^=;-B=785|lWY~iRl(n9uP$%u^NDN}dkDnK}>R$80Ff&`O6hvS3Y z*%|J;ToLL2zoi*4lekD;F9-+<%hf6q-6%%|OrO^H-?iYTp{E#MgzPe&Rf$_c!0*Ew zgUev0{5HCRz6Sg1r)kh_(;&nx2dkrku*;|3m!8`&!Ub|(woCoH_p}s14J-=_zS?ZM z5h9cRH{K=Z9uXK^SzEP)^iXpqj_v)&>tJmxd!{4Ao~G}*JMgD-kuj)>nF!XtaoA&= z>F~;sAMkOvvNHW*BSn4V*X4YgGN%#DhH``)r}@&5-)Tw)j)2$=f0hIw-q`VGm(2z` z9re+L&m?;J0|6&SD?_X6)E@$ANsOO`ySxs*Qjxm#OQ>XPg-M<)zwrNcf;=o*>gDu% zwF`{`;Vau*UTuxA$IES{f7Ns7Q;Pf2%Pc1rWO3Xtn0EC&FfnZ{2HFLKJV1zKxV`ev z(y9M_9`CmQ){C6I!x`v16#Jg(**lEUB>qu*8^%prvaCI3cq9-FCqMu0SBzF?HT&Nv zy#6F(>&3swj+^=_W+;h%4 z2okH?Jh=-_ zgT~jZIkT6NS#SPGAZ}sy;(Bto`*9oA@H3;{La0sX9}jfFVNW>rIp& zx+sR?RWzfXK81EF#h)zL{&-&tknU7vwLWd%sDZ$7^X5k2P*WURb?*DtNJD9zx@mcetn zz#qTR7!ROsCa!aFSRNYZC!d`p<=D_9kJ1L=H`o1j6hyWpm>O1vS;(;B48go1eO1;7 zWL=Lxi}{1Bw%ub4%7;_GEB17sAtQpxHF^}V!Sb4)y6uNEj0$;ODS=G0&dN|;tAz41 zA7SxaXND-(fff9|%aJ77`{nREkNmrE^s-{qh}Tc)n45;FrUh>axH;vgHs`NS1@hNF zi#6jn*I)QNl4iHmTcyT<2JjJZrf*#*9iEho0(oNV^8V@y%f-jL%gS^C0Z~eoa;#6S zlZTt6bA&oe`mD+N9(D6C3W$9v$=}91!;Eq@;OdYD-HE>zFHBHcJbwR^`En+;G%AAs!Lr2dXy|M=d5aCQ^(oFw6=|$ zJK&h|8k_RKFoxvMD;hwBd@p?U^chY%Ja_($y60Y?KbQY6SlySoRROu}`i9R`(LEd2 zE@kNuYY|OIN(F@HU96{1K^0XP{WRG}pRUE+J`9LL38{nEg1c(R>fw5fQ}63x3IZ<1 z`w+nX$PIjac_=t3^|(|bqJlVfECdSJY^6nI;3DdwSfCfMY`lu3)JM|oz{a%*TNxX8-EKAz z#+t*9P4~c2v5-otk}qS_1Ke2Tkv&~Nu+9;+zZoVxQwvF1S!;m-DW>H4uG4p|C`0(q z{uxA4Mb{;qv)5vE+A85xfy=Ec_M)#FD{+`Q?`+vysv7o72Nuv?a|xHxOfMJ=$S2^+ z4Rk}r1Zrb%TMm@7k1fFX2sy5rnB~EVw^Xv!SAe+24m2_p(rIIB(tii9_1AQEFjvUs zalqZT5{%Q)Kwf)@6U)@uj~S6gmiHPCcBRwEkVLo7XomM!A5(4m2bYqUgTyoiKlGIa ztDf4#!g(H!#GUk~X@!f{k!(-!w+B?9Bzk2PTYNLNB>;{cQQNyQzUl$Xoquwe~7gYF>E#djC*Fp zcASkGz)ON&l<5Os^v^z7LdaEyt5i>fR{g`L>eW_B&+py!u~OF|cDPx1UR90YaYU|n zt|mhf=8(;+sMFNneZe_%;#px!&f9chOA}Su4F=pv8NL)xt0=}~5}G~_WIC?o4Fvk~ za>%}voUHQdx$xIL#zOm1(CZrI{}A*b^1lT-g$V>aFFCT{-d5E5Z51%c z28ACvx(W{6){9dc2R-y5EMb&Gasv4u=v@75kgzyVFiJs5>5R31SMtd#rT4PL)h|+c zWOf*6t3fnHxe!~A5z?P#{bp?2PchYXgZQOx|Fh}#!4Wv2wg_ab64kO44cmP~7o)R7 zp&4~K5g+B>NU}`7QWtDxJf{(HPtm$kR5_U}g} zmJu(~Ti=#ydR&tz$rr?qn26g*CY_49^;^D!ckUC-$n0J_hP!}IuY{JZ_%*WixU9h> z$#sSjpMX&ducl%&>|~F5y049?cUqnU{jPfV>!#o4-NNH1NergD4KEK}iRWs!RHzVZcf&mtE$giX5X%#@4^nnpo{RCX=-1wAil#je~y$M&17M zP(Lv{3$v7otsIt6K3O4mLz>uDh!h;Cqbn3B0V{X8|5l3d{y;p+f%RrFZ1*|z|8Ewf z6t;NJGas$NtR$_!&CRh*5zLWF03qf$ODXO@(~y)WmUl2jxX3^K@m8VPzx#c31MjoJ zE-3z6PdGm3{~_<)n&c>wb?qMnW*~$R*uJVRm5Q3Cr>7A@Bko$IMiK}eIEcfq-_QHF zM`l)c3p6upt-U_`Y-~hzW*j{H>>eJGwf$s<&Lg-epggXeGpcqnq;qzr8L~_H z0+OWU1w}|YI-MV35F<#qdXkLJ6Oen2LS`i$BAbN)+z%FQ@yrA`bpokcU=Y&b<57~d z;1?}74L*ghae;>oFi4B8=x^WnQM!uU3ftQ%+hY5Lf%0P9>@WV3kYkN0IGoOSohKuM zL_YdOXsfu}4UsxzSV3HAg^m$XO6<%}_{VD$aUkHdl7Gqh>FDF?MlEhgE8hrr0R~iUyPxF&p@p`HeH;yN=GFG5n#RR<^lk zpsTU;egUSYiPgpCY^j-m4eqP7_E~d6w%~Lk5AFny`z(cXhF@PB+C_DzvTdx(OPTc< zaWR>@3ksM4k-<>}Hb-dA8`mG=JSHc%^a&Ug^d_o_z01!wCy8$Rrwieq@pt&FU&;Sc zkPx6YGIq~g$Z2iAMLX8bg{POE+Z6Yeq=gf{E0&Wyxwh>6-GG z!%@rt}a|zG#@fS48AgWV-RUT9F|V2vFhmk)zOz zho%<{7|Z!+T$IeFmv(PAM(78rFmm+G>dr+?S`}plr2qz1)lI`A2>&6fAPS^Bpfplp!wRM4M3*!}3M zx*FrPzgOcC%wn{(s^<^6IRG5t8{TuvK0tj~y*Zx2o8)Y=8652WB?TBi5h-rnP@Zg;EQ&jkYYOpN=XfGor!4@*EU|7z9-;OLCiE%8uAW0gi? zMA`&1REN+?u=nDb4t%t{eNk%Xt^cauuySUt*dBg`e-{4-ek@Ve*YuAgUZTn z4+zBYTOcLP!sddi2SCrO%L!85oI`#9TZcL6crJ}Zz=7y*>gtcGAG=r5TsvsZAW>@> z(yrX8!_TnF1Bkv|eN{%v&~_m$OT-q0tYI{bR5T&-G=h6$2=1ZK{Z|YVZtU4IctL~1 zQChTlS|t8Ch(8b6{|`XiaRbPZSEYn02T%I9|Fkig_7xGWzMt$u_e-9zQR*wLTtIIx z9`tf~GP`MLXdky%6JChI*NCZ*-;|mw-F;dm?7CpymrWV)QVGrED?Pdt|B!e2H{^Xo zwTnuLe-;&&1@m65mR{Y+_2H{b-vxb3ui7^(Wgo7L5=}*cn7F_pzOrCq=Fg1s_;|x* zfT9x%MN6LGXX6@$jMcnqW=f~=BP`QG41%oc+a}wl2(4sU|o8@ zUA|v76+0M*AItu)@1i5-fqWZ=o4RcBq0hHBVEsf6cDsatE4`TX)mo?HKcAFp#f5q* zoB6w%FQ*`}d`m^f6F;Ic6d@e9uTnizTARA_OZCJjt6WRey-du0;-o7N}W|!WKaE>_<8RhF@&hX66=`!~n(P&Jg`2cuX+In>42^ zfqz{V3fum%%moMQb`T>0YJ?1Ul8`g9aog5>&A=1yjch#4HwQL&j085L#6V01cAOaJ ztN3N|2jb&1;Nov0?R;df zLa)WFfIQFIgb}MVz4PlTiBKw#XvBM`URt+jl2l^64b1XMOVwA9vw-y~!!Y=7wJGp6 zmI@FhhjYyIXj*A62P&9_`(i*#rCXI1l-vCJ=u>6AGagV5#~9IW>jJX+$Ulib*7sS} zTGkyFXEj9G_{yfiWf1&6)MAW!Bndh&0_2-_;1yo>2YG<+-sAkeJI}vW)F1=_JJ*-- zuc2AzTuf>2F6wlG>|_hoe( z@WwKeZ-61UV|u8Q%e}|3`C1d*SkqUd8_@0OmPfMw=u4dNP1VC?Q=ZVeZ zc#i(BkE;3+!m*CiN(mG{aJ{H-HN6kzMsIN13xG1sFq-G!G(kcrQ`O#jn1&g-cFmzu z492Yfj(b8q61b{pyq+R$L?Zo=3yEg&KK6>Du`Wr@bqC)B-rZ}}-QUu9N9xg+&;{;w zsuGZ9+_9!sn30J0Bw47Gb2CBQ2hCK$s)J`N&`JVm^e-=~>bbK5Tno_3^6}PegXSqI zL=E?XHaFLdMw_mPyWrPigyHJXI*VU!^h}8`j@L|^R3vyahwFNMRAu9-Zti{iBny!X zIDl(dS=7?(1wg;)^>%V{g5|e0HQ)GlUFN1knVxL1j|Kgac_G0ANYyma>r66bV5%MR zh_c8<+XOP?45=fVOF`IE)Mq#`(xIf&dMSY`a1 z`j{(po%RLPR#Ena8}}DkOz@MNZf7)(hy6L9OsTAxXeBMBh25i%$n-Grs4^f+nc|?5 z&5YdZs{5s?wBQ7>^bP$i_fJ-KhZv*`uPFr}dAj{#F$RX;!seEycp6s*+?gZp)X;jP zXw^mki>lltTqBFMT!yu-wTA^$p{mG-k43n0A5I!R{UIwsrSFb@l_}5JL||FvVVG_H z`j;SMbNBFtyu&})m98DmVk#Qb{b2zRv8arYW8e2(rFE(qVkIKO3Dr~P(PDOF7SP8! z+p11fj)mL&Y#Bh!uGOg9#47yHeV2V$L~qrd9Nm}INg3+W<$_g6%=uuScXXcqxd%lL*|BjSJSw7;kg@g#VVZP; z+@Ej69Vi8PRhER2KwQ@MyDafv$l9xO55Nu11-0S>4G4!8#&(Mq*_7L*wqaDx#+ssS zXB4S_Tbw0MG+O`73TBgC%u}EAo5Y`c@+yT^E-b_!BmZ+eXS^k+Q-%GGEz3}IC3cYe?xGklmiZ-v0J{H3&v>k^7PFHt_asoGFGbJ2(%ZBlwV2tYJlee^a zEFk5ljn>OyLf!G4nR0TGQo=DwcwzCj8Js?2wh(SK&SU7r6)QdG|L1!5;rVyPR!#&f zWHAnfg#*iU|af%|{en(Rfm zvvztOj!aJ;Er4rXUnCpP!Pd=Gxqsc@>>5>L4%vlPMqJbr8JyHow*tjCzB_lOs#+YOVqX;L|9h%p8Et z&4NFX^q(Bn*{G|Fp<~v;RU2q|2IwF;PPA2n?KtlS1mP;$k$w5pk?W&AM+m0iZsCJ|9oe`6<+&Q_a8H$6-4 z0>|`}Gd5utL*V)a)pJKqj;(>pq&+G^mWLk;b0O@q2YQeYjFn;?`R*|Du@J_pq<~X< z+x$VUlPpA4n=;|9&{heMefYMxN4ELw%@N@o2wS<8|KwLJTo6gEK^j(FL7#bojwQ8D zdI2b8^B)Dj_sCYzd=9vZ(-p0j+bqF;bLJ~crYZ|l%n(Qvc@pjJ^3)` z2pXUq`^JIx7YTgK?h#Nd4gr+tJ-Zwboc-2Zam*Q!waJVhjv#!{kP*OGR>P?LM|krX z?3br7Zdkeq+{H!Z^QUeWaS<1lu{^EJ4{7%1I%XH~9cmYoDomh4+X4}RYt#a#A> z;kt$#YW3!Tgq+g1u^rP?2LBR%hZyAR>iAi8Q({$@omIux-1}DF6IZtJhDu8v0CFbC zoY5%Tvj#=1d{Ge+?0}d56!%>vHmAsWJ0}Exi!~>2R{0!hT$_0%`sI(z^SQC}YVrWW z5u;(|UN$5$?JI+>9ptx3H>Tsv)XU*M(j){PK1q19ArFf$=Rx}a#j#NN&4AqRIao2; zQkC@Z)I%<^*vlit3;3QK!3_KtW>SaoMWjEDC@7DNob%$nKbF4myqM_t;`X{3^%KSF zq6VE9Y1{gmEhpx9NRotn12@O@#Uk9Z#IuWm&c@MudE9@Dv1t@Fvg>kCTuPoaK>Gp% z*;B?k6XL+_TSY7}3V_)A7W@hn+n?7}8rCaOe33ylp;j?@AZDssOC{?)>eYW{Uil6IGKAO9UI4e>rTcd(VKJVunM@6Zw9IB`YFD_L4 zT_qmMA`@*xrDZy$95GqM^ucY4oanvL16UKd(!N}uKnE5tvh`Qak1u!b zL2x$IH8`^$)ojo%YHuP0Lggwv)vY2ef{Pj!fU8^WI9v=j$pp%xTV1(O$LSZ7Ir&Ac zIODT9w^*6_3Wjz8K~1t`55^+SlX7Rt{0)_Yz_z|C*4r;GM`%fS7 z0p5%)C_B$6*8MhrjdVg3(2N`iTYfSdS3e+}4!inEuGvPwgJH+M}Rl~L59?#ALv z$T+cD6&UxUU+_0#!W$jEmFm;MgwmXHp?|2JBhm>ORB>rJTGepk^kHvRf#uoZb%}_uqNB8sGYXk2C(wZRtgBc7#?tTsf4WsG79msW zyKq@mdU>en*Q45he{xio%}CqbMgo4YenQS4;^5LSZa_$=!MM3!B!qU|!e8m(Em08N zGQy2dGB$r`^FRKC5AgtJZ}itLPexKBg4#^>lQek1byvqbNLzm=eKDm7~k%YMhvW71J;_Fiqg~YBp6+yWj zH$?kHm<_;OPj1_vNOBbye}x+at&77uci4!_RB;B*W$W4!Z1gY0gV9gN^elfr+MP!O zA#19o&*go^U%+h=AOOkkRrbMS_BnKv#L?&Rs=qJUObsZ42^ysO+Hu z?vA!}@mzC%Ha$~T_D*t^q~mtdF{GooZw6uk8dvqENvUl;yX;0c4{YvR1NG#nP_pEL z8=od~4h(XE5)SUlFa79K0+m8)BnC5?|8zpKpg2ryJi(6znFI+a7Vh9;q~ARUcHcsV z!TT`Nlt*BAlH7D!l~bUTnFD90dp-nvA0CyHXwoI}BF?252N=GN=DCNqVJ;0?30%jB zL1tEf?q!MUvp(yk7j_+u7^+cCWw&P@;6lxfE}myytTwBeV@%m+vj3#wm(`kii|j7=vk}msv3+&9MYlocsyiK67A0 zFLXl@9jF_Ortz10MC;aY!hhXvB@%wDmAFRxsdx1Qgp#*?* z^%g!XEdzMLlFQ-}4kOh~deGN|5U`I|IXp9bxN|bpZj> zwZqK)CO?k=FWlN17U1?^WP@qTf>o|u^Z}px$*_wc&xz{PH?S&Z86mhhr{%V5@9TEK z^ksV#hUtIcE)`}C6X#+5cPIa|pNszUNDjt+= zcQeEHtZzv7Ev^N<%a!VZ;GR9=uua;*k+~<48yQ_^IIEvdS6{{&|k=l8d@QG625%+)I-By1hP>%-avRq)&(xcDT+H&)fqY#Aw_e zy3-Zm*!0Jp+Hau=5#~%X0>}EaLO&7!a79!{-x|@61DsFAV~nWC<^$lz|1?pt82sl- zCpKHx7m^q%8|l-4f_UzhQ$VCfbsBJYI3OD9=dsu*C=%!5UyeW@Xhg}7FYr#Y&9`#L zbugee6rJdi+@!Ytfb$s)bbcO;S{HUMk&vzR>OTGrGT2w?ye`7^_h2jG$YyEW1^-d% ziC}%en59s_unz#y2Y<)y84qIaQs~Oxb$QtItGACCMSB4D-nk7V?A{X7eq)?b1&Su` zyrfEg*=D-N3dczF=ExQsAHeUQkJ|8n$qocSoWAR&4_o4ZX3a9z;^UR$i(cTri#?Vv z8H;-<7#l+a0MfC&_7m|0 z+83M&7%LK%B_^DKt@@>FfPKZMt}g-GZ4ZGhXScUhy2Syo(-Hr-;||<+JdeVv66yjG z0H%`!@J}mo@QOAdWA2C-VmqwRcKZtc>6Y9nRB!hXp70msn z{@#l8Xtw@VTz&UdTjVw=ykIx*B`@GuIw6+ntq2%XHIE+9*QrPzIOr9TkbmTrtmUxP z!7wwMyt$JT>Ib5}7tykeO=vNThf&P@=S8GNp7~2AY8{l!`+D^}*HlB~VggD3 z!{>%IoxURbS`Jn1a`ZluD9%h@M_-gyCL9JHoCh>*@eo-{A%y9tWS)3kvIGtQa2&fC zP@1p))nsz)GD9>oW-v>%thZ}Lo`)+&(6p_n+2&_b1#yl$g#2Us?C4uq2smk-kB<-t zp(9xLlcNMc#`ORN!1M;7^R&ga)~v1YD*0v}Z}RLzD!ta!MVLE$kSIpUVwgZOn9n%u zUY#~uGQu|sK3G+7+rq{+zl`q zo2_wJ<{74y=(15xEAfH_Jm~d5+*m$0$RExW%jz%5MF)Iz1S;Xc@e%d4sD=|I7Jz2n z6llPc!7(4}03?0t;%9QaJ^&6AL97%4z?@*qP?pJyIYxg;aj-Qmupo zkiXO74opFVwuoT!Y;imKkM-dUcY+@WzUG~vnm^-(15}%CAM$6{MJJ=VfOwmun%ZCcfMqvb+K`4rrx$e#RF+)mKTft|pS9FJn{i-?`;#K?OJ(|& z*Q}TOX{daf$108_XAb4vOe%N#2RpJA)g&}l)^Q9j$-0dO%$;Y|pA_M|I5jPX-T`V# zpL9!EY z7VkIb=m^e$0Oz+gWEfoTvC2LcCfqK^K9jM5fTfJvzh~l$6%rJmRe9z2rPGe1%PUc| z44-U_z)|^$#1u>tB%FnE06Wx%fRqL|?R~GdV4F6JGG*gG0gn$JjmOKw;9Bc~t0KAgQ}D}5qq2(nXF zG8LK4lf8uxZLsi$4kyD4bJ%68OOpZ8^PxvG4iP=76Ma@x0QL8i>g5{E#u1mis~&h0 zvu!$Nv?5_?co|oOO6vZdWGf6tQ6SrbGd$Oc7Y>@&$Tzl}fvpLk?IUVlKFY!Pi@sm5{LZTNW8`7Ii91w6~dRn4_K@r7Ebynre+oUj1 z>weqJUpB$>84O?A<-|NT^ZA%5#O!eMAWC0Ir-YD(8d-v~VU3w!4I-?*QW2UpbF&q9tD(Rew z=fw;jS>WEz4oJz-GR1TLr+An3MPG>rRS}&Lw9H z)ZTEuV1Jv|}wjv-S;5|CzzU7#CsX8U}JXN}OBC<$cq{KTs}Gz&vuC&XL7rzh1Krd$s( zr5Q>*L8>jbLPg)2w-G&4gM|rrwrMHHA{YY4Gr-XcB6+D|x~M^&(*y(skBe*m2LrS1 zXI=i^Cwrx1AB|4PK^aOk!!RNspU{fGQ(_VPhV0AcxBN~ER+6`ym3bjQ8Dr&BxutOl ze=K9+PWC-Xwf@o#m@AFJ=B=xZzdItg!2CKKXEngn`s}7(4En0h+!a$wu)usUa{$K< zqolvmaQ*4bbK~bn$hk8^^V1sOTT!I{`A-Pnfk20Q7Z@M(>HxO7vPqv3t;VA z=AV_;>zquIB7CJ)k`!)S3QF!P(|8Zv#nqy1Ml}Fq`MI(xgvwK-*_xqlr@C?+{SKJR zj~-D3;+W^|R_kxGRye4D==b4}XPTPhgCPtD zPzA&6Wd=4vXv|`1*X|#GxBB%LtRYx#|IS+i)JcKER>6v@JQR1die{i8MuR!(lGi98 z(Qn(mnWr(QYDWf)%I0Y~TBd~div-vo{Uy!?xT`95lCNtR6GPWPoV!q9%T9FxKyRfB zl>fwY3N|W2Ctd(laYx!H>%gw_wy7FhdP}qgSlshZeEWTlz0mlq^9cToG%WapL5Q7v z6Kmc-L0=CoGo?`m{`oSBbv#TT95+B!miPouSkwV^%8MBQJR#FJlF<}5>wNk4??~Q` zn`nb*LZ4Vq1r7<-!O%5`J=U0Z7!{4&Ap$Vry|JXuUd(RQcJ?VjfH`F3^EK>ey>kv&3sCSXlO6}rM$Evth zR$jAvP8 zY__9^|E=aw4&!;%is1%@;D50E;4rI;5_<#|(E4C}t77fFgqM+@eCTnYt3H`=s{V>3 zUQN@gwvvaiOV*|=Uz?_#4*(}co!pI`U_U*MPTPv({3EM*7Pzmw)LdO(N$|8Q2`a7J zlzjgj29Va~=bw^pM1f4Bz#bPWgleqs2aL{;XT zR|+c}q^-X!7G}=&N&Gc4ctmBXN7***F3H#pYpFK26X5NYuSU7FA69};Yu3o7zvn;k zo0eg)f67cE8b)0t$0d>G0Q2LH^dE1d-YtHZ_U<k|B?f$B03lCYJS6|P3}&zn#2 z$ja?VF##eD#QLIh&Xq#1Wp`xl`U;a2!;=3840j*)L%uVgII_Z|ZjwK2fgC=R{t5cF zqs2wB9hWXTJ%7TQ{)9a~Rjr3oo8P?Ih_%D!7a_Ni=J5E3OQY*aR(ue>3CehhT2fgJ zDT)38A12~obCeZuY2PI2oQu&pidREJQ5e#6CPVsMlcthIXRj(^tCj0d0v~4uiYzgg zuMiPH)?3mv0an)W-4|}8tSE)vtbX{S?mLh>hFg4*EX;Gf% z76QozS@!0!ASi3SoZquyZ0)=;Z_6(L)n_8XoSOMTZ6fBw44GNM?judqG^5C3S2*ea-npX z+=t&>6jDpZE_^gK9i2R)Vxx0^a_A!R$;puQbc_8o2h#*`OtCZsA8x zQ7i|Bn{A$N9%qX^M$Exdz^BOAJ`**Pw!Gq1`?Ph;UIbUplhqJMgSPVYvZQ z??K+TJzy#rK)$(%HiIEBn_3zd$Q!KfdiBRy*W8(z&F1oE_2F4roXY6V_RNU!8|)Bd z&G%;b&*FIJR<;56d0Z*c5|IUmIEi|4RY`%GaP{7r%npV=ebr{VTM6>kUjhYB$CVdK|FrT^*Wh|e(+f5>tIYJX7&O7 z)7%Z_LvTYt37iR5=a0c^TQoo-S3SX)y(lR{vy0k}aHIRUOiLV))bd19x?z0Cvit2j z&^@7I+?g~RBpz^>jz|au0Ip8WJ49Fmd+CLtdK63!QvsJ7yx&gK!>RiGW3Z(O@fnD$ zL{8JrhqECW)+fN0ln;#d6;%D_RQ)|S z$a#;qIF`^lMo+&?hf;A7xkXC40-;n~0{{x(LLjJPnhTY{DBf0(kLdwbw|5c#d#^Qhd96pp3!fO40&Dxcp?XgfUf*6ohO(^2~1yy_rT z`4r}EdIwBvw_FF*10cGUm9mNesPGfJoJB9ad&uyEsrnR#SFv5kOTQKFy@T>Rwy&>n zXj_!ct|r;ig@zX-S$9KdkzeO!LFOMk7?R6sWR79yS0AyW#4wPh{ zMxEG!q%PDWOwAnXxrxh99;>WdDKtQ!3Y8U4m06tJlM#W>Vto=&%uCk zhGashS&xC!w5hD(=lVkNbcu;ym7_i}B1&8YHTyD;+XH@h8Hj=~mk*X^r3tWZ8fBFA zb8fJpo4T+q6u27$AY%{5007!+oChG1{w|zIs{T`0kCZ=jCgabl%kxJEN-)`NPwTcF z{vGib#wfAzW?7?f=-P#W+AKKcWFlC!>do;~M~U#;?K&I-ReSDdALf+L1(uYk1SEW4 z!P~Q=_xuy{%F4Kx6PneY*afZD-%7svS!Iv?K%m48QvbY4w=Zh>J|(0EH4_6|5HK+5 zcCm#-fe~x4tliH5=7K_+L^z@#=vqF^IX`1gpl>B-Q1G;?jq^|Ztw7cHsmXF`zSnRE zY0WDQ15t>aos&%8bMmvfujw5J8M@VGuQ`b3!+xI=Eg(!YYP#_sE;a%2yHt)(Dggw{ zrD+XQ_-a|p4X*1(5oaq2g~@q*$fG%`IjzZ8oVWl3R|Vp1bxNeLUA=km?5GldtFhGz z;?|Y)g3a{1&&Pp#+?~D6&{t9;2r44_XNmv%gQK5SEvG*jas49-d6>7hdO<}tvfKHS<%k#IL2;(Ld|e^(vPqpZ5ZO_1 zEuswguWVLaTJpfib6gl+{nZu%_Y=%3?o`87yo_A5bG^0I&v){@;RC#M+vTD`^T@9) zhg&wOq=k4vHr1TV-KS0S`tZTtf`M`H_tNVFo{%rWy>l!!CG<5hb8s*2DL5?!)IJ3O z)Q0`Px+{U+65EOzi1<1xzw=JYG9#ZeX+8hiGNZv zkz!K+zKEt^9+El!J&O3ZkGd;>Q18k+V{!H8_=sSSJHX-{&7xyCAUjVSMKsc(PWrFp z(3NAC6qP%)ZJ1USq>o zmuD=P(_l=OYxE3j1VwHq^%3u4hR%_NY{Ub9%_@O$G@~E z^fpo)@H2SEL^wP!6A8+$;eqL%ifjlJIT*1pTDXq)KeK{rOp2f=90hu6`zw9a(+h{0i1b20pk0EexK@Vc33RHwjY=kwwp%Jd~HO*1&MkLx5MN?LLqXP0iMpup;&tFmb# zQ|OZi1q3i1adGx`D2drw9D4u&(Flt+=OJRov$Kq?0J{}X2FnVA-sTNJt#cKTOdupV z704(S9gQ8gOGE1htA2p|+M&lj;Nj4p;!2L8NBQ?W^gi%2>e8fzNtRGUKS|uH|2=oY184~`D&Bsa}YLw%dq9BRfhcEf?Tm* zK<*LmEb@Onai*aXPx-_X>Y7;rwy#zvTwp zjXJ7hw{w+XX%2^T=Y%j0Ekq46{CdwMl7%)Y#FptNRU|u(on3loQq~zy)(-EA$mNW{Ad`w%4?k7m zO%IVg<*oA3cFMKWmfIDZvVL4XP3g}E6=6^u!X(wml39Q9G*-}k1aDL}0;}{)X6su6 zB}6LbHpUnSSM<-4nx$zB|9`|DCXD;Ofl5zJ>|y$5{k%3A-kngpZzV~S5H`F@671q2 z5#P~xj1azsB2lVRq%ZnXNZDwagjD4%122TMII$pZxr?AMO=9}D)BD5H3g@#v;EHB- zf8bC+th&@$-$%>}H#zM-ye1pemSSpwh!2|g7y{-(n#$*^^34XHOu|S5M7A|vmlvhx zc}iwpb%3?T#tqHzE=q^RFO|;YN$2#PilS>DQvCDZRXOy1QAxNsMY04)i2b@%b?zAaj-} zY>DV9<=|#bMIppW`1hsF>npGPnZA0*dpotG%>3hsFoN+1j_|e35EMVCk*q~ZlVqIc zp3Su3*dB9q2iDwbBSXW=W|svw5p`)HZKm`P_zDr@R(6DOl9g`dz@E|tycFMR)wc>- ze=1D%-NG_%l?^?aO?<>E_gS*o8rb>W!tLBVQwA&96ds1Tm5XO}c6EY9#7UIBn)IHL zU`5A`6JYE3fYClP!aBAT{Jyg7JujzYv_>GaMkC^z;wSCuB$+g=-N(TF`yGrr;6I8Q zM;FpF@#V#%*~OJ|Zn(LW-l1aWY9Gge;ct5lW|tdaR|#IN&4|GQ`(^j~fwE-1)ok zsFM@Vk>>pM&T92xr47&j&&$;MZr=ZNy}Il70Cq1@WN;Fudf*wds@+$AEpZ{!JeW|y zg-O#|U#kYCeud>r4DD0I({-y==&mPcRayz6NHyu+pAr2}aB}mGhc>DZMKo3gRLI93 z{NR_mytgX~aLtH@8h>OB3twX3!Tn4y`R8PPwJ z#5i8p@{+bCuK@z<&=2QzU=HtI9jAccu6USn?1E?av&$b0n&D=as5q+Yq7Ge)vu*m6nhtyrEi{d10Hg}NcYuYrC7baoHwZizP}Y5XAkW7 z_Lw>u2|4Q6uT(|g>e4v;F{LwNXoDXj`D`_rwJT+A!8K}Yy?Tj|fIly__9;Y~=vQug z0LloQ2QUr7-xsdGi-cq_-}q0qvA*|iU2rZ<=XYXpFy!qz3MWZ^I9hy-hsm%!+&eQB z^g4o;Ka}buk`LLKUw>n|1p7~m-l!aqY_SBbs|cqc(PIYx7?RQS!#ceEaoLOF} zQHB=A61>H~pPr#27`{?wk3+-pAWvhzp5FPu?ZI4;mo(dssYaeH`oJJok-7hE4YfB_ znRIcF%>$G$c<93b%+iNb{RJz#W)HbVrwib{AMZ-z)~cvyhnL;!(hdA+i0dKBa4mI9 zbBC@(7R&HB_x*`P+PCKgHYx(9CwXlxwUJuY7S(NJ3d6xq3^74uh|urQ1doH-6S@4p zC{!&Ww5|>YQ(C8!xZ78EdN#w=6h>57KN zqta1PB`gj@%X1Dd`j)AyOi8lKQm8HU>&L3}W3o_NPVOkkXSqeJ57|2WS-2Lgt60U? zFHG@EO!wwHzUs{!xs4m9=-=5Kg!5<=8yDpn#5j2cu-%-;Ikh)o@eHfPJd3}bxTEKa zDO`{u0e@sZXzEJP7`~%f1waDRb&y~nu~~KSwuseEHB#S=sq(yXH0)Lo4;F;f!Q{4l zeEoP*6`T)wF}A7YX{J|I)<0@Dvu~f3&aFv&Y0jZ-K3I}v>KJ0HOUFYovf&=o+3D1P zAq!eR_G1pZBO}Cfg^a~nG~h7p3}5U+dHkd*CPzeJp&Nzqju&$=OW_atZocTBXt+z) zqitUhiZlK}Y@@{Tu>AHeas+nHD+d|L-?V^eKecjocVPcbtDs@KykF%LkeZ25E?Eh` z@$S146S(dd-FS@Ggn1}2=C(NUK`on$D!o z|D(s&LEenG_K*SgY`kN*`fTu}S<0Da@Ey7L z5N6Ns6-7U@r5c(yAGDSnx*RO+`CYU*t=Zq@`9d~{d(ovIAWEQ>b8NJ!53w<7im)B~ zV(9G23%Uea@9Q(sAEjr!(SM1e_GgGi#^+{JRQUSn+stTH{Y97&oim}FE{m5OJsVX; z@;=0FgfZr_&b$x0Ei_Yk9X(iBE;rVS9{yOr+AjFA8!^Lq|GYRfJ;$k3!-5NC0GWGO z%^Iu9m!p#!MvpEw1QDD5&ZikvrDX!i6OS*PK0qtTmUb%Iqd;YrB0Wo@ zy4{DUOnFj2`Rt#Wx=(x-`*ePGK*@%x!z>3(ob2D~Qh^A6SJ2>1ljD$E6cS-GO&*{& zYzYs;DjBoIfd^k!oBDEK4W01?B}#ZV$}SuD?V~YTNwHY@$q5YRO8z_%`w&io{DrKQ zVW_>w1wR!9r_?J?rF8^(+hGZLeOgL>9_bM=`-QjR~m$0Da^~m8}T;0@8kwyR9nw(XV+{!2{!f4IK))giX7r!jth!I=yIp-%QC)>ctakln#dH}R13_!gwtPtrkl)8OK(yQX z4yj%q8S}rWJ-?Csl1s!~7W~?FX72R!;xiKY3(^$&2gTSXJNXY<<^f@g5`qy%3x+`p zjNp(j*W%LkicagfyMTWOMy5R*kD6f|G~8Kp$l_u!FOXNd6q2nC=FDA|o2PP&;$O2I z^Qq8`^RkyepuNvNDj_J`f%yg(Y=HjFA4e)6a?hDS`KmCZ4&XE-!Nh36y@sp>pomYy zxWz2+KO9;pSVZw_Pyjd{)?m+B(8KSoRX#L6{Uh$H1y+L7kdA%~k-<5S>hVSb9 z%Xx-=)b9YWYroR1Ct}W$10{IjGv+2gUPUZ3L0!95(MeJyyGIlKR`;72jOD4f{rfqa z@}VmAhM)gMKQhQ`&fsi0uDbIAPvgPT#sX-tseJu>Waj6*@^lL#ta4WtvzQLlF>LXL zdVS7|^*-l+MbG*s)kiX{nR=93_)tvBERPL5+!OtWla^oHPszfdMyDPzg`2Mzit!tZ zlvekvHds@&s2h1tDEvxQYerHePwS&>_wj)zmMbK;jvNTTz5dW9)||2N$a6Kz&2pIU zV?r+t5I>YeOK^e6@Sn!0vzA_oU&{Y8Sn?aY&q}lFzDdw3&}6TP?&k}>X>NfLUaTwS z(fJ43u?fV#;`dG6O^fCnY`sZ>b4i53DScBTRtzMzFv2s<8t8WSW0J6+MEn>7# zxbLJn52dNp*)t;wEq7zD@2X$Ugi-w6o4AjNA>ZCykHirHbl>PREr^UHh&$~ z7*>BmAdqKhF|2wU_0!>(Ig}tnYE!iFts1HmNv`xFDwJ7TV6K0K%@L$ffZ@b!#uPW9 zToeFk3O3evM(fz#wAFeI=ef|PwkT8!04k(a=XkjNGT}KjMlNO|J^6GYsVKX22TmT1 ziuV0l2>EBG{hR#6A;_W?%DuQ#J^GXFBRbrp@R|R}O)1aV^j_aDK5qLiNEzr++Mb&= z2^C%+QOB}faTwRJsvZPyxzYdT#SwqN6D$4lUtje1us^az<*61)RH@!~LIt-kMZCAI zb&6B=-j~BJ;jY+~?@Ef?YrIdW03-|Jl@!(bRX+8@GWyVC*48t}YXRWQ?bWy!{!~+o zpVJDC({b}E(GjD>SUVW5tLL?9t#EQ9A)#Z$qSW@oa(-$rRNAN*&LzvaIw6Q9Iv;bv zRDDrF?Tv|iVW&a|P$T?Zn|wMC&W&1&h0&r9w4e#M1@SqZ$)g9fEa20SlK(D`zw<6) zmfO-?;)UM2Kh>@N+IP?aKI4K^%lX%y^qyA2T&kKXSF4UncZZ1AUu^H6bnb0-|3nKU zNv4@|l3Y^Z8y>dj0+T;CGV%JB^5d&>8V3|!QC&wf1Gee48hbpcor8&QXk zR9aZktXcdvYCG|>yExSZVfNe)WAMW_YEKQ5k;+J9x!xhC~5{Yk|r+nLvu3 znL(QFIu?sjrp5zI(D}8vD{g?4zabW%)@?6WVZ~1bz3q&Jy^S6olCBZ!0-k?bApmH5 z82*A9uQmrmOh8=}6Z!W(5>eiJX(QY;23;P&fJ+Jen6naTa#yIiB_SUINThod4$;I| z8@0sPXCFI~^%| zS5=s(0(RISID*^wPYXQ`!|kK;G9sMM;&r|JZ!$NJWWW21BzoKCQAT zFQojVZcOJCH@e78u9lwh6+sOOP`yFw6Dda+H;_*^@NPt^i?ZZ`#`WsdGbdaP`&VlR zpyQgMSeSkd6I_A|iA%w165Fm@s!9Yfc#Zm3zx0RhRvN+AzhIIB_sha`aaKfnrDs~R8N86bPO6cx>w+wu)za@55n7Wn2bxx>&y6xdDhLn0|1yAm9tJd z4d_w<*6E{w@A3`piSmkjAjk9eiO3RWAn!vfZ$>u3dT3Jd7_es{#p_z4|8~L*YVF@| zMS&f26*b*1+tH7lc~?7U!A51x^%|_bPbkF&4gTF?m{j;(iv3r)P*7j^I1y{I1Lvw3spnTg|j zTo=J4^S(+Yy}n~7z1aA!4d%;=e%cMBc1#aXoMYz^3*KzLV#vp?ubmx8Gje#5 zHj6cj*(0<3+Hhyh^}~{Xe3xOJP4V%cG3+01ICf3ja(R>kT^$zu{qFLzeN4vuUjgZ$ zFKa^f+1QD^!clU*)3eA9oYbm5^B?ho#Ly{-Xo-c)Nl>g#DnGKL@|Cf!mQkqKlutTq zaEZ@n;^nm?x}Sf+U@gn+^E-T{f!)ely7dZ5IE1jbd&Jkxzz)|ginGtln{Gg1sWhdy zfWE5CB_S{%eDi9}&|daaMUs6^V=9(@=xt43m9KbTd`4GI`)|peAZJ;_Swb69O2Sc_ z-2EKa2B*d6y=g>psAQF138$t3`Zl*{bBg$;{`eUYxYECpGa($yb5Q?*{vWnb`!{MA z4K=?O`(sc#I za>A*O1U7>yVewY=Ho6$a23pMN)U)pjM{+T;iU#y)kpD7jp2$6NMgqm>`=}urhvb+y z9;(GSHo-XFGyZtNn;}&dNwlRF5ujVOwYmR(xdbi>Hk7~FL%DebYe4oDiNek5_nTzE z!j_+i^JhmsU9}|;DtP(pB&v7_Y$)b{&wtV@fVJjSKxx}ig@3vrpV=5b`z)Ud0k&vs zf}-{6*3Ec2fyd!Z%6t-_BD}_!Eh~-BIIWC5{BV9&L8ZOBE`x_44q(eXEvm(Q%Dh@G z))ecMf}%`T3Gl-Rk}XnqCsz*%K%oBD*dGZbG=<=Lz{|EPkMh9k)qj-7u?XFY&JlY& zrVP}LL8Hl+XrPWx-K@SBb3#cMIQyapLq9ow{74bED?_Uh_Bd`kFcq`AqpYuTzbxPc zo)#Km6p#cnq>df?;g}*~!{CyVIVYSpQ(euIcH7xfF=mD+JO0J}CXZNQ}8dbYF~3WlZJiBc9S0n#2pB7CvUJ z0yFFsa#Bij(w2Nhb)L?~BH~PSZ*FVo!PyfL`HtVG5-PX$ZCpx3_~XrbMPr~GU?Zu60n@B8x1+rctS>#iFc@341$hetI(0A zv$6lMXkpxvZ-YhMK7hCm8A&YwY$_t!MH=1}xn~{&4d7zfd>fK2y6K|OsJ0;gq~X$I zK$hO*I1SoAdZrwSPAUpa)k)Lhgu`{2_C2kgsoyZ$o6n92u>fUNj+BD_>u0D|LFoli z4?P4;M(;wWCQ*}NJuH{3LiZ`fkUa%QI$va4;PUSS%SbxVp>I}*DC^7oqybs`IdOnU>$Vz8s zx;PB6(8b@BV^Q;79Q4PF*-x9S11Xn6w1Yo;aU@{p8G&;8BqY&G`0v+J%1|`6kETn< z1-gSa!X08map6C7yOguB*lqIiT6QkSN+4Pt{%8SEgBFe9)=WhsCja`hcGVYDWVOfy zwg9-u$+4x(GzjEqSN^>#!@6T($2t*IroZByrY3-S8*TaGRqDY2+vl8^gOL^^EErt~nkGX|HzF3N*`qdW9o{kpR8$~%?a~*uMLGH=0;hxp!4Z{4%pXT|QTq6>O1yUC zB_Oz4BeSP;65NU+ZRz+C;UbsN$dXIN~D{sRh6` zS}K;xiCaVEm8(D+SzRah(>CtS?h6rfFHRd&14jxHZjwJ?g!BHnXpj(+T?|_+9#fG` z8bIU?YBQnnHL)Xbd|#QfAJ9>}2wgVp11>tq6N?T+K{PC_WFqsPVweG7g7U-XZ^7i@ z=6MU47%62cZd+X*0LIwiY;vW&n4#@K0%q%?pTbT1{t2ry=2-dqCYTiq`^rjR#u>A1 zk=exx6%1EM1qtBd+=XBoB28@L=rm`k$RMPR`;@W^6&FlVIhap^n~g;5C;thT)UY6= zq?~DMi)LFJng1GpkokngudW(Y<1PAJ@9%{YZLDE1$|F)_+7LI_BAF7@^F}FLgSwinJ;*aFKz6v2jd2m42Hq zI3>K5-lu$GQns>!c-1iS~;ml_9E zL&os*e3I)7aEdk)6aE9ZH~AhaD(%k4LUYb-SOSlNT^!~eZe`&8N$K+h)U=PbBk7Sj zN3HEX2LS~i1yPP?%)wL|DSRV68^MO&6!@QAKn=PKAD7N_R=!wDUJ7D#?-N|f;1yinC6>4E~*Zhm#SlcC{rKAZa@~OVW*xV)*ukFM4Me2 z+bFWT(kQ^(BM}^A5ww7;J*GVWkriBTzQR$2CI`W)T6xNoj_yA(CCDCKJ&|y;oQh=t z8#9}s`ci?pE0&gIk$(sJLsz47#t@gd#WpTW?1r~ny@VzszAU4n5faZW;g$|PcrAzB zBOL~)U5%90m%R7RDAZ?p9?wNhy3$CWT;5(_RP+U7R4PF|3g-ki(}TYp@%3zGfP^^D z__*RQ6QzwCUQxx#bSa^(?3d56hB?Bqc~$XCiDI@6*MG0fU92km+>Egz`O=OqGkewIBS@A(hhiChnnhlo8cgjjP0b`|k|5*~n6Rn03dMA8YbvdDQbvqZ zCrBEJ$YmXgJ9wHpv^9GtnKdYh>%O}?xg^ z#_dU;7qiChgZc)B;$8R4$W+i`WV>Bd4eR<+dj>R-NIo{c1ydmaHf7Duyh8k7gO@QZttH zScSEO#o%jSsB?*9ofo-p9AAHmC$clxQCGJ6PTXt$e8uH#%yr^sG$uzHBpGuw4uipU zb@`!z#FP45NrmysQsXnsQaf~6$Ye-j=WMZMydHeb7h{m$Cr5k*Fl%NQ3M-a$(K@=4 zq28y8xF9ChFPO-OTWnQ%=8qh1&!|07^3V&#!a3)E00mQ{93b4bR3eQrCLFZv<+1f{q}_{f#|IW|!@z&&L9kEY)!B zfX9;zxtD15kj6dFbd`RVWJc`@V#g7#DThIfoa09j)6X;;vGu z))A#%Yq^_o2M;XxW<#?;EbgxE@)S(abhxw)=0s1La>Xv}Qgd>g7MQ@wt^0Ty~!uCqQlBN^|<9+hvh z`>~Fisl}(N`hBYE5yY;lzetSX-JH5ENo%53tU(}4%%V$I7SEg!rvjHBQcBhK#8N-_ zT)+0~DAAFUAu@b&hp(=<82^b{V03=p9~|)uXK#N=oAvE4GWp#fiVGxdRdv5^L1O6p zp-p1POl|_{e(`U_?@b-CfjsEt^Y;gGeVUn|7JFsvP5|Ilhb%Pz&Ao7b?*7pZ5vZb&E4=uP1A40uO_jP?$9u= z5Z@DnwTF}yTpa{hmf1i?DsePqe2U|4ul%#X#_m@o7+*%(5@x3lf*)n~IvK<1YuVct2+;@q9wj?^P^GD>^blGqz2_ zcxAtLKY4Lvu!Yhaz(xXL%sGbP?c_K$tPO zPtBm~5D{jvqGNSJ`4o^I)Qg2i!1(b7c_v5Yz`Ea!QU{FZ#kYw{tl!}rKQmv#ejtMSr)@geOR{O2C z9{AM1En!cuA@PH5^+vq@WlM0!-Ux$qOF5XSsc#L36w0y|kEn&4lChs(`77_$(D5#F zvjf~#wzV`Rfv=n=21k!R9fqrkF%AY0SK#Qcs@iHvn*Pup29TlZHU#grw+s+|;TSr} zqj`oZ&`_IO2RyDJaZLEGa@uiirYo+Uv)Lm;I5?q9h6N_Cp4=%#y!s$me*J`xO#SuJ z%4&BZlCywzFNh~yz?#|0;HjkrBd2ryau(~8zi@%czs-w=V?ES<2Hph=a#|`o5VUYWON)ONzDag?a zIB4li;c%csK}35df-6u>xT%DVht?#-(*yrwK9$bY>w6ulx}Crrx_c~@Tir(p!pte; ziCDd_bRDc_had;pa*3~hRLg93z+jrj`ki|&Nf>gl#`0oG$?+tp5 zkzQ=yeM!*+qPPytO>VtnL1EFmFKIE-0Oa=OdTzN6w~OvsQnPuFbj+3Vxs!|gCU{bD zLYE`85@2^v*QGVdPz{#Db*TE0owGXwV|{p3sr>WGz@K>#6(bT3yN3_MG%ZqNDC0?7 zTRL-1xGY2Y8}{~glo;**l!HQW%koOi*2cb*q9~O&+1hdCrVlpnKB~YXCTj2kV;*{b zX$$_$_!~<0)J4yFjq178nU<$U>DdQ7#8SW!{#FGFIJg9>Z24v*Tcq+q@ikqxSuRLy& z4XUpe{*GZ!J!XN&@PbUB0#f`(rmkW@R?wy$cnf5 zjiPdNC>5x}C_BCE>u>X?!>xAN9qGwYjdn;_)Sdss!ETMgf~eqf+O+y>NanWf ziAC;1^&6W3_<0}K>TbObtcq;QIl&7Z9b8o zs0nB1gHv88#C(IMuI0}~0aYTCU1)we6Wc9+%l6+zuAfO90YM&ARC{Vw-1-|nP5~Z{ zKi2Z)_xkG(4EyUI?k5WQ34LHJn^x8UQCN24<-`M<+v_nxq5jeswnaF}I;xjuHjNlo z*Jnr1oOsSwb%j)57U z8#QkzcBGIHg;u6iWkw#{aoFP(GDYWu=)Aa^(R(o>vZ+TVt}Ek24uQm4I~IUg`vCE+ zrw=Cpo~iaNMx{}J=*Q&}bsm90=razR+%vkO*s8;YL4$q$C)#Vgp$!XxS$rcF{1uj9 z60kS-gP-mf3c>L0x*v#%clg8QNRHG`2#DT<_LRrize(zEaF{3{$Fu$0Nu>-EoYf88 z!OjS84M?i`sOu$IfPoDZYPG{xeI2V+oWIM9V}pk_=S@jtv>%H7aKIT9 zXhAV$}QJX3%B4{(jR+rLq3?D6~akU&&6WTIBoYBF}qP$39Rs}NgUsDOF{$#Gb3 zs?+?_JQuMC%Rjf%=hZpOba?={-2no>0~nr!74mrG zxC0;?fUw{^^>^j`cl>W({XH_We=+HnLM!-0Q-iG!$!c$5^~9wn^|Jj+b~Q9D-@dGO zYba^vKXD;YSk&PNLMOg_<-%0kN-SLYylRN77WM8v&STF*#f~lhjCpd*gT2tXIY?Qi z!@zFY#!2_E8QuN0XP7^7o^$_UHixo;IdRP9_m>CcJz>)P{u=n$BcI;~z9sy46;Da5 zhnWt_G8SD#7W0hUl!Ij)3dZObBf=U6mFB6y^u4NJjMtOMe14Z@z!B5_kZ-bfhJhqM zA63V>O}kD2ZRB;L)NbAre}+@JLAqCMP=G6RUQaqdt-U;**RUjPxw2@piu zGZ5*%0OCOCZFLCfWK zoa4!Lc;Jsa@opa7&`+tdst^4gDcSy9D&xt~D{Z0ig6^edLj5HPMmc|}QG%;c%E=V> zjrC*)Pg{xhRa?ZfUrOhDg8#dWd%#TJE3E!eERGk^2%MbFm zU%Dvu@0Q)}BX<|i!<|prJ*Ca!&is@ihh%l=0GlLHKQ4fHm$}n(95lm)iaSF&d_cPh zRyj;&#=u);a4jIslCbbEevE~rb)@Gn4`Ah3e0-wHFHEV#J=roIfz2;|lAM0|`jo~1 zQH5^u?NswNxst;Z9E(8(j-T|36w1tW@bqnXK-!ZP|X8C2<{f zi1b?X;2yq?Guj0nFx2e80}K7QwoX)Iyk#o@3>gcfI+bap`#c>NuP9XU)jn1PV6_D> zLx({3%Y%|A^Yk~~VS1eFG5s(KWBSFbVac3cNr6%E;Cp(?C$pRnrRWbwq$S~#84fa~ zZzpSYns(-f<+`N9{1ZWC4^%uvDOH3UXg-)@m0?saQ|&L$er5shl`^t`lkt&2<^w&- z<;1_NHYj@AYU^zHTjcUBs|BwkBp+n1L;+Fjf75!igsbZ%dc3k;+;h;M9kdUg)^(`V zamRmp@{DLc>I1D4KF7F0_fD!@93)j}fl~SHi2uSfm61rH{-&-~*_dZ?nCWg2NgS%k zq0y%6`ikTxS6FTSiIXRng(vGcdF_JcX$;eL#8eIhIC~z(=`ci$1&6C?tL=yaqur`i zdft{wAHrsAo;pZ+cvqYf*q*J&^>Y>?B1?ll{y_y7BDi=;be~U7joezIctf1+kW^{y zKVZBft)jorSVI}QrPsljFzF@OtCPBCC=1;Sz8;n^qY7eP9(@`Fa5toww0bvVv4HQ7 zC;;ZazaKsGo1GYg_3G948=lgC^<#|I>+d%wgoLyr<-=6>zYErrwUi|s^OWK?8ssLo zs0#rKe~oG=u4M7UeD3Kb#YkhUT>fJMWcj%)57tMm0&vaS8X#WqM5dOzFh%KytT*Fb z^J4SVqPWTkP^TiK^O>C{ZqEV*>U$x5ca9Ws8gO6b_y`X}m_E5*hg`33n=0NqE8JrT zVdCrh_Idp;jQ!og*vY2AWcTNib_k$v!?h5FXqE!Y@>oP2f9T^=KhHn_3{7v;^;Mma zgNn;TP&Xn|Jj=Jyr5nq8glO@oZ{P3)V4Z_-m0|4g39RW62@h`{W8%c#60tc}!r%R6 z=i+K{elMRHm7n-5owFtrf&$?t3x(>92YzB>?oI?BSMt ztAG3U-p}lPV&1X}463stUjpPQwa#8f5okG7Xu)+pc?a5CoP8h7GrgB8xtTHLMZKQ$ zCmxvk#&=Dl5E*HcA^Vt#iC8{me}(A*C?O0Cw;nz(R2G7=Nr_Xql7=&VEdAf+*|o zXL)t>PbIW$+Zuo=PR*(z)AJ-z%y)l~(H}A(i>_NI^#gJiV4Q@Z4I`X$0E7Q@d7Hxk zbpoewS`<}9PX$>q{N2KeJsOA_lhs8v6acaKa7_>z%u)OU z6si63i?TbtftGdAi!V`W-#YHMzsAJ1f!kr9f-d-(|14;JW}5HWk){qzAv2t<`!n`{ z>J?V@y5qW_9Ge-QP)@d8eN*Q&7O|onRkb_X!Q9+?I8Js&Zj7p+Xk2lC7XyxW7;TUf zA?GdwCtjLpE{}gtDm9b9^Exv(dOy0ed0{a}xXPX&TeDZ@--Vt1_zjbLk<&P^^PlNY=1+3p8 zIR6PKo7;C9mfl6^|1j~JlA>|CeB6G&c@b|UoGYSw^5_Am&!|x!Y;Tsw5@Ph#QwDTZ zCUlAc1nqi3JFFlcUWXg~HEv}5Me6)%La9z=0dL8N*5iem2?wD_>@r8NaD78`2mKvd%B^qNJ!c$2I6SY|~ig|7PdbbZ$oJOS8bh*Shda?u~AUFc(b>R zI7^E5UU9gXbuH~aRQV3aou_TCBOGa(S#}U9ebuTDZp!Eics?-`chl)+xnKYNS7Cfu zcND}Pq3{^260-jEi!kjGq0TA8oMt#Py2`t8IH*#J{Xty0E8YfM3qsUyg*eEV8@w3} z5gilCfmh77KoYkNfCt^lo@iD{UT4ber%SgO}Y=#wqFtUQfBlIqyr zfuN}O-fNX2ArprqsW8BgZ_)RDCggljfXF-@#p;El|ck#9fFuLsI6nR7Cd`84Sh# z(CxWjN1#hzr^sH;VVJ6wQC{&7Sf&|GRZ~Tp@w1tI^(b8qf{9|^fKtDb-YKyFrdM}H z_>rpqU%5WDq21m(`dtiMRN1$8A5^;5BS9Cl9?sW3LX}w?ufHjE)Ct(kjjfVesV{0- zD|OV0D@ZvRjR1_tvWfHwxp~l_O&=y-6o)K&ez$%oyU1E+aXFqH> zlh*|;sYQ0;Br_oZPezV5YpnW|H1JlgXqu^F+)|*Xr}i88&Z-Wis*A|s}7Wu z6Z;R-s@`<&*z#jVlOgmfPyggT3s)2D-B3J!Kl;S~R<1!%N{(;)*gHnsg)PUjxkNH_ z?LA{=s~ILTE5pfZnk6jL#$r+Jcgb(Ww%V&&QmDl{1`gw;zbl5`O z4LSLf)z^FGdw`6H;M#2{;+urZsh6nI=BmLKsPC^oF8beU`orMMVh!BW=j%^=_uzIn z0W(Xxj^U>YfOT|cN@9T43r6NCmkUJfRl&P?h~anz&9nZ>H5Fma0U+P?eSE;wF-Q66 z>+1KT`I6=oo^y&Ru`6#w-O*ohB}2oo>1onuz0!f*Zt50R84;ywf_YDph=x8(0l#Z0 znuBI|!5bm7uBqsw-pU63l4fPROL+QXj!-IJq~qFoPrHkHi0!$mh+lg0vtS-3h2<(?sL$SdGo-uo? z$2e+#F)RRPTvSC_qSNU1;BS&omA9?BhKtIaXp2XpMu!TxeoWOigMNBMq``mVZ;?1I z76H@sjD)Uj8vE0T>GAW%}c-wXJ;g;_&$?T=3?gkfa6ah{zw4wcJpXt)8n%W zkkTk5(YK^=wGE^!SjDeLKd1wx6y-?0=45sLBnc~#nQR1GS#V+fj1dMXq?c@{$w43q z?tMj&HQb$RJ5J6;p{>HKCE9A(Ka;;1XS;FikEvkNXruOfowTSIUYqu$X1+FoRl!|?@QmkKB`px51kHF;BI}o!C?Wv zD+-)9t6x52=Xbl}s|+)zVUbOgu(1;x&n}+GWqO|244Onf5lkAKPidMY8hnbDDF2}T zG&o>^LnUX__#iMKbMvLB3ZUM;UzlKNn)yjxXis1WSWhZr7>ezg!Gpo{wDL=l|# zo&4xxS+Ils>!}$f%;FI2--ZJrnA@{0f+!54C zG|3P&%Ei9C)MT|EFOlTQf~J#9Iup86UCrsF&_>i@+t7R7B?50trw55V*tY=jaF~P} z)MFQK{+sq>>A>cVIxnj|V!0sPkbs#8v8a~MtlmfBIS zWMI{(MgBOsaE%IapEC~YLE4?^cVn9oBN{TVwm+!G`^ss?qb@AE=x6z;zEP*}6bU<1 zpOKcyTt@sMWMjkD<;&9hqjbe^j6p-N;^|aH6zVh;4M_7g* z=Q*vkd>Z$zYVyxzt4#dIx-M{N9hfGF6R?=mNZfTo__1Amv0~eG1~Hr<`c9|hH=c~5 z)+0?V&WtMBIqH2Ap`Kb`f@N>Oxm_RH>19kU?wO=XVI3Q8RC)l2IY6_NwPRVq@K9dG z2`IZ1G%(D`C&Koic6XvjRhO%iS(kEAdNTx~a7C};q|Q``tYnCku@K9=&|IJNovKCe zmp*f%`_P;9MJdEU1scu;v4m56PX5!-L~jT{|IKu#1G@6l0zoN(UklSIjAhH0L4%Om zcVF~gj+R+3jh06NZ2h65aqlCey}9cjhFTqyh9@Zd3&5ylI#iNrO%%zq%PmPZ1eh+Q zAebQQl9}qx*XG(i%D4ztSXC%vrTGeMdQ%e=!17yj-5TDU5kN}``p8r-j(&B@79Qr( zR@PQpL1&E<0>^O2zgQg;7Rt@|GfiU%L|A`TZz+pd-Oq|SE=|%KHv^t~q{)FhY4z!Y zQu{bLKnN?2@(LdT0J%_Fa_P1GL*sly3D2olIC4O5Xss%yOn)4Yr3J;~xmdf% z_Z#YN%@!3+7oP?pGn?zhxRQy6O+M16UzaL9acA&{qq-cgcV>3jC>9oL3B=C}#`Xl}UfHw;$jbzsYJt5v18%2Qo+#Z(ZIbUkdSQz!W{UV=M2 z;;_O3oN$%4zwa)1*baB;EXU&-V8++ytDsN6DY%veqEnO~m zMO4X zHs-dELW9-FeG>evziKpBU=U^-PC!WHj?EknO;rcbs%m9wJF7q|irsTNk7HkB7DG~T z>xcLmp0s`C>We==XZ?El@0W_3cxIPu5IVLZ>geXNgH*Rl!+`h4zZxs$E z`~&;!0nzALt{Q^ggnT?Z?K(f6G23F(#_tBlmeIvb!&EqqSj5^KKmQzD*(r6vXkk8# zN!Cms+=0#l==Muhf_vomaG|!VUw*pt^azh*%!iDvf4h~Bx<&SWHH)T7z+06u+h5LG zGcvWAhkE;tt#^E?x+ltWQTapw@=8{n3HaC5QApx z*wpO{ijj!eTEBaT?9wG1#669Z<(G_iZtGNSl*$`^kTVz^8^Q|RVD&n(bHOj}VUTYl z)!E)@uu>{UZiU&RNyZgHphr-r^HfYJeHw1u4qLL(71R|ocS5_b(;0z)I1W>pHgC(;6zULghn7IRt%)ZR* z&d9z1?&fw;Q`4KNshP#0RiYja0bBd8eW{QIpCd>YjAk)rnjv9@sMJkK0VJUA|6JFN z_I>5BRr!Agv=|I?`$IF615nm$=e|gWvXV6P7OiUpO6-#A`B=LlOcE(7w zy!&?*)oT=QMdvmz;YjLezMmCn0zVWIzP%Grz&3r#2a8sa#p+xC@P#X}KUQD%)VBZ~ z#dQ>b}vU z6tAzufUdE20Fu_0L0A{){3l%RMhF@KD=P_}6c`w6Pm|zJ8dkJdMA#n70x0`u(zRri=?+_z@fZz!x0Mn# zV0MpfBo%*ZZ}-|RbJs1FSZBdp+C+d(a=DY>IlLniK;)t+C=>wrPf|tZ-qn$5ntn@C z{PbR6EB4TYQwvSIp}Jz1W`YNaV_^cg!b-Z@`=w!XSlJWmkuDsfe*g`012{o!2(^Xh zvk5sgf{gCJ;doN~7U5j*hPX`(lLdCdhJld4EGlFw7z!nSkt_mm62-Jm&}$8`iVB(Z zUrKG7Ft&nzS9}ADKy>3Ws+6KeRg^8Tl*9jLpG~MWH`1_MSZ3OvH8+v1s^gld)DV&& z%hICr<+o+uL7(iHWXGR429oNKrobY*HMHvuy%hOaemyz}&DwztX^a8LATU^EO@cxV z(Cpz?z{j_t5S3%eHq|Y~nSaKmK|X*sbXXYd>rNfEBKd{6I$D7VlDMdCsxoFi50z;s z$1$fxLrhCcIYIKlMyzOJKl3n03^WXTq%OG7G|LN?7XPd1UD1k`s?;J-Tw0W`^sN>Z zx|yK1TKru^B0zC)kHpdCftIATftI4J23phBYM{mKBt}Xj_CTToQll_P&>g}}j6J{y zTD`IG5~FV%xs8uET6C16o0Bcfh;TF7=Nh1?M~K=Km%d23uxGtch0rOk{S_A~zK^VO~{R zK4eAa%Qg9E)a^9rMxV?EN(Q|UFrG+N>C-HDl98(0J$pCkd~pmH1PinImmKk z8d`w>?w^g%fH5CFl=`4e2%7nFUm9vAec|L^)~F4#M$2##V_cx7(JBQVMwxKo;lIl- zdQXYf=QTm-vZObS4YwcBAHV@pM*II*Nvq&>Y+wVW8o(HZ&sxvv?~N9D#2#!Ni2`6q zD`38r$4;F_?V1J>J)$v9FF7N~;rgp;gp7b8fu#jhXkmXE4@eQq` z;AfHZ2F5e$v}2Jy1A$WobawNKZqGcbHsgiTUr2=&al#{}Q6GlvhKfTZjoyesP?^!8 z1>y${&@xf5326)4ON^ADq#OGE{?VhzDJWq1iOgEOuO@!^!$17p-~as|HuLA^@Q457 z@Bhy~?3=%yR{LhQSg&>u+xdR^hky9{Kg?!}?ep_?^QXzpYQu!)+itr*9{BjYU7pqr zpZ44Bk^hhL#c{iT<>PGj(md?ui!U?Fc9_j9`LsD5nq}u}yM(t9C8DALP!+YHJgzpc zv*vkqJT`k)Rlm=c&Ex#E7JTc~;n=7f_Edj6>=&~`vu+m3X;1Zc;$glKQjV`Xb-UTC zwSFM1w>Yl0oBl~P9OwJbO;uO3oHpO|4kRJlVYaZ0i+wXcHt*+On&G;6ZZ^vEb-q5S zjn(0Pdd)sPwllomZ4WEf6W{vV-Kzn>lT>T|+pr75hFx!JzYv=jDIoGGs76Bt$b`jyG$uwWYgU*v~hg)yj|=DeC#ZIBJ}h z^W&Va&DUek1lihTBN80Yug&l)1d@$IM`X_y^TktRGTKD^NOTSAa49VAOd0j15qubo z9a~+m9%ePp{e_?AkEq}VA|zkf!F>4yo>V{PyWRSAa(rDkhgwk2_xtTjE8%3r|1du^ zckB5=_YT6HVD$v}JGE*u(aC+&&Ws%B|gM^Z9PR58OaE?7$4?uo(N_ZFkQ9 z%jSqt9e-{1kL&FVYimrUG{iH|Vy)xCLIEt~p%-8_nHo*iKri`C<*+229H zm_*z@K0-#-Wp*tr>=HbC`iU^iU1VC)SXH#(+U*5%^rfyI3QfbcZyp=q$YeKI`$>R8 z%o?{IjS{}j_bUj(?WR38Usugb@bya!xfdyek+lXI=5G#mw(BUrFphPb1uLy?yaDZe zvEXPXVp}g2tp#-jibU&?*e1sq&=xy%s4@|wOi8TgGytxojJb6YP|0X~`Lx<;R4l}J{oOaHd ziBT|E^m6|05SYdaaNn-D%d3|gLh5vXg2_wmVpS6hRkdeCf+A;_P?}~8Ali59ty}#d zN-*hGBaSRJXTT~QRxS0-mS3Fq)W^{~lxcVOLZ@nEF02+5>vrfRr6WETD&BVbeLix? zPSc|I4IH-LKBsyk>@9)tvVx5(YsY0miYylumf>dp4YQ7CnNMF?2^x40ga2s)lVJSt ztF!uZgNf%ISPJ+osk?5%<-#z@Fv*>6ujYrRo4GX=CO#}Uu>lN=_~u3(CQ;q$y*+}N zGW{}S7n(e`)MV7LvJfvb)%rtdx&wvf56lpXqnZJ$aK$1mn}%gt?J)t!Ig{aYjH7>h zb68i0kDQ4@OfqNKB$bh(Q9c_B0T^NnZMWqZh=Ll4HM;!+vWS#o3c;)ytR6#)?y>99 zUQ~IPsL@C_$zGr<1;ha33DlH2$8!zywkTTH~Kwi+i=|ZUFMJ;Ou zbw{m!3`>zMwK(Xsfea!WXoRGTtb2!duYv%ds;x(&aq1!fb(l>KArMZ;B5}h z>`)^9^oFvt(nb#=-a3I0y*+=WwqOqQd<;hooR(!0&h!?qJkO7CD#)_BP%bfd7xUNe zRe%PK3MYw#jazCi12PAzu^wIfY}($YCUrBP)%0$@7yVU|3pp;^-S}{s`n#YZV_3u? zMXLJY^-SaZ67WyQXK9?v&7B+E7YyBRM1sV|`fQf*#>}JcLm;Js;6guU-lrex2__r< zeh1LyGKgrQZkVMXwo5^t-Es|8CI{+Wr(}SvqWE?ukYF;3bc!6ThJ?=7vl5rmww1Rh zo<%?LBDsc=-Nu4i@u{{{&#dZmFj1my{L-gbS1%r}I>YZoet{F-oi|TeNcRt6&bbE)$f`&wCq20 zJGGb~l?@|t09atoNEn!rBY1w><4M$Tj_E`9=G?@+`!IOS{pwcMxl8(cCJ`G+1EPhC z)=OroxvqZB4E3Pb&oPA6U}u>wadh8nZp=bHhFAbI2V?zi>$1g~&qDKciwZlhnXQ`a zoX7)4;xq|vR%D>SLEc!?3l{5WSLUD zv39mVyydV}k8=?MB0HPOwvg1K6V?d3ue;pw`2A6>Y3D6QOMw%LyDjE~ z#DjY%pAi}pgw*E{r~%mq?-3IO;neDZ0z+mO`Z1wuOysa{Aeq)XD8iIy+tGKXq+Qnr zv#_djCL!fy?Q~u-TZ*P|&0t{9GFk7LT~B=2oSq+={rIu;f41J*^mV2VKVHBD2wg^W zca0g{COoxpttK%P(rnUKfSYBNZ*}NxURn12X}x}haL3BGrt^-X>|T+)(NX%KSS=U0 z`G3ZR!Fsayrm$;alC+GP#+{RWidb+*G_;EteKT0q4bC zmE9Z~=vVFGSz^w`z^2}2Ir)N;Xl=Uvi|oIa{3E(W&|ASmP|j!<19rxQs}1@OFR-=3 zVu5KNi$t)u&MW~GpL493QT|R;WyVwGYl=}xfmp!AtWH8dyHhj%j9-6HjXz`tHd`DK zR#S~L8?*}h*@u8Ez8{Ff?g<;Ld0xnfbB`F|=5p zGmLGs-9KBn+f?}LeBO!$Z#=|i5=0k-?(X;2cP&b>b0OiK`+^zKpZHQ_VzOQ+l4Sk% z5)5sFSR}xgYm+`;D$`P!?0Qmn3GDkTPWpP+UD5-(FDy6Kd2c*sixF5}{KkSEl*?f$ zYeWFK&>Vm*FUoJPHUlJ;0)TGPLbHNWZrd8599A1^2hU#&u#OTFo3|^`DE}b&H><~S zR*jqwyh{6!&Cof$yT@U57-Lim8FXkRL7lZ=jW+{!b^+S+wc(EB>WI23F2_8A1hSen z0?G8#ly8+Avbggud4IZIS=4l~v7Bxy1Vy^BYCo)t5_OU*b4m(tX&*~Evi*a*y^itz z<7$6IfXT4TUTr`NAyw;V7#5L?eeBf%O=o?qs~N#Jj9?f+zw|(EL{ibI_g@k$#!oj`WFHIBVYB^iNIw$|IIDjP?^`it2M@D8icyO(U z-wU*5qq6u5Str=pP9&Jg!jxv&N{hNnfMNvC!D^D_w`{2UENt-6R?Ok7f_=nB0OWRU z3uE%9yn77`0Q?h!SH?W!}WAFzMGE5w-bIrln=OZwe;as zbu;H`L{!_m7k8p#G1EV-;K|PsNjWaz_*gga@XGHc{(#_FR_gKcEd-v`z|4|Qp1Z0z zbzp&zPnF>!3yf*D%-CNy^UZ1Zexq)#4g&;C@3*f;AoX%17Tmp^vg=@Bg#+550`9z` zia9R&rbVP3Qc+serh2+VPQdr4P2{Os^Z*ebAQc9CoJkF&)dGO^A*mC9;rgB*5e(GG zLiPIFU^9nN?8VQfL@MNmwOUhrMc`p@^8bgbSXmKvj9U!)1`|GeI*&Wn9+*Iru<`bi zZOf9X)rT9^w&Y%acq0_D3&sT&xkTXq10XXoTX&&52mX#0m2py+?Z#yPj($5`x(CDm z!g&eaA|+MA#l=m{fRL6(kl`iusn9@U=fN>`$m&NJ}MZ14=c&><2Kgcwi)YIeCxZ| zg2Mtw#P38LEwr?j#*f=;xK8(d*D=78cMAjUJ6CSz7E_AYe1jAFCzghyQ1PrCMTcfv zynD?(4^!l|4OxSe1@Um^;`s#AS%yw(gLQYWo>B_?2}-yqAetlEvfBi+-^%gBC!zd3 zbMmcQB&t8`)+=T3%{Hahp~WG?1+Zfn#*kYQSW`Zv;l|Wo|9Wa{t&Rp_3{Ale^FV2S zHk2xJO%8CN!SFz+EAycg94@{{VZzA}JnH0Pgb`!0(^}S+p5>syj-PL949}46_woMe6W+n0tqqYr$P&u_j%wWCB517)+RZp}Z%pckEAFwxfcB zk}A?bXgl`OST?WZzKSd6(gBm(^-$O|71ZlnNld0))dA7J{y7GzJVmg?V0@z`CG0!d z&X>)y?@n8xal7e$W?ST?*bK!T4A_0+4k64HMJB`-?HlIaohfjo8z+i)?n|Y^5wp`s%K&2xoS#U^I|=2PbyqPFpkNY)u_JASI76;PR<6) zP>gj5A;Hqbs*&Oobb#w0A>dV-C}W&rWiJDm6;DsEq>>I zxz>ln)AqDp{$$0NV|D1i!kXyW3KLUU7E^ZT;52;nU>hBMYU?vy4jDwp0y`Q%n~j7a zSnX(@w^Kf>mYL8{%HR!c8LOUPc!%U!oDSG@rB_kq^A=n1kUwTKc!R}hDr=+7lqK)P z6d~6Jl!!*zDOAtDBBx4;2k;r7nD4I2Ff;+m)9aEUSWq}D9y|P|IZE3N-R1nI3acu> zmQ3dMQ2xtQ;zKq!Kqs)>5OJSXDd-&^M)kZUKQTtEpk(12B~4XcfSW`Fc~M@eNH$f= z;;)SGR$f-ZeCY3w&gzNKG9*p^#(q7$c*ZCfkwg_M4%)xe1szUZYO91(;8X!C> z$oJ4x;E;maCmRkyK&xy8DyW+>2B~Uzw}r_wmsGKDBtcPTw4=I`UfBx}+CCU30ri%D zwyldGyuEKSUfCWRLy8*0__LbTCfm|0w()w?)(9MPhffIi=Ns_D?N!;@0rE+ez#jA7 zr~y~hEMk%_OFWz&FtFYkGC1^_bT{+5_`0)wdOm?uNcP;QZ30iEug+jg#q)Es#6CQ2 ze!7%lVB5c2)nTV7TS62OVwZ!)I7H}{=jV6WYy1NpX*odT#suGk%SQlPc5=%Mg2sJZ zeTFUNq?;reM)IE~<69P$*<*Dqpx{5;k2J~K00ySE%K1nxo+M*$=9|@y^4P3BNL1F{ z(%(pHy&FB9WwYc~P7rx3n??zm-u6xF?co4;^t42~Xo~PYuGTK!cGzf~0MGY5eePDv zBGhdwIW;Pz`y&~Rd4^&ATijw4Ff22}OjB#C%B396eqcT+yc_N+Q9iP=RvAmPzaDVx zK>{(G9Y6!~r4*;Shk77bQyW{ggiK#yy343z?br;0V?`7j0V{E|0c+X!fwp5)CySM;mTIy6 z1kh!pC3;^PY6W5i5LjBr>)c+mLnElQam5$ zpJV8Zg8J9v$&f8quLHj0GUCZ(Mq79GzBf>oDrs^x{={58*oLBdO>&q5Cj=O4`&x}( zgQ)~RQKax6F_n_7f=~3_yBh^9SB>C+)X_(^Ji$z;KONuClzTe<>8I-!(4+p(_6ZfS zg?A@xjTey|x1T?op@Fj@Jk7c~9e?OwO{Ts3DN9soH)CtGN^%c^rQD52x6|SMVEjpp z+)Dg#$I3j1j(eD?owL-tp$b>MjY6uA`6r)7cQ`WQ>V61pUu#g{bFSH!3wHIp*68Yl z$;--QEj;yhCWUc225EhqE5?&YU2s$T9e5H_UX&stt`i6j4j);WWuVXWCfd*+w?_IN*GM{SlIM10GGYKTliTx4Zk{M}8ZO?m?gQHL?13imGRl)jGKz88?;J@)7d9O;$JK50fDzz4^4P zh|;cyy_!k~_r0HH9b_XaJlsEDoKyJyYUY`RKL;HosI{VFBfsnP;3RZ<`)nfC%e zkEX*L!qCYxJ4uh)v^$?+ z@aBnAyE$fcOx$}MkSLxBFPNI~zdy%{;K9T`_ym09)y&RjxihdLR&rd`sHSStJUftM zubMTC23Cy9me{)qnyybfn00*{#(bPCs}Rg~b1PAUeR^s(m17HzVUGyH$wtk5KdPa% zZ4Y%4Ou+gT%gwJ3%}^>xlXJFbXPNL!0Kq|XZWJ%p|4}Yc__tPqxd>QOE(=0I@Pw>Z z(-;z*)*z`)1cqFmJB7!}08H^Q-H!LZ_UO{|ou8#6bhO3-mbRP}fYt_~ zQfk>T`$Fh9%}XagQycdiR(RI@0Da?U9INCZ)RnnmwWalu*z$Q-s`Hr;Hr)fB~1Z+Eo+sy^2x)}VO^5y%Ik&0;tK!51I^4cC zIQ*;_-a-0hs<4FH4{Hr(a*_WBb;2Qzm7ovS5<8m; z3@7~_Y$cx#O?&U+(KP&ryY@SEY^0d>mrk9%`!Jo_?96D?)p1LS<`+~Nwkv_F2ybvd zzB5CxlX$L5ne~!7wJK))RQ;Wr)PwRhX|l49XSC79Vfht{2_Fb+CfxQuzU$pzNP^)n z!zOcn98E^=u7^_0BKf;(9IL6-iRsvk-a5g1S~9&G1GQ51-L$i;X)u+XVO5nBG+Me} zlC7EYWsa!)KF?TVAM5Z%PNjWy%N8k^!4`vy*1&AG?6?}hCy@HbacB$9iOcC|Z?}R!i^Ezu zYk9^X7CYG%I;(ZyMDgnIVK>G(R5)iU{O~}70Z?Y0@H_12esQlQYcjbZITI$nmh(a( zYo1=u-MGe9jUH9<5>>J;s%5y9U{o27;J}Y56@aM<;RI4G?wAsH`TS7TWaD3zC{-E2 z?{R+NWK=2Ykckko1ckB|vSRjreE+F;FI5{!xQ*xMNFyI6WphEJYVtiDDo~W!HQ8M1 zC)PWUIDH7b)3R`+fZ&!=9-Bm}5)W^*6dzaSS;7MWU$mEK%peoF9G+H>N91I#ly&b% ze;E$As5gc9ZGD+c;c57#{&WWcKD?j)GP~;y25y67+D-5NC&Qh^E+YD^nD!W{PjO^a ztiWc1O{DX6#r?$j_7cTrkYaQOjS*IlRdy+I9{%abO;4P0#(cf7l|Fev*)PQPn1>h9 zJZ}|)lJ!jsVZj`*yyb^*W(%whllMGBP%1whko;)s9wIS&fun29)eW+&ZB77E7o2Hz zD*fV~nKaC5F|ItgE6xKlr*~a^QCYJ_(X=#YoSRa&ieN1rZkEx%3|XnY_pzc2uhM98 zGqQ*fOHK|x{}ilIm8sM==tOK(B|-Cppx7io&Vm4$5YK3(cRg-TdvC^r-u3L=cp#@R zBB*T23Tl|Ix0?N6D^V>i^2F44NTw`j3p4Q}c~NhE?i>!?X!D~D94J@0=F4BP06HiQ zNtqc#DFgLULS&5iL3`=>)#{pB`w;%~b?nav7#^$A_i2b~bBm8)Q}#hfcrJczD1d)d zK9O%vut5jg-V}&?w$Pzo1EEAD7^9VUM?;hnhJVrL4(cjpP;?Qrc!b@=OjoC7ys zD>Naj)tUleqn*!OK%(b~6sI%`3nKXh1&p<>uNHXqus&&+!g8q9i6WC-1K`5U-mEvB z-j87Mf*J5Qycco$5>1aw?zvFUcHmc+V%KiT2X95560UGD$5Ogzxss!OsZUFr(@1OE z`qtAwGV7B@?dgwt{wMwFso`@pCsB_L(yc(%w;n02rUO`ALY z!Cpknj+W$^ACDG^G4-tPA@}`Mcc@X%&74$ZjK#k5!=-DyATX-Rxqxu`%)_VIkcw*K zVwG^*#sCC_pCBy5F(*gZ#L>_4u@jyOxT*!=81~gVS=l`^*qHu0+C57V(~H;I>%!ZMMJ zNg>7nD}XNA78p?MY%)r>V4WmIEP8m9Dd?FRGG{uX=i};V{?jRfHLx+Ct;5nbXQvt3 z#}eLzFIQ9yVS>wOotEV|Y?HAOuV{4!4`DU1(FO>HjK&}}0Ww4I=QMz^i3GKBj!PiVB3;|;7AyU<0S%eOuo-W#s-$Dot56_~zd_kusZ8iUD0*_+C!t%L=w2sx0>R#|N2Xw6}S^N*p z6IN(#c~&bs-dYVnqOG=FZAeD8`J+ z*D$VId1xIn`Cv46HAa+$JFnpmjYnhIpl#+q1}GWd&B_Wdyf(~5A=vd57?o09Rci_|CON_?1u^|RI@oYA z!O*8qC}-)Jx$WICMszXlft#7t^d@J(K`LByEA@rEw|GILs^|tR6bqCaJ>J4I$9;R& zlS^G3Z`8SfSm(hJ3U2`ZJE=&1N4PY?E~4k?9`uJLc;<^Z9B)hj+|4((?2Ldl(S8_p zSltg3u1+#*t5ODG_|Zd0JAoigxA40Ob};!3SGL7Z90gzEK{~u=;Z!}*u(8T(=qp&f1r3L#iWk06BZvw7c9I&ej%_l5V3hr<08?japz z6LT9Au&m|tdi!9W6UanvTDQKry--Y4qmFm$qf{>8-#Uwye zE>-b3wtCK@BxZ&NkU)9d&p!thLB!%7A%2Bj31amZLKH<1<)~&@oX<4yZnsx{8-_x7 zrgKeD;DTf!8NpTG9}z&!bjfj=DKxB@z-Rtr=NbQSYzwaL!Kx--G-~PtDA)gnNP)vaS_8nQ}Rv|#e2<5s;5SIxBQ2SRnAu!=Q znF(1InK1E;;qiNsPMe*nGp<#51)(^&#)=aNTdmOapei9?8bUr`gi)O&6396wqp)R! zQv3_0`2Qnwc5DWvz+Kb*dZf33 zxeqV%owjP0diVqxY|F<2Ki~LiyM0K;!gEEL5W8q?w0SfW13hZ2`z0Gz{w!?N?kJg{ z^=e-hd`Iq9!06J(Yp}pBc8X2eRcp3K!-PlR9$c4?c}Ry%N;1S7GYzqqmGt^ycV?@cGfcq9@cE+i@sM zbse1%hJk3llro9W8#yo=|Pt;I1d6t(HD3Sw(aKjH3qPPvo%N zLT6arm?E!^uW*AsN3|t(z24@9$s{hkSVE1loLOM5ma$q>Rz(5rv)PGHl490<0#W2D z^wa$LkYtZRD&Hu-DU!M96(O*Z#+GycwjL-Zlqw29Eqak?-|piTAu#?ZnQSwyNRj-I zEd=TVw-_onq;?ueRiYFV>u-I04_N(`-8&{$<@$ZO82p8CcRIRgh2h ze4{PENH&~ssGNqftVWA8GJuhfD-D$^(J2y&)4hlXfO-WkXU)Fj4Cb==ph1kbBNd~g z`kJHA2I-7m!LDQ>Z^ItrHXRP<$|DS`3|RVz97vV5U%7ith^UKe?E>Q?t`IGEUK))> zu#CNZ^a+*&h5gYutj5IO;mc~Lp5k<}OncodLYLzaw1^R~`@6LDs7{zaVADbDq&rw{ zSouEI*o`ahzV^$PjD+jAVnL}E3jma7OMs3@y&AG+?Blc8K}!NBV^*ubfN)fQOyA})?gI> zO#42ik2aIv7uTsPh7yd>1%WlgC5$cTtcPz{`-%A`uGhh4++36~ZtBXz);s4DxX{+% zSKXSgASKRoZGMh?zSyt5r;lt9AF=4f+PNl z9o<;mAmvEk(*HAS4$t)oH;({<=7=$EKDAu{_|BGZR!~N1ygd4u3oHYB)CICkMOdfC zq(-N>qKp^Qs$qHsR2ch~ zB#Vl5eV`}=F00%LG$wt66D@ZjO2v1jN@%jLGuDgUq&^=|Qk8{Y36CfQgwz5F)&)+$ zfm$g?ConJUv(IjKeGTP$+N`LZ3(OF{lheZiHn5BI%I1mdpf$r`jZi}%0(XWS?KU}9 zhby8nnN_!N9vcS>ptFEChs`MBQ=Ox3Jq57L03_P`IuDCLgu!aVeYIdqRgwwV4udFB zb&Ywd2KXq{1fINjz)_YKnG=<_VD?kh;uxfK2{11(!#~il4EgzCv06R)a#X>^td*Xp zw6SDqD^7K+Q|xTU2!OSg)*dYyU`^az63E2x8>*Mf8y>dfF1O>BdkRVw^hWcIRK)+^ zqMD?UGD=>IeOUy_Rr`S z`C`!oyy+Q)_)XK`s1@K>8T}37+J>dtgzx(< zs~oH{>f-aP^@2bX>}_7WUp4N`tlSKo?9O&8Ohi~NCtumDefJjiVi?QZGbMVr;DW1A z!f|x*mVRtKVDG5NpNrSnEyJs;Ss&=ESb(`_%5g^PH2X=&m*Futr>YoLWm zvqI)Aj-DX+*k*rp_Zoz-5<+Iqx&sG2V15>e2+XpMvlndRn)$W{KL^CRpt_2uiTRX- zL;Zou6zN?N&wT8RWy?X+E7P-Jd!Vb({R6fX+Ad$UUR00js9;E)G_>GMJ6r?ez=5v zm@^d5*r$p}F%yQqu0eIsX)ch|6dh=a^C4$)8yUR?=f?UR#B-cZpj>qRDJOuN$Rm(z zZoc1w1_~5?Ya(xI#$yN|Bhrqug$N>}BES_FCS!FMNSVdZ)dXiwdUGEdPQ&FtvsW}T zfnf7(?PXI0B#N7q^j2y}E{Omi40O|Dr;>9KHHN6pi4+%$A9tE-xmVN%>d(REj=$_M zj#t7dAuML-jIQa8$%>Wt0}TE8VDv-*4<-Pc^|PrHEOi|YE@xqcAU-1B{V`?T0xF_+ z$X5ekVQ1S}z4 ziU!x#YmpeC6EHft7j=$HtUg=eFmywOKtq|?4ZaZqUA4F=5vw#o9n8|PGM*6Ry^cU{ zbRrg4QdDM!DJDv+ASfrrE2Ee*5!+Av+U zK_x5>u$Z#Y_6Ub*_WJyepcL*Syr1?haKOD~hk7EGv@NbK>MKotn26G8Gq_SD1|xwj zT&j_yqRSYV!E_PMax%iR*A#R1TDxd11rp(N1mUz(V6e2Rik!2o6NjXv)R0BVKrw^d z1P#H8KGetda6SM#g_O)J)nO}@fbvHVa}|(9Oa7pJ*(}T*1L6L)o`(+P(MHSx&K~O! zXTD@-vlq2ifF;dL)^uVP6=a|iK4ldq0wX1}S1g_D;_5M73lDZLI$@k)@y1)@p8yhp z#Zj?#`^z*=zG0@$Ji9$#7a_&M5oriGE@TFIz#5X;iqPf!r*&p&U93Z_cr~R^Y_ei; zCr&HLgsC958AxbwYs%6?&*GUg%~(FIWUHXWz7(^ztqKJ7x%O+z>Y{bJN+YFKH6%$=6ezCajvy0j$>WXx(4hHVjhZ5udGCz3q0Y9|k?7}3CZZOB0HK1|lLMy)lBzTT& zBEG_eQHe5L3|(${RtBX$-nyGjs`~-Qa!UC5epfC-v2IR6Ntw%KyR87D#E;8^E$r+kQfkqfF`=$61vdL$$E=~HqG)=^`xh4Y((;q2Fzyi(1^i> zEGdgSL-d)&XF+SyJ2Q6++KcCtp7jroE&tLn66G8y5gBHj7#+3@>6o&?@ul9h=0BAR z50V#Ok?Rsz3Jct@#?n+ZX?^1)7&Gw^=<0f|LJRIa@j|%K8;gAg8uhGA8$x)T8~T`5 zvn}ilIlflx(F5eNz^o=$Gc`xmIM_EBCze*HcesqxXHQV0l(x`|AH&J*e9N2?9dYrg z8+*2j8+Ry~$!1OYBsao=WO;a`6Dz~)ivw6dG#g%#Oi=(ym z%e&v^I4#j{6@>)dA19Ty6eJr`^#!Lxv59hEAR90Z#g!Py#5hUN#70nRt?G+T4Kk{= zg3+gmn@eYM=PBE12XQ%=K#@(p>)7~Bcfo~&7=Zrnq`Vv)U)>Hw0J?^k&(>qRKC>7t z56i@_9S`H@Usk%N9ZV;oYy@kQU-tw^j@XgbW!Z=_Wn82gs7nh>8mH~XzF1v^>^!Bm ze;Mm-d5uvP4^_=&J`Jw`);`rydI*ASv{w=bv`3mow{@-~;)Z$eWjBwlzxM5us^z24 z9&lC*-!j=DVi#5ZsmC;lU7RLqK1qJ@o) ze3e|W1lHQN>t-QZEQ5nw#Hj3HLLX<)2aA{vV1*K|@59xYvH-*#$9mX=50q#t9VlJtinxozrrgM39K|qlveP(G>K>2sr;(2k0kF^LS{38;eK6fU1#n1mPISslz zU+YM(Aa=CG+6Wis{lr(F@PO3EZWE_?$N7C6P?|XlSE^RwpVU z$oDegrWwMNmIr_>Ou!0~mH@s|5v}>ED;Q>HWRKPJwTbqbejQgm6t!eLR%eRmmU?9A zIJ1e*+2xi&&@=Fh{awihiZMV>u=Je|alZ-DKZe=*ZxI^*)3Ilv%C~H!?`w>rBosU+ zyM_o=`u+AL6Xg8KH@N0;iaYHSX1=Uo%A8ZVXEfZ+xlaREo!-2ngUStQW-KbW&!f5wV% zy$t+&+4-FFYBsZI-8fY%c(T3P;fz6wJ!3mRtE#NCd{63j7XmdWhJD+2qYbqqpfS%L z3#d|}YuZq_K`acTC*5S*&CexnA?%j+wQ%Ygd*T!RcYfU0b`wu$7Zo|f+5QR^{SV`& z^P#c%6nEwp$@X1O$@j*loEeNuE_{WeT7BzCEo?N{9AGFTu`vnAEoJ(hbnfCUcAsxy2TJ1gCJ^iptyhULQ>GAPt#0#QW$omeT;W$EJ}7(yvhs? zs_0U{M6I?Is}dG{OC|agro=`3D->gaF|?t`5v&la87)$dj>Jql{Nu$-V^6{e9@EWV zPIrEb%K~c_oUhZEMB-9O=KtV(==f?9)0rP#vbj?M+j>*c%&F8$3mqr3M>o1BK0tur zemkt`f*asJj8p&DG3GD%dU7>3O?a7)VdWL!9@H9EdJv|l*WGu4U*W$U~m-kP6`@UKzm#w+Na!D`FzS5+)FgEV|O-gIc`lYpTK5B7aSc6cG-sE^x~V(`IXARL3V=STH2LvFcU;S>YR8&+0LFAX0w9tFlSZLZA_%qQWDbix zh~O5cR9VXbHLo~R7{ko(sZ)J@0rgE#h8J>F1oq$;m>VnFGNwJ7=fIv>pAiS2c&Fcc z-Yya@2_{BG*_HanhPmAk;@4+akjvy{@!}m%-^bbNEMhX16^kVBhxnd}%HdSAwA^%# z-6RXQ>fAG%OV-t17tax6@VK5^(7PRZfxVN@6YaYu9Exz_L+k`w;VfsI6rh2v`Y0YA z**9jd2az$DtaWJPuA*h&^@=?^w$Z!&#+txvxO*k<#$#pJL)$r*;u1xvD(h1};{UQc ziLOLYK&fJJs#GM_pkIU_F0&!TI@X&H)$x~kVp(-|D=YF>HF_+^x^~dRoT^p%u$E^_ zIz~KM^%}j?YC>`ab`>4q#wRQg;(G-qVI!n2++`wpIS-?K#uF2ZKbTpWqWT;Kd@uH6 z$OhG3H)u$Az-^WQ=Sq(iLRe=bdz5*0j3%stcAK6V58 zNfgnmsum_yV!uGFcnwhBUD+*Mjg~1fjYlXCjU250)xrd?J{E!6A>`lCedmYcp52Sv zhK;R*^|pR)ni@A-K&k4v6G@p2fn2`7KH(zuKiphDUu0;?s&u#^D_LIwT018@`(1oV zfx2miQ-EMD3Jo)VAr6{vi}lF@0K};l5e5RWQ#$nEcHPb+rQsuh4ZV7l>dqI>lx6}B z62!$ea77@N#ugy4OL|qk+i9eI*g3lTRgh`hFE4^MT8a6G#8iZ*!G-lC4Q~|~343&& zTDO{X+5I3+Jk4g9-L;%=V@MrrwwsVr4ny(*x3)g)SFMV$wH+?hB`(P}D1^$N-8h7$ z<(nzs#nB~v>PBfLtA<9z`G;nFr1p`YdAyOK!P*VcdTPkOp8pUp>5av$d;=)b?UbApLB<`?8HD`rC4Oy{5iAJ%*b7tf;(@*J4@eWFaP4#jL*22u=?EtDM+ ztE{(1Ja?O{{bZ`29mbYS1W)r!rP41!#BJboB)GqTpme1y7CJ{Vi<3@q#l87LqK=4dNu$RZsor7yb` z$Ayn3qC}RJ{3?R0O!Mf!OTL@(DW7Qsq?zpr^Pj5NkIKhk!w`ilX z!2B_@SM-6^Jy>D+#U|6m{$j051#Ch>`@9nG*4P?SVpy#5v}en5&d1*M2i&wi8rntQ z*S&Yc>+*Wj`^#*6#|x$>hBk&imC+6a6&oy^+-9te!jOkK=d|;CtjSA+93qlYh8pxp z5Mq>aZbDX#U9@MJcn;Sk;}dh$z8zo>aYApn9V}IiAcx5kO)~E7S~Mr=n#tk5NtX)f zm57BVN#{^!_Gi%O?0hs`CQ5|@R0Htx?UjHv)%>LlbC>s&6PKG|;v~8(TPI_NcEM85Y;5_@K%mkPrLh~XzjPM;T$t4 z%(uS!N$iBAI3Ey`4oitZl_@YZ;)?)=ZRR&&Geb4voe^MQ`-K8F)6<7Cc!xlO<7oYg z^T{0qCUvr3LSOPSFRwS2{2;EcAglzVqK=A8EQJQXft2oGc96~Api0AzXsaNl)8#^6 zkl=1FA2`GG=>;A&(s9NV>%MQ8gUS3aW6-IZ22f;54RX+FtRqpOJ(o|Y>{^E|(M=pB zEroA33{E;sNc>g!>cC)SOo%{Ll;Km*Xr{Ba3?sehUxXV1j zwJ0uEG!q^3FzspZA|^F#Tz-n}=2#$Oc33uqh&Af{-Dxd{;@kZ+qnFEO1_DF?YcM%> z(k*)OydW;O%X`A&R34yV(KAWj#%*F(Y?8R=K1|jeoGd1-$~lO!=fXB_dXp%hJY98# z)^>eA_1oht6rz@>y))Ql1a3Ijh5w$AMj;1M>=4j*alsmJ^UD{8BM4vNsj?)vtI%Y2 zzj-ON&)5*y7_w_ryKl;?mIumhCxYujTY}9HK9LA4^}i+GEFVEKp~uW^z zQqq;8!6lTsg+;m~P_6tW=UJmUsx|`cY6>*!9n1lf>PfWX!9cPG-2e=UIW$I^UKgL}m=|{&oOqlxf(14vT5D|ogqfV$G=pd+- z?P@%>OR2ou*1FmFpK-ME2GbM`6HH|NzckGk-G~4kwO*O$d}E=uD(m&sb5h*qNf0-u z=La*=%0e=ZC*&s_C{ivzv3hUSRqd5ye$7q=SAYQn zM!l&b@_?Q!fX;1S%nH(D*fwtcazJ^?lj$qAkfI37Xj;s`G|e1U%PlV3iL(e1uXcRG zJribF=*>W79WWI4N3scDu*YO>ngdG}#luD_7~A5lmG-j*aIC6qm8G4{Jb%8sv5sqa zRr3woRy#VMSKXJh1?MMzgJ1y`4J9Bj0L7f7wE|yI;479m_;oZH2XW%?LYz~g;sT4G zS?$dV$7q(S<(R#X<2cPVr9zc`>BMT|$oj4G?xm`Q5g_(ss%zevY2)qsAODL?kBUiV z!YeP;eZb)jgs?*9j)<&wsgs3#}sBrIvk93 z*pyqiX%CO8VvZI8npA*V;TJmU!G?ra-66n|O?>!~Ik-LR2r|-O7|R_G%5pp?1E}23 znJ_Y$zzf&htd^Vb?~}5PunLVq-v_k{V-<<{r?YC7CpgR99tHaj?4b3lsJze;NSUbw z9*;MrQM^^{VA=4>#{g8G)NiVjg+or-u@bZOcDtLY<2K)57MCVQ)68!fxb7BvmW_Rd zN&X0pnw-VlxkqQV@MBkX2qxQ}!vfkm@v8=FFnl-uaNFm_@K?RtpN2EL#qd3jk)?+e z_p89v0`gg`QeN1+qvU>a+rgb|HP<0Vz-nRX3o7Ume>^~nGdo-&8VCA!NlHw^0Rg&5 z1c6@IBVzK#toRgT_WFwpBk2j2%y{+fMi+m@&NqXgQaAyUe2MVDVauu>xtzdgEW!5c z=^5u822^G$rKJ)#mSJJX-5wrwg1;zYI?=m%+Fdnkgl)XX-f|M7(RIk>SB)Lb6=8s) zpho)fv@3!FEu3(r0T#SehhEvF-X2bV=@T(5MHQI>on^)QFquEj_mXPxW%IB@6(9s) zlRmeH#U7aocSf?T9_MRUbmIpDC8m3xPSt3^n^Dd!+iN@jEF2dauMx-0EaC^A7j#be-sKrKNOZZPnXNv>hH8}d$? zJZZt`p;$KOwod4Ov*<;%mHuvYgNVnO7)AAw(}PUDnm{ACk27wjot}jfrK>LKnO&bQ`v+N3}4;*Gh(H z@u_u!hoy`*>E6+PAzXTF<=C?6*O(80HZ272D~{Nt%tlV;g2vioTJ82O6i7Y`=lYyR z5T)@Uqcb-u%S6;rkMe7x z&pxnnJVAGr*s<0$jufp-{ILb&cT`yk;xl2&X;SGj^TSdr)hZ)cAr^g^L1`6YO#(Ef zcFM2hN@F(bP9`!(RfCUwf>!`CHuA}{={G>L`YcnkZ&EyqWsnA;q;kpjAiFVr%2!h| z`D*pnjH_VE!5nd}Ol5Usb>Lj^z&N+#!Lb3U1j5#$I92=++B?@)G#P~d$W5-GxW*+_ zr5DUjZD)09MZ)zk9M}#B>xAqefJoOp98!je$_P>Fj~D>Ubz44BLdQgjx@%ev*TDqL zINc^pEUI3crcoQT9Z<+;KkyoEUlKCC{ap5Y`B#M2HB~BHmVm&Or}!9mRm&n`dlKh{ z2x3T`ROAdUbz3K^JaIx9x`d`3*0JIhTmC)+K8UJT%5aktK&SP?V0B1~6tt4^5$Zpm z>j{opWr;pu5oDl^7*C=VBE`;i$B|~^$E>3@TMHAM<$Q2)LRxGK1sFH;Z^AJ4-PnXT z0#*9Y%dFOzCd?5!0@ zY)c&mBFAu&Ac9IbcA*MI6-Cc8; z3X!k|HLWRIL=fJ@)=J#B=Sb@rv;c31Z=KJVkBk9FZ7Lf%uCPz$ZxoYCUXdSVS{fzb zBqvVuINK_h*m98)H!|BHJRU0_p?2VNjK2*rPG^gWIRuJD;OXJUZwccmZw=hfe$=(y z$xB{@ARrm(^3=&wV+$DcCzl?o=ymfbv&Skbhr8H&o;hQ$sd?;Gl@k?+w6mqPXb4AF zT8kb&VFT6%MjLKf8dp0vv>M4ld2(aX!a?U((_+Bn4_3=Y=gnvyr*DDra@})4+o0LlL76T zGq_f`-hmUT;5S$+Qj+nGTMsIa19$(%avYxpdw4FaBfi>U!)C78bP~4jWullS9*qbr zYv<}F-5VwWn^Rp-Oo&O4JHZ4wt7%3dZP%w~o9^d4D+ma3(fv}9LhRU5YE^C?=BRL| z?GPM{B1t_!gd<-Ps~GM&TEwPqWfw>7Ry7BzF-R_XU<&F>j_%(MN=akGo^z&Z}4 znj8W%hSk>g8-fgkYt<5&S)OAsZ6nw&Tt|3=2r}DME@;tXlYuYQu7>2{vnUi>{Zm0U z_>db&;Q3bN+&>UOe%dl)EUl%tt!|Dfu)-M^IteK(3O@5>Tm=1 zFA$Oic0|s17GGXL1=}~+A#&6XCNM;n;uNY5Fo><5H_hfG4AeW`w7W3E$n#5UzJ-w@Cn4?5~Nr>LNqdGHDM0F@Q35dOtV=& zHGU-pHkYsv+#CEeR|=N;j4==im)51kY0bMJ4dsI9F<;kDxb76A=0{TU*rGJCkcZW) z4LCm$56)LHp|sSu|HAig@>##?`Bl#;U;W&AlNYwSvRpkr{=#AwTv%~_Dp%dA$1JIm zP3)PRrJ52Ym3wHJ*UdI9UtwKIkmmY?`2`qJv#(#_lk4eL)AMH^fs`n13 zjf)u4e6N^nIbH8n&1)=qe2WKwsjNDo+xFU-rJpu@A&jv4iy)Ylt31U$GKP=a6Xq%w zAtp)(mtZn!c;?OsK4HH2F82K58NwS@P;uriuq%M%q#nVzwsXfr(&-*>*7DVu)5Q(& zXcC5>U~R1a=1`a%G{KWsCDm(G4|z}|8&=JThPMbqs` z+x`h4IXCF?O}A0lT#y5I#?2+%=c1r6eGsVYhOGLF*S_v{#pIOH6YFHfsaUx8;^wrqCi^$O@5Pmz(Ds_w46fDL=7{X&EhNlIxg++dXYglWTwV zEk_&mWQQnk=KTykIIzVgfjJmdW(*uXq-N$D4DvCAl~xf`U|Gvu!6t4HOz|ZPL^XH3 z3Y0Tr_&@Ta$|W7?EqbRHI0;xQhLytvVJAV>0^S8ZV+EPjUm>WC<^3HeDj3oR1MweC z>nrU~Wue){)Tfxl+x;}A%$eORk_v0>Zge1evsW63+Ioq}W)s?BFn9-3bi^|dVs4e+ zF!*@{h!Vr(s`}FH8{aS0DH1OIqZNg{&TJBwPzA}xW2p9vkf0KE%ck&#;VPkbI&NbI z;}fPik_@hjB3;&1T`7Z=`om1ipm5X9tcj8sW5KFPG}*wTc85W zr7fPvNUYFpcYE1fmc5}u6@P^}H5iC(!jA(oZ@vExQZVwgCW64kXBS7Gm^b);FLPjs z1|{?$sK-g8#piFbk&!`Cg{W;ehm;{oDEIhp>LJXOVpcsV9)UT6jw(al^p1=%IWU>6 z8mV9iBO=w2-m!0_y|GpwL}=B!sLgD4%fONxneA>DBlSB*=HdWUX#Xui`1>*6K`;eR zvwZhD3=wNIV*JNmv@t=NV9Gh}2ZKtvf;Ou+ld$SyP+cwWf=rUhVw#eM)z~+7;2-HHVJ1IO2 zL=kukZ6bBMRben_^|tj=CJSr;D2Py;1{_2(@PK-{iB+U?lg%;aW9yB?F`(f<52S($>&x*ujvB5N}hs7)c zwfRkD5e2|GmPXJ-T)0iz6Tphs61!zWws=UR4jST$sFB>PkEdx3yyhk}BbgQVghuzA zviJqp7u(>{{o03;hJt2p91cSWrnpH5PGMC*DlI*y(DJ;nJ)=AR2(+;hnSUw#yh?%1 zmY?P@1*jH9VmmgcmH`N#y$-=;7IN*bDXo)q$K3bG!aU7vf^`=@(*b4dwL}z5*no?i zYXFC(J2I`+j89uw!^3)Rjx}F~4g)(}Tj&w9#bg-bZ_(VXz8R5+&lCL5OjZS-p5f?N zZRn!lTR44D_#LLmUnFDs0jMnC$=tX!9R!V*T&<98vpaoTZhIS? zC}RYfZY0BPiUC-Yh%!?*QH@AIHkk6>IP14r>JhX7WzHhT;{KA0k%#RgHY5pYfn*JU`6+j+uiK-vyq=`raiyBY zEvH6tA8!!3&1?1MM4a*>mXXDb40#MUuV_{s6~5TbGNk40gF6VpCXXutedhg2zem`a zmGouL;Il3@`)oM;j<}W>p2YGQw(Y$!@r8Lm`j`3jSI zjhL$;r#uw)gp?vOV67pz+r__eFFRyUDnNr|Bw1`vkVc{ZKv(Fw4N5wpZ83wD5XA}6 zb~A9xrr4176m}s{)J?0cx!Sy{#gsoA zR2$rC9j#CBCH(>c4%|2<>N^Ww#G1EWz+@sqfuigl5O?>iLuU9TO7ah+xooz$f#6=m zSW}Bm;kujyV*Av2qhi0Qv5<70I-CNUAYn}(9kaT9;k&qAI{h*@bY>V>*GdvqI(BRZ zg~JytGoV6m-CwYfD$afbGVDv7uj@;xmB7jn0c7?GL5+r6P2%{?DhqaUHdwfe!z@_J z$W4ghD^_IMTllQ&`TZOp-KJG3WF@^M5(7-AcNOye>i8@@M!;H?*zq3uBS< zHE^{Bk>IP{d`+W65vNm`>PjT$rHq_M8+GcKkpjd<;2!hZMM6t)lWW{0;gosT5NQwGie!eX- zJgW*IKy}X?nW>nW5#iyxdw4`9{`dd!U;p*rZx>hBCl}}c{eS+~XB)h~zIbyrUtHt< z|Nig4|NYVV#f!x+XBRKtoGz{(y}CL3Rd0_LlDd>+<6Aa&h(R^`rU4)#B0V$@51i=Pwq2+`s$c{LR_*{ky-oIQ!_$ z&B^KYqvOTR_aO88+1cU)hd%|X7r%eV;FI&~o7wq%vH$Y^qaluymnV?hquJ%<>AS<5 zcM#6~hYv(=&8`^}s?$@P!3=ckM5=>vs7!s7A8{0(*IuR-}?b_E!;zz-n=BHe%a@_KP&F!u*Bn86<8 zu=&Ty^^>cMvkx|h-%oB{-RcV0__ks8Cs&K>SBEDr7W>bCeULI5!r9{Lc=5yR1y%aP zAT0j4oSnb;@#34=^{c(v<^360+|bq4L%iQW`%km0li3ZT&xcGpw+TGd`*o}rZ=rVI zA(Ac5;Y;t2!hrt%<>kfo$<2rRQ0i`A46OJ74IJ5|L($><>f-eD{xsbRVrzEw^9O=y zj`~AgB)HQY%!h(Fxi%vn>KjJv_^TjJp<)%rgH`Pce&NT9zZ|dt;PMh~;$h6Rw#V4) zX&+3$i^UB{*hi}Q^7P@3B6DxPxw-fNMOa$hs$bYGT;2T_g#{LzvPLS6#OLs%#-A!KSb#-u3jvz_FA%ie{R}j zUM!v?)to;-bv#ZHee2!BRwMJP#SDQ+OQ`!JYCZet6fx|fgKp{n{Nm!~`sQkO`3MWS z>#O-AsKuifvzys{`;!NEF8tQcAf5f_74n$V#npYMB5G%|^OMUrr?Z=jdk+(aNZ((c z9N%}Q=U8ZcL^pPEj+EoRqln-2;uN0q;_9Qt`SHp5ZBkm{NpQ@BAH9SgGR?ZpBdm;{ zfsOaZ^ZA>bn_E-370^QM`uCH|dlNICU0q=1cw08JGQ%91&+PL0-tb>sU0h=Iep?c{ z0$&t7pUq!$;C+RuK`*ghl65?2)U|TC=d*ijC_>ll-rPczn$OPP&hD)n79i~A=H$=& z8=FSmEY2=rYm1Lw&ZyMy?#qUoi;L5nTZ8wCfZ*0HkZar*y7_y3d4F_f&+iNS=3;hT z42d4mRjr$~HM1Mkq-^qXo-7hvp%8X<0u_*t`}@PkU7o&y>$qj$|GK!EBfo*Oesg{@ zN3{LuE!Sk{H`l7bT`i7Lfm=W4dxk>Kk!xOm*x-Z`0^I) zmc(!u@TIPAVHfZ|_k-KS`|9NQ)hYhJx%V*E3~z3ucL}8ziUi`v!6qo9c?$qdxk4)$R(>+Lb9k<2&_4A8Aez~|*gnbZl zA2#FZVs`b5gYJW7`%kmt#ntTi7skZ@VOTl3|MF@vLpuLI3O8gni<@7xKtXo)Ak^SH z9^wT4^Rf?@PtH-7eh?)OK&=^_Ci!KCg1|8fj}MxyfjGbTg(=+iCAL3)d4VkZX7QnL zkjtFBUHpPY)YC6b-FAJsz^+kNmFosCrCuEQdfJ}qMIUh)eoNy1Vxi>s&DBX?r{7tIczJS)-1DQ0=fB?B?fMeMm5(mY zKYDp`g)MV4cm00(2-T&Ig^f4wF0t9Wj=}TO+4VJc>0i7%pJBy-n)mGNnmao8==|fw znU!K~=RjQBwoYGE5a34s`8lc=2yH)J93P)9X)nSTeD0dzvgqqaU@ZneUR+|Ic}xK< z{`%1s-1pAe?0AJ6ZoWhz^8|H+A6`#hAw^pY;(CF)S;@Mk>_PqoCtioQGq3AMbY1&S z`o_Rp2Ox>1*ZMZw#3r_AMx?c&D3i}_w%(jBq1~E2dJkFs3EaYZ5NETi*Q{#%Kt3#f zU*E|LxQog)@S5YJ>c?W{`$(NL>AX)kEG#`l26}a zuWN>#e)948qbzprKT7`pPP<$)l?S0d_t1IhgJ)V@J#-{$sJEW+xD~_8i_>>-R9G3W zcR6bYu5mn^+Rgz*(m{%}l^Y%*n5=R6YPbel;UiJ&S?H|6ct=LX-e7m zVh(Upefb0_!hHt}6WwkIImv&CPn-U>#`oDTnMoCJNJyU4YEfz0J+Nj=_}e+lGmj zeWStd#A0QVkktHV-E~j;Ps=CILu?rXY88hT zo0|)c-rYC>q?17U4cEWvdapg#&oM%TL=f|!nyi5@)N+=@|Z8yGDH1)jq(3RRNgm>WmyC_Jfd^%Dbp(<#LsQY%)Kt1h}A3tv@xoyqW z+#Mgm(zEV9y3DsX=2UdSulV-y{i?PLYGZh1o4kZrtt%;~Zh=Dj2^mR{tyO}<;E0{G z2Y-wM3-P-w%uYY zcf*(xxn{*`DdLP=|*A`m+PBw*|;ek1YV=n3+6SnDbJ(j8uBZ=6U-xbuuEDd%|wXNCUekeYetoV{=>3 zMYb8C13(uLh^}mDXH!p>6F4!}!^nguogB6tk=YkpQMitIMG4nabGG1&$=?b6OOH(i zTy}3{zGQ13S1Bn2_F15G1BQmXi=|0tde;1TY{mcmSN5)KC7pe z2@-6>s0r$wvg*sWdLR!F_}aU3zG+ib+$G`A{(RpxZE=t(xfx- zjO+z%;rE$*0M7fQo)5PR% znT}%1Vju>P%%)>WCVe;XG|^tTQsIG(+j=Zh8?i*X<2E^WA)+6_7Vo7?LatG;&k@_Z z9`_zx>?&5I3V4TNX*7fQ#ODnRb=Iosh0W5H6j9HFuqYCmybYt&r=cyg-tn@p_a1qK zEC`lHP%q^eER;3g#V$>=3nPu_SA90?LRemxDt&BcdQ*md&&%74RG*IJxMTXLdFe;v zD7?;V#1J6bydC<-@vAGbGPp2Q@I;P&#Ci23#j!xqP0nYG+=2!UFss8Hm=nAOv*SIC zsbnpW5d)USaN0VCXayrm;T<9%y1+s3H@SzT0O05df--!yhi1Yslx#3!*|{w{hRQlk zK+ME}ZK-&|e&=-nkVvlq!Y=ce=+;1raW2t>{hiapJS{9Vygy`i<4`9CwDn$3_?P4FBP9Z)#ckHA4cTfsy!k;M{R z?XDK7Gp^?HdN#$<8!b|R^=4ySIb+~zq~OXK>sDeJi_N{O=1|2abY3tsP~2kf0vTeo zki0PmBPC8_-lqpSCB`Ny_Bq)#80w*V>Pp-annbud@v%*$zu)+?o%~Lfw+B znK6E_&q<#=lWn+1hdSL*oT7K>4^^CVwY1UzeC0WJmlcM@R@U~3iBz;zV&_kI$3f(s zv+!+?Pv4?95R+E8`%21-WEv%yEEQ03R$%e0jL6($uZRb&ze>47+l$Q%|9rGZ1&!)bVNRNft;8x-9Raw$L0mzl4?-zU$Cx^1uM0 z(H1KMa^2tU2eusq1TuAU&8{H54BuiZnP(COqN<{>KKY1vq6+?-&Psk?O zQ=4$9{Z@pur}V>GtlIV#ymKoui02NfngBnF7TZdPQxjsy6E4QROQvO9ovtj0iG2(L zGi2`!+Ls_SAm0EA4G~Z72YE?@kRm>zK^5+%<}Je!pKb*e##_AVbsvEC#x@)d3T}!Y z#7)6a2n0)X(i+%n*G$Us?9}QT18D`R!SADpDahi-z^bMPBl=o~M+tTl=kUV;Ttk^K zgQKja>sr4mC<>%HHst>bNbpOk$-LT3HW@f+5c>~I3Ll8wI+7UC^#pu0D}AMtwZF(c zk=kcTbAFa%A;~&Uzzt~bXUU$jSD;qu3 zf7i(Z2$MdPh=RE+D?8uj;eVbIOG3<8rljUnLaoV5Y$Ae#8PC;TBnHG=t)X}9dN4SS zxEKiw!W;P|*&9Y533wC=_p2_5Zq1M^hb|4%5bQBl;UT_4{zD#7C=z8=o+1(A*rUnJ zv}z2ycV(Prhe*D!3|gX=6Ez01YsFDW^gNGRo7FN~Doq`s)Z3+QOLn!UsVq^>BLY zqnIno=S+v2Ru#oJud6^fI`@@iffpz~tpj-9{p5iwnBv3Acs&J>eL3{v$&5y!O5r=R z4D7e1c?A+pB}utVRUhCF2IJHebHTl%FjJ{1S`PHhsi=&wA1r6YcT&1sQBd~*_aLc< zODs;40^OLyO3(^(7ZU4I|G6HH&=C)oX6aiE&W*x-Ft8m8(%f(bCtK=Ut7n$67y=5g zKY?Liw_v4l;tNampVr65DhRWSDwnFw!E$6fyPRmM_?*2t4x;`Q#0lEONt@HzPuekf(wyB}Ji#DiPly z-l_W_XT$IgU?=ru4x@OJ@u^dSW z;I^!BDp%sGJ!21YL|rWDiC=Tt%e|5 z1zmKPCdyyv`bW1yrEae=id6#v+DNYZ)I?k3E1i|wRQueMEaRjunD*HUEy@zg5v zPXe6!kR73F1tSI76$JGfC8$3PyqBYo04ck$>9guUP*ueJOM_G($Am*D})k>aAz5K(B`z%0#Z^u}qU zhD_Dwbdbve3eiZ-UcnMi&z3pSS9PhMQyHnAhW~c|xm>arlyLF)$|d(QC>pJmIC$C!^9XLaC?Ta*Js76tm+f5q+VPfG50e znU`!>5a-=T>z+SFLp*s<@KH_)wo-tZK>omqJ2t|}c(cKzhTwsUfF>_8B@u0XHG}%< zM@mD8=0)qp!A`!K{jTubu-kwS23utGmtie4HRMwjJ`^_i1Bcqg;!^JIV!Es9V+@uv z+8(?7zS+;B!HC;;H^DR=mM50$c3750N#rIjk+$0bgVA!*|^%( zrHA?Kcz4}$&ZuemFt`Y8AEnexXm6AnM@iVSd(gdu*Bil{(41$Hm0}J~mN(F17bDXlX+GOTqN|H44#NG_RbuQ+Yx7AM;$4S2XW+YER|@~0 zUFFs82lW=`v4kt)EZ(ccZ!LHBGUQRteUmureSAC#UCMisK`xtwgLOA8b2(s0k5{U< zF`vXnX(U13OCs?fKwU3P`vjpSQq1(h=r#-P@q<^HtUsi9Q`{UU{;7rtSNzCPdta}+ zY^`zP!p&JprHzrJLTUv6A?=(=Io31Cr}J%-Qa&&ES3N)voS9g&4N6jtge>mdJy7=V z8N_@I>`oyZX2Rx9q=;AJ8kM-Dp=u{232P+G#(}jcHpRlw(bBP5A)kxK!ALW5o|@$o z?>IN&(HDGHQ#EPtaoZfR>h&PgR+EHVd(=7D*8PVojnRN}8Ev1A^)>yzoFdEi4BqWlF#{xMZmz>K| z^@B=6hcGC=^fvmvjAmz|2J+UZL>)^1&K%9UM{^A#qVQj;hPsfv=VMRRkPh)msvBu>z`3f|B`# zu0EQ*^3&{9n3h^zT+p4qlaDA`JO~%JEz%L^%zl~ z7FiOt*g55CXHYA)WV!I)buh+wx$|$o;eFd`RWBe2H55z0%q)d_6m9RjHRPv0dFuL! z409kjxbk6&_`UWcIgyQ}em(es932)Q%%i>j926Fc7z_mw&4eTKI!vm~2boRdF{Qd@ z_X2e7wvTW$_-$>&p;Z|%SA87>&DaDJV+h5Z6H|?g<=q1@(r_sMiQ;VlZG}D^8Gfks=wSorqT@)+Cgs z2@6ewBE*+s+T{gmdTKn04SY=6jHJ&`_+)bP?VU2VELlz~!*o)e81P;|bXx}jNUCPZ z7VsdJgF$T*dcf2?`4}`W9%UBV`st7&05Aw8 zaUshN;piY|;Th#bC+S%Aah|F}^*wdaA$piOp=!7fX2Y^yTuUd--Uu~GVf8RvJ}(+i z7B~aQ0|^~ncXueOrV6g30M%(67?q)#C8rHnM1T~Kq!G8a#mzy5NcG{s(4(olYU!$5 z6M?0pS~yJ?UMtMqN)Nu{0|@6^CMya;vQ(^SRzIkGc&3>|25>LlZY@jehUrkcwuj%d zi-O;4-y9K_u;QC#or{^<29(%Tn(i1y?q#9aQ3bxuPzOKY=u@zwHq>CG;S2>qmH4fL zEG8(C{HdBSHO6*H>?`*Km`WhE%SJNAlzscLgwsy!YQW&V!0TPO9o3Uo@&cuGd8)NE zrw){X5yC<#rvd(x3aS)+H&3Ml;30IMzZy^-3Zcf>NNaLjOaKFrmXM1ptSf98aEVX` zrTfmffH_SHRM3ZjlJ$#O{8^E2fxK38?m%)tx&kfm*6Pdc@am$7%8-*v1t7iK14yp` zNqZd%s1BqF(LOz7Pei)n`b47Cwf#5<#jlZ=rTWWmm_cUd%ms?k>#Lb7UyK(KjNbrr&;(=fh- zLI*I{4h*YpE@NP;suPBcy7gdNT`~$XX$v|s6in~sm5}?~)QKX(3l73@`JRI+&V#xHXv1u`1fw)@>KL-7255vNBB)@$+DlC6u8ydbTreI| z6Ot(g<=ff& zID>C&ngD>EZhVE6%~$#KVRmE`@0+<| zh-5_S0u=X>?GR-fb6h~9bHAiRy|~jH1-YIMgPBHyMN?dN}ViLpVv6I0$kP9t?yLfZ|urM%FV22)VwFdi&sfksXCgJb-H$+PF?imLPR|$ z2SH-{1}5(3nz_q2@+1y^NPUX{2q`v8bfSNFI@Qj47z>>Cm2yT2sgG%DNWIbh`6Nao zM?*JQ2ozemN4vgw-9E0M{=fiR8k!u8KC%b$lu-xbsB)B#CivU~u@PY%c1oD+Ua1G@ zmb(mW&V=HQ#5;gnGru&RP$Mi+52(7$K_8#3XD730g#hwFJZ~Dfhzy&WN`{(>ya-i# z*to5C&JK!l>7-m-HU_nzG2j~@-d|p|Y4uzBp_WJ=_~Rs%mbj~_9LX>uwRO%}Ui#E6 z$dsAn;BF4HH%Z6FWTK7bDjxMorAswh1jO}Yd_!7Z(rU2m+LEYrdMFHX-TLh-wF(&` z8*SD@A+IYA3Fq<|fJWFT8DigbQ{>M%w5DQOBDQZ8c`^XD2E?Fb4h0V}1f5!{5yJDD z46)9@0pSqPI4S5^2uWxF)^n~jXz;)SLo}Su4jmu-`xBIrJ)~~UZeGErxdsKR%Ys>SAM(I^>h=vF;N2KED(+9(po*^nsE$X3o&ib5y`yK?T3?cs9cQHmmfdDo_#0E-(2q3-zc0iwFt zmDrj$*XpBwB8a1tlL%U;uriAB`jHgILZw&ZiSz1@6uwDmw^oPRcU6{$wa!^-E>Beu zGOmqMZ8^_#K!r`V`Yw`Y@oYbSp!-b zl+zh-w`?EHdD+$Osmue}H^^%WAf{rH@R)ECF}%bqS!7?qBx{oL=!fEtEjG@&+GI{i z0i>bZ5$GabGSdP9F|ujJ7pOOQ!JiHkkl0ccqDV;;h?e*b5~BQD$LO^^<3BOeObsLA zde}8vUUM!5L_vWq3V9znGzyz-v&m_mu82yH_|D{Foq{AR1)~9)Zd^RU>8U54Sx*Og z&1#Pi6mW^oABRdANJ z=FXy1FlnUSGFOj@aag3as@bh*YQ8U0xF*$9A7=K?Mw5l`%1UtzI2)lPWk@&W6bMg#Sjp zJI3NhdOF}@fb*!3Y|k{}s0dYjprEoS%c}-xTXudmAqz5sKrZE;l!sC=P(K7-GqBR1 zP1Rd1iZUsPV*NgbvwV3qdCQ2Ob;{gU7EDqHr1yaGi3rT%~CYw6*w?6mK0bs1~|a zM_zR;s(z94*|!+5*;C_!0NnZm$nChJ`V6{bA{#i8vW$7yfDulD%|r97vWUtR7D>;r z1k9Oi5l9N^Py^^1WaEv`B!FmuV5vMM#3|-@0^I}lZnO$2+|D>CYH&u+5>PT7JZ*Ud zpgs+(hH*RD+W6z5QPfo9{ZSZ6y2K%EE`@WJB3bF#CF+N~=My-KbMi!m*%#Zb}M z1( zhEvew*s2afYoHZxOLQ_~?jm8}oW-)yQH)KC1%y+`k*}+Y0!B#hng%SO^Xacyh<)#0 zb9sSpX4+Psc<5SpV2IDclt0zA>LE$3Jw^JG1fs!cih*e2{<8*!nu%B04lI*A>R086 zXN1&o%22ZKzTRW-dt!7Tb|!S#5|>DeNPt`2Az$u^`}#FCkq>QIyXhNmt3F+KXzVyW zrg_u^kbWchS04iCCfyb(q^3Ks?0A6RC;Ho%=ikK_Ndo$L_btvk;k^QYmMaWTqoE2S zoWEF7bhUZ9_Bm;O_ZO!XV3A+tyMm4ri8P4;k|*YR|LaoTK=iydXcI!%MPiL;?EvWh zGYuh1oEuaw_Na#h6JQ&>Rzidpi%0#$2hiUe-F^h zUbW&|+^S|L2g z+zA*w#9!6L46oWZj>gPx0>u<3=^&oAMaIZs;zM#>U-4>m`r2!`ZDdOZEeq9yQ zS1JO6AbW_&SKf^oexrPG0itV3Te|M)>z@9*yXh~Mp?}jLu}MG(x<|(Lss#m9v%*1? zQ4hhxc&w+2UJ@a1D^Yyi54M^Uy5f}|69bN}#36Dy6WE}wccpL;?hAcFvlUbO;(dsC z`is2&Gw58UYl(%?b5ToVr2O62pFF14eBP27!A7?*Ew~Yx|7#Qf5jO?#Rtp8fEpT8| zw%NRXk`x4w7&#t9yy&^S=2{#S>EYC#m@M9)* zEpF#gH3;zbM;$nUHLZM0q8hx0Q7JT;&(jEv9CGkke(z8%?Jm_^X^>$y85%ZFYhbPk z9sG+Iv(bkgQXGo+u0%w;#KB$+q2NqjWI`~tgQPKVIg<}IQ!^9AZUGhUl(r#9d&nb- zQaUX5*FRopQF%ajW}%z&hb#ifn-!d)uWgK4qCc@VEO!7}o;D8~LCtB9Ak8{Dm%ZGJ z6a+W1MRWr6XpneAHBHa8g8*5W;B5n5ddH~d4+6dPGpI-ys^*4c3nUVNa9G^8a6vvs zu!Pn_vr$Zw^lQ0&K%r=9`VZJH!z*3ogu#k*nku8KMjjIRAmd9GqA+Y7D%^yj>Np;X4c;o&?Kl;~#%u1YqELt&JeFT)m z1gP{*imzr$d#S~~r!tw{;}{7khAznDwl@kS3O5x*KyU1ybYP&Yzut!RhYMnh9q-^Tt`wqgvY_Q-SW(iVH?d92Su-W^UgDTpqY3hL15w5Yv0(yBQq_pACfBov(PB_|7wJU% zB!fm%JKL>p;%aAYXMG~2P^1xemlj5Hb!@H}OLRc{Bvq*N zZ7FMv7Pnrz+t(_~Tn?y7`Uf61hai6GX=`fr2MIzC*LbENA~gVrW)31x-TMz{SovGu z5m_6aKMrt#L-rSstp=pLa3+*34LHVt;|u_%Ue7xJEbdSWT$Pg#!Vh|}nov-QG&EA+ zgq4*Y0hyZQqaQRR^6HjhVBsJvtrH)G2xf@b*1=k0b!R_2hiiBk2%3uZdl)gXZgwS~ z`WpRS$A$;&D&vRw>qFp}dGfTo6!8WURv68eT8@BeLTTk~g!V{a{#G|5qHibgzPxm*$Zt z2)seWEB+ba9D~}_WQzQY-(WNQ$ZKh6QVXJL2WMB*1AQ|P^a$ZZSh$qDU-u=w$e-^N znq+FwmVf|hcVj`1BzB9B>Zl)S0nu3od4lE>thzA0Dhxt#q=g;GU=fvF0!rllFo~+V z_MbsNinO|PM&4M{6~Pj89J!$2ek4ua7jZ2^HCFdzzYr#?lHQ7IN`ku$2nluUME6tQ z7M5sWv$!4X*Kz+Afl`89B&qxWnR?KHIUV+!J35U!@)JHq5@rCM9P1O_M5IcvFq=(5 zcTSTN4FHUSGI}Y$X&EPJ41A0liIB4dia4<{0l?Y^L~xud7-?FuigO2wxDP1$jL=By z8-n}(VzZ{x4;n`6vVrnD5fdeGjSciIv{SUEI+*|4sg`q>_6e1H(-x+xKRFqSiQk=K z$1L}fN)y0>&ZbNtDM4AZf!sRjVPj>NQmN$#L~#DM@vu$5jAL?{X3jyQvvyDubE>kE z9yRx*#~qTs()O~QD_C9Q?Map$f8bEKz1V*511VjF&z{f$JQ{^-&K)_+fAJ^xgD`TR zB@$#f9GM~|dgC@-mhT0ksM6Yd43cIveG?FtPAgFG6Vk95;d()5^Zt{-&a$X=+B5eb z`HFW^;2Zca7WMDsF|evZjop&SW$Nv-zz9fHaB^ zJ)j2we1jQ^_fr&`r1nVc@=d2QR!2omdP|7OY&8RB+;di)1QAc0GEw8AHc~zq5x%`PCWK5T4=ReH!E2S2(?gNx$imzh|P;Y7xD)s6%lKWu6c>RDKY)M(Amnq?BQZZ zWQlph1~v(7O*(|Da3h!e0$twA9OUPxBBs2uS=_0S@oy6?0N0(Q#+W=CU>ny+6P?-y z;(}QLF%G*@@B-B4W9IAuq@y9uMh8mbx@cU1kji6mDQvWviwkA8y1>6D2ys`_543sW zPFp?RILzB}N)07>wf`OWoG)L+L$CI+m%O?!V1=G2DY-S~2GqTNG)n3b84nhi5y}z( z!JD*859Q}x%b@@GCPqT1f0kO4v-JoilITbsd`Ic|yk1c&JJ#@nB3iOsyi1IyP9-$V z?H=I4e$xB3QjPf(`;v7pTl%TzpxUcQw13qH4}!K7*3IXCn1$ zKk-F`m6|M3ZI;r+8MCCjjQC5WYoN-BH9H-5%>eYmJEBr6{$#m`ORu97aV1bIYBU-> zc9WL~8;OVA0@ot$F-L)NGdY4ArIArfjue0JVvgVm`(@?Q!!ikZl1&0ysIR`D* z9@CSC66cJi`O!P<_8g!iHv&kE5*LU}h^?|ABYL2Z9|uWILd$(K5ugdZQ9?0hLQgYC z9#XOqr|5zwB;o%*PIOhgiwC{eL#a2z!}eu$(7pUGC)m&x*&Tt2QIN#6eRHvk!g5!e z{03VxNgov>XPW|mJzQ^xYius8O^rih04gHeQ9wwlmF9=Fq)z8KE326!0Je`hcW(wE zV{I;|WA&(oEr(5_W~U=x73Vw5s)uP8DDy+U01P}ZQ`Ca#T*2t4*L!EP{f#Bt2gpvgYA;_7iEqOLyiFM`*AdN0L)La$P%p+!c*@qtr2kMA!4 zWY>Ew8c`CkQ%8Qi0Er7499y+%DXnE7R&@lIABq$)TKMay^I!mO#;rFNshkp#rn4Fb z$n99H05aT5PYTW6V(A(wyki{dKM1LP$zrK*-`xI(1oYM`1=r4Uip3-Yl8h(c0TW84 z1sc}g5eA#}z4w(uS5$yFRofzzGzovSw@`v6BV_(XL{>T(htQyQo3&QhcrwvW;ME*7 z!(a|^M_|=Ve#A;;>bn%l$!NYLE%7YF0Am*qfYEYOl_Z;6gg?Of5E0!tk!9He&?^e_ zEG0(6iNgJck0@A)#f8m1>9>T^)a?OGPR%(@jW2l`7c!6&A|O0FS+Ptjp`^;_nKf&2 zU2y7nEBP?-P7)?bjHFrU@QSlT%%An?$c9a0I;dC)@FVs z*|LSuwj$52&T$$Rg6WkA=!oWPK|>1j7v3nb>N4wOpn!Qf*BvCri5+cz)>qQ@<~ zK#U=^Iv?@cNg7mBZE=H$4_e=96qw83RkR20-plvh;mqZsJ7eqS0wGr(iZ7z7Uukw3 z2OBz^T9_D^Wd5#aPiKLU8y%64T%=H%U$b_$7yn0o$5yD&L4VQx6i0ck*uMe{p0)y! zu%)rO5~(~RMOd$JJURhDL5rg^lSVU5k{^U82dhS-J!^PS9JLfu#1X?l8yG8N>vY)_ zrIV5}jU@4|_(f@G!d2mMf0U9SC~R*Oc9taN6fpek1dJGeZ5A*lW zB&x`loNaRehvXxC8d(Y!#pVG~Nr@f(6OtkY4@27yv#3KWJ#Jg|O^oh-jomH#YChrw ze}W-B9O>AM4b;3o9vDqo%57{n zG(fWpBxetV!L8knXQO_E;RuZe1oc+2XbrKMZj3_>N#t|A?=W;VkW~3q2_sEectZ7D zSe2?I-qf&K(Ii9SUpY}g*4`VVN|dl6vTNX`XIT@YcceNHP&r|w#aP+1q$BhZtY+3N zO-Mt^z-F^8+fY;x_c%J8I9m)+4wFr}7d%efRoC_Ydai+!gcX9XLG7nf#ECs;6-Jqk zekMrO2^y^8qm7@zbYN#XE^)eLc83Oqa!l=))nVLovP&!`Lg^(xh5MEokt&HGHRdx0k+6{qF68WKJ^!M{{FMc5|Kwp}3x4?me+QN+Btf`1|_$&xZ zma(h=rHUKN z2`rwV!_fdu1Blg^v#5dB*NLmfOEaxi!PUNI$?^-LrC7dFP|dWX|T9gduLTZu*fU={1 zk@M4hnQPF&=n3(7t`0sQHqfrWII5jZf8$@s`(v=K{rMvb;4)+U(T?=6X>Kk zGqBH^|4OTwy~Y;8Y;2J=V9Y=naBfBS=m?{-{A?TR$25TA;o} zOvOAB8}_C#*ydq8Q?muM{bM0MYJ{sqi{psTvV^IcOn7u#M$c)~hAsItFP^rKc(7|c z0EHDplVW0kzx~$_f!O4&LC;Kq<{G9BC}@NBII;X|xMt|w#1aFgCfPwnvg|(&UTuvT zbhJdX$Q{UBNS1Y!w|0_Y<477{GdjROh{FSxuEUewuIRoZlU};FXyAQ#GN71!ckxKG z4RB-O9WaMCMN==L>u)PrrZ-)fyLe7yGhIn)G!rCA<2k}Sk5qTL`$kt>Z zjl=$nTwiXTR*2+P*G~oX7F;4~YG`_hV5tSU(Cw!MF(Il>QfqdKJ&SdXe|z{=_dxn5 zx$X-tG6a7YSv@p_w8V@#`Lc(PCmdVSX8|xDN@X1SX`b@>Dwfl*a&}TS+nSkyjKfP8 z;Rik6&Yxc5VE9~Mn9pa?&pV30(L4Ag*}#&|eQog-Z@v3v6kZ|_ z-W;!pWk2}%ui;*i| zr$1^Upcz$(d(wdPs(zT%I+}3Rtzz#a@+UHmrKM5bJ;wO_+8gUzYTkKBz@R(42-LEN zK{~f100~A97-^1vu!pogG{@7<7jqb;oY0F-PMq5mY(LL*a=hrfR~s)o&Z@IL`z^f% zRHDhJPN-sU)4NT(hc~2g9h+p2WE_kFX`+QC<1i;8JoZlP<1~mUf<(`dj|%Z|1fFjd zHx3belMS!U0%05XLFkZ&Kr#xn-n=fo*^S4 z82zjTzc)m-wrKr1!l7O-H;@s_vf;zq?D zpl4LS4!?b(vruAy%%TsM%EsLM2ig z!Q>00E@U2}9PxL7qE+phZdfd#P!FjA&0A0f8ntu8+~zLWr{Z%RbF~1YavN;P7wf8h(Dh)zTlSVas+jF#du_v}i;+dkNxNe7oGnw?HL1p(01d zekHliBFO0=dN+-v{!1RQKlS zK{E}-pFv2TD5Ds^2-J0&l01yO7RI79PGb|;D6ACkLX;oeyN3SJ_$5%V6YSwD-^ ztO=hKa7f*Nd8`k_w|DsdC*dM<8QaWXSrC*;P(3i)Yy6G|;*hAv9N(2%6kF^*$T~1aCvNw!_cUPbkUNa{85;weoKJtPl;!;te!O05BbqRTBG6 zrKB%a({4Kkk{$t%K%`QuSxI@o!4?-punk`D{=f2;pb6ghE4m+_0RBC7(L9r3$iZk0GUwq2b2~|+d;})c zs-^8mIKp+?y%s+m!I1r-d$w!E4gQ1$RF+-~@De-nll@S`O^rtCmUNAfjIj)gCwzBP zZ(9-q25-9%FI)Y2a49Lpr43xyN#wRE52vVAnfHqf`e`3a+4@bIj%+}8rzzy++V!l9 zN0@j^&70bwwY}9F+7wYR0!tAHz`3le1YzV7x1<(CD$PqQnx+DwZj$H{az#W!_Lh!b zn*?>C032KNsB@B7V&0A@aZYJv>{kXUS7BX^4pb1rLs~#!sYvp^d3}%|4a}*b^pN}u zCq!+z7iEArK5~nvGgmTF;uaWoi*1Wvf(6kWZ@sXN3q2vq&=D&mUlwjBlys#;6)%MI zv?wCG+PX$u=zp^6ogB)myd7H#E;G&IfT{|wnHdA*? z*CL1_BnTeu)yo*jif-9j9JFdTS)PbMUS%QdMOb^UClBqN0!NJ154gXZDv1nAe3wY5 z8y?idkyoeFNKS47-QqFBfM{Ba_%&f6%km<150>pER0?-v0BK9;Gq>bRxgb%Ksax5b zyYf_PgC71G6Xw>-kJ(Wbp<)4{wX%V9curRxQ8y+@q|Pwo2!^p%4OMvDlnx?D666e{ zrbcEtURR+GpfxN08Y-aVMNaKX+q)N|P_(|)lY`X8WZ0RkS+YuTR4C>n?VT&s@tl38 zI%KoGu>(aP@l$pyi{{AC(8VKg`jJX$ruvpNAJjpOa6$j~fpgjrYbGWKL%?x$-r}$M zj+yI7UALRDUIYMnnz|p}gH_1slrJ-;bEi?;N_JAdMN4**C8)c88t>a*-O~LWb)DER zFKG}~@Zh0v*GA@jvskD^LhQ2JuU@<_`AM;9`;n0e7*M*?eN^HFj`|V%&^>6 zw!Zj>XtQurFN(@ptc=&z7DGZ?c1q+4?ZUtZmwn=*yrZ!8weHL*8Zg<3=6R*x_LnEg zRV|J?7ur%+3>r~lrxqDMfPZ(2BaC*LXf6NJL+UP21LLBJ6oZ+wfHfqeR(Fv{8T=@h z7f9@73sk%OQSaVJF6nPb9wu*RNCre&2$ftLxpq~}El~<$pkAkV>QFSy8qB~{Z9*zr zkOOMN$!>jv-o8ZSMrQpFW&~yGl(k&yw4zfvqtn?o=u}*TPfw?2-}oVI+J;$!$ZX@2 zvoR+*4&3_>PlkOX)4r4*9<-XS2BO1W zs}QQQ*v}KSziZ&5Ns_Jwr7kpSSK{!r=VSA^G(?8hq-oO%BI1@{1J$Dq`4oxIOEBvb zIiCc|OV2^KQXY*-67!Vkv4>5nQ%}Vmi0JHX`|NfR(UNA+IU*bc`cq)(JXXmcUajgN z#j^IZ^U@60Nc6GnW_po4O%s*i_BP8gIB6SO0%lB1`jeZJBvt|l<9T5q>G7s#L`j8 zed`FquG#jW7m-!NPhhi?ErJQOBCra&A(K(58giTse|1DBRMYk!@tGRzJf#!RLLNC7 zjKx9?6}LF5Co#4~i-XNEjx{sYFfP0Zs7hF42E;tM9b`vOS4c3VYCz0xQ@!9_^%SsM zuogs2gEi7^P0sm~3Y#?i=1(w)UchTRFveKz(lFjrMXi6GK-=!snlIXlml}bembwa- z9yWwk8+RU7qHEl{x_1U^V1GuOGKP9xHdc(O22%IKRQB)U45!jY0KLtkP7?`96Cfte z2sZFh#5HHi@Fq)ih(8-}vHk!RhbChh{my<-_hyaj{p5b^pA9{2(d$}q6ylLA5vJA( zI(X=H!kX#2UWPR3CD}$#Ru_*+;LucoNzTDsA;upf^-R+K1)~p7^59H}(9=`l*p;OB ze1|^oyml{(Rw2|!3+5NN|3X-=LtU28jJ$C&CBgoVl)|wyjnzMB1o(=L*^n4)WW1a^_fXly0rp z?vTwRdoDFn zKb={;3ST(BdoMj~X=XMGBo;}!0}zk8(9}gg=z`1^6a`4nH=+X~Zfwv&v@bUWiO?(O z*3E`zhfXh+T6ZR!=YPXyI}q9YP2|Dv05|=~x?E~2g4%+mB*^>NZ?*aD8A6@hJeIjB z;ymMK^E2HG>QfAvoIe(V`l~C>=UE(>;M|1d0~h*~Nv2lT{EEs17^$TZXk}$OF!{O8 z1=ycBf*cUQiHrXPKK&J+rUfCpelDW;?W)Nui%vdJr?h#~o z#K!PsbNYx+KszNY81G!a`mO!LPUlZHW)P{!MXpM23k__EV;0oIMQbKONWi4OA5*YNNdnwp{2i0x$WHV~^-Ic_b^}6qsNtg26iN;(Bl7sn|r`{`xpw z#USPscnXzA(_5EI1VslnC(reqGDqe?MoXPwk3i!0Ey-?Q6&yL72wgTj*Dpl{=o0Fm zR`VQtp|@0m#|f%b@eX%z|1a)uNPP}>l*u8T z1*%_cWgIl-OKYZDkE{Ta7Wh>+-^4>S$Z@7_F3C{oo^Tvps5L@s_=(FD3ux;Ui*5|{ z=NUDf-0h|9VGy#(6ONxYB5l^V=hPF6pFCEb>S?AsKhxyj!p9KGv+ zqAtmaV<%HF!}M2gwbZE%C-W2M$HJ&`zZ>h#BzBrWy!~UfQwiZ%udh#W;)!f2Zb!M+ zbb}e%r-d(exjno9YrXuWH+-K-M~?_9e|#lS)QdlIRzmR3zG6)N5Wo=6#GD;+8=E>b zJSBPFs-$C=bCs@*mZF zy(Oa{iioUIAY?u8ZqO5odyx6$k>5FeX7vI>h@q+<97^K_;nyu)@9Q~zFBKO0kFVJR z%X489;itAp&?DKuz zS>xrzf3#@_;usL{InsUlyczk*pfZAPcS$DBLkv6R5zKJ`mAUNS!IEm|of{ zwM0E}!tg<6DS}WGlwFF(JM8k#&i_Wk)I6lBTITf2f5{Q44ts3R>ytb>{bGr61QJ0w zf(!J3%7Xn}*QIo4kky&i{G@dH$G3tidw;#<^JWJyd?<@P@}=sF6DMVGUPkz<4*IMN z90*iAT%$<|6ppU|LfaD}9eVs!Rw8{(CeC?s5B*(ml-vu-MgVKuQeaZR_lIn>iSP4& z2k^icqDI&OCQ@jVKjV^a`uUHYKA&BfNok%LO=_+T0TcoGXYz0PaET)2(;)%np(=E7 z+L|sf2u!JX-!&gkfif;)ODSr=mzG>co`S`K}h^tN%~{Z}|}qe}_~{1WeAR_)g# z0H4|~c+ITMDl50t>(C~1vISs(u9}=9nB*jFJR}L&pP~E~qT~&P?#6WuTf9EKnItHZ1MQ z4;oaKlWTHL5b4B5s#k ztdsAo%5YWaMV6TGFNN=4$iF8j9Kk?kfc`|An5E@<3C>zN#2V(P5aja+K>p!_FrC*c znF(fb*wX-2O2Xl1$cHl%L!7e1ldYtOGEc})YU@oU`Bz6HNM~|R!?26w73y;6zKdk2 zaT6eZ+d7s~R)vtOA+dM|NxVV7r>Hj31yYJrd=?Yehx7}FQdO-*G{@I2Nh+xNk3HvI zWXzAZE#x|I`OR9)u$GCOF(ahTSp*d$si_J+N161q&254f-*_gZ3hHfHiw=Ba`H0RIzGg z!UVK}^B1ZzvvUY(|TsNq1kPrCLnBilBz z`|>*yQmY_gnm=StPu|0QFto5AuPBpA(hGP6vQy61i3@zF%o)HxC-7A*yAv9PDR=Z# zWt%Zf@a>by*$MuVXd;Gm!veph;xMzrS21-|7!5W=WY^ z5GI905pz~phhjiSa+c}=y5k5TIVZDk>s6z^4$6Fh0-mXckK(C_gvm*#@cf7nMf5$3 za^NNofb9~i zT)(SvjxO1~$Z*B!Ax0G-$P&Mnzfm~=eo*rO(LKH41VaYL1{=gc=sC_KDH2j)Q04`C z+OL2^L!-Z8HapP`Oc;-3`NsPX2}5=Og`@_ZDD?yY6f8gxfFn)Noh0`ilh0@7edjzRr4}i~*TEKwf3Yq)r@T)etqO*(^(;wBtL@-EB zP|(G^-{NPw=@OUs$XsaRDf`N6Jn`Iajsiv{skmRE)NmYRa-kZ2BB=ft33cF_cWrF0X zd4un81sx}uA}3kmPrhcn>#t3XqwOZt!Q4xlhHVF%$5~zrY1h4v)G51>5+(@|s3eX_aD ztg$*d>G54Y?ppHRwb;9l1G;vMPcPM>w>*UnQy4P)eYtyo;JtZ0YMZ~+pyI;>{Qys@U}UTkL13#3{uwNmmym}!ADx@Nu)~gukL>!j)F9u|@Iuj< z=sDTyKW4XcCKjta$+|4eSYCD5;h(-x@6AQ)ILGp@^-(kc0W~R?^TGU%L2k*Wdi z4Gv$?3L2aLwt+0JpOuS8@&Br>CWdFAsnITgbq8=m6) zRl=4}cp^%I1cpNNH<+VV8o;xh;<7bp`J#aN$Ciz0;YEd;#dK;V!Uh!#HVOzo>w!}f zYY+5djryEQTiSw!T8VGrz?qXB+b;w}hb zv_X|@Jdw2<-6p5amz+Xtf9*%3ydp3i2Fw{HI z;f$WBpndR4Vj6SfX5#YgE*d*KAfx?{WlYbmZC;~haSL?qsShHIb5V;`+#SHh8V+J_ zC|_VO&)Q7>X=DqXPMkmr*}Kw;Z+Xg~z*Tyh=_V2pz_OFkhqRD4-}GlXiaJLF-8yp& zyp4%VmyJK=0||c{%N&}?9~ybFh4Q-4R0%8$Hu z!U7jP8>1c#Z~C*82YEQq_uuBt*&;9!s!U{$?K)m5%XVFa2X?5vfdbkn6q%D&R}3z! zpD(vJ9$DV-df=evo_;LKl*CKdKrng_UIV#5AE5FL#AI#k1?~@od=l1hm?_bU=kT@A zmy^VSA!b`jU-v1v$uD1;MR~3wYH8m)?|9**A-QN}ct-b)-#xt_YYkcz({{#grD7!GdxiPmZ+1Cg<&? z7W;}b&}{i_7zpXX&))8rjSB#(MrQ)-p|jjTO9rts`SUZJ?1NTpT6ZOKs1wGyBD%?W z0hJH1(?V6#C7tBgr)S8b&~G7^qqwSdoo80L@^hbMm#L+!O$0Ui8Ohz#wxFvFe$bDL zX(ff?!qk=k)^RKi!C#-!ma-ni>uIT@{lQpo(ntV^O+s=<-n|HguyceWn(A5{y@I(P zrtm&1m`?pEtQpp`ljoYU2#%bbnv?JCFy~(B3qdHNE5jmsZT`S*LM%Q#0|$re)h9D! zBgMeF5tTde0qpsw9L5*c0Id$Of_F%PxY~*iIxD8^=#T*EW>T)I^W=$+!dp$-K_2>3 zM`<5H<%&M}1AzN$58Ci?e#Z0s@Y3`f-n>Jqg(u$X@PTG@1V_9BgpFae!V!_wIco^KSdUVO^10d4L7;--Y4r(bpkr`cJR%-}cMJ_M zdEl~X-2@07!BZujhtbghzmi2LT3`6toYEPXpUpH$getD`3)w|%N?v$L?rIgw)ZQu$V7M+-9$~hL**Rt zpxpUWbt40!hDn}teU5Pmi_=ehhSi@(lOqGKDOTx#nX9cZ>?dMszvi>YI-{+b7Bm5e zAv{4=B0a=u9HgPv*&V*e%y#~|nTbbAJI){H5QM9HDxxTsVMQoASg{Cg@nfL_{kK6# z#h+W5=Tv4mFtQMIQc0MJDf!_$7I><2c=~)C)8KbF`FbhWnhk*qc1j*>MD95cfBFKJMNO{#NI( zj}T1Ze~5+yw9qIE*`P%)p0#DOxIf#=s!~mY75&}0WAQmq$LIjfK`+0Vobz2xD2NWC zDb!;x2M&Q%%n1rJh}|u-z<87bK?w2!NkdG^`|yOI*=eɁ>6VLLwH;S`)Jp;dh5AVUpI z63@%&k19nT`@5@ieU?^rs<-K$x4IC{=fn^%0O5B}Pg9elNhd}q-vRu~Cw}Em>AypV`|6~{(K^xPzG-kfD^zh+K%0kZNb?O4N7syqm4}-J@JbXsw|8C{k5Z7R=UT+ z$>d9n!o&y9`RlnTB$V@Kd~*20&!L;j2AKy%mk^hD#o;h=bU>W~mpbTzvu3z-2QL}u z!Tp-GGawaNQ3Z#(;2Y#Ov5rSv|5qEC`SK*=u2)XS7F_ce@{xrNF3Cr zH}8<#f&?Wn8uy9E`ExORMGb$}3KpmDS-K*}O(gJ#gnJ4YHSWn_l00x2Jo$-3$&


bfg+TiRY?P14D%=S8I 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

TT!JeZ#&pR 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