Skip to content

Closes #19377: Introduce config context profiles #20058

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Aug 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/models/extras/configcontext.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ A unique human-friendly name.

A numeric value which influences the order in which context data is merged. Contexts with a lower weight are merged before those with a higher weight.

### Profile

The [profile](./configcontextprofile.md) to which the config context is assigned (optional). Profiles can be used to enforce structure in their data.

### Data

The context data expressed in JSON format.
Expand Down
33 changes: 33 additions & 0 deletions docs/models/extras/configcontextprofile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Config Context Profiles

Profiles can be used to organize [configuration contexts](./configcontext.md) and to enforce a desired structure for their data. The later is achieved by defining a [JSON schema](https://json-schema.org/) to which all config context with this profile assigned must comply.

For example, the following schema defines two keys, `size` and `priority`, of which the former is required:

```json
{
"properties": {
"size": {
"type": "integer"
},
"priority": {
"type": "string",
"enum": ["high", "medium", "low"],
"default": "medium"
}
},
"required": [
"size"
]
}
```

## Fields

### Name

A unique human-friendly name.

### Schema

The JSON schema to be enforced for all assigned config contexts (optional).
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ nav:
- Extras:
- Bookmark: 'models/extras/bookmark.md'
- ConfigContext: 'models/extras/configcontext.md'
- ConfigContextProfile: 'models/extras/configcontextprofile.md'
- ConfigTemplate: 'models/extras/configtemplate.md'
- CustomField: 'models/extras/customfield.md'
- CustomFieldChoiceSet: 'models/extras/customfieldchoiceset.md'
Expand Down
8 changes: 8 additions & 0 deletions netbox/core/graphql/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
from core.models import ObjectChange

if TYPE_CHECKING:
from core.graphql.types import DataFileType, DataSourceType
from netbox.core.graphql.types import ObjectChangeType

__all__ = (
'ChangelogMixin',
'SyncedDataMixin',
)


Expand All @@ -25,3 +27,9 @@ def changelog(self, info) -> List[Annotated["ObjectChangeType", strawberry.lazy(
changed_object_id=self.pk
)
return object_changes.restrict(info.context.request.user, 'view')


@strawberry.type
class SyncedDataMixin:
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
3 changes: 2 additions & 1 deletion netbox/dcim/migrations/0205_moduletypeprofile.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.db import migrations, models

import utilities.json
import utilities.jsonschema


class Migration(migrations.Migration):
Expand All @@ -25,7 +26,7 @@ class Migration(migrations.Migration):
('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)),
('schema', models.JSONField(blank=True, null=True, validators=[utilities.jsonschema.validate_schema])),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
Expand Down
15 changes: 2 additions & 13 deletions netbox/dcim/models/modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ class ModuleTypeProfile(PrimaryModel):
schema = models.JSONField(
blank=True,
null=True,
verbose_name=_('schema')
validators=[validate_schema],
verbose_name=_('schema'),
)

clone_fields = ('schema',)
Expand All @@ -49,18 +50,6 @@ class Meta:
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):
"""
Expand Down
40 changes: 36 additions & 4 deletions netbox/extras/api/serializers_/configcontexts.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from dcim.api.serializers_.roles import DeviceRoleSerializer
from dcim.api.serializers_.sites import LocationSerializer, RegionSerializer, SiteSerializer, SiteGroupSerializer
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.models import ConfigContext, Tag
from extras.models import ConfigContext, ConfigContextProfile, Tag
from netbox.api.fields import SerializedPKRelatedField
from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer, TenantGroupSerializer
Expand All @@ -15,11 +15,43 @@
from virtualization.models import Cluster, ClusterGroup, ClusterType

__all__ = (
'ConfigContextProfileSerializer',
'ConfigContextSerializer',
)


class ConfigContextProfileSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer):
tags = serializers.SlugRelatedField(
queryset=Tag.objects.all(),
slug_field='slug',
required=False,
many=True
)
data_source = DataSourceSerializer(
nested=True,
required=False
)
data_file = DataFileSerializer(
nested=True,
read_only=True
)

class Meta:
model = ConfigContextProfile
fields = [
'id', 'url', 'display_url', 'display', 'name', 'description', 'schema', 'tags', 'comments', 'data_source',
'data_path', 'data_file', 'data_synced', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')


class ConfigContextSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer):
profile = ConfigContextProfileSerializer(
nested=True,
required=False,
allow_null=True,
default=None,
)
regions = SerializedPKRelatedField(
queryset=Region.objects.all(),
serializer=RegionSerializer,
Expand Down Expand Up @@ -122,9 +154,9 @@ class ConfigContextSerializer(ChangeLogMessageSerializer, ValidatedModelSerializ
class Meta:
model = ConfigContext
fields = [
'id', 'url', 'display_url', 'display', 'name', 'weight', 'description', 'is_active', 'regions',
'id', 'url', 'display_url', 'display', 'name', 'weight', 'profile', 'description', 'is_active', 'regions',
'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path',
'data_file', 'data_synced', 'data', 'created', 'last_updated',
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file',
'data_synced', 'data', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
1 change: 1 addition & 0 deletions netbox/extras/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
router.register('image-attachments', views.ImageAttachmentViewSet)
router.register('journal-entries', views.JournalEntryViewSet)
router.register('config-contexts', views.ConfigContextViewSet)
router.register('config-context-profiles', views.ConfigContextProfileViewSet)
router.register('config-templates', views.ConfigTemplateViewSet)
router.register('scripts', views.ScriptViewSet, basename='script')

Expand Down
6 changes: 6 additions & 0 deletions netbox/extras/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,12 @@ class JournalEntryViewSet(NetBoxModelViewSet):
# Config contexts
#

class ConfigContextProfileViewSet(SyncedDataMixin, NetBoxModelViewSet):
queryset = ConfigContextProfile.objects.all()
serializer_class = serializers.ConfigContextProfileSerializer
filterset_class = filtersets.ConfigContextProfileFilterSet


class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
queryset = ConfigContext.objects.all()
serializer_class = serializers.ConfigContextSerializer
Expand Down
41 changes: 41 additions & 0 deletions netbox/extras/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
__all__ = (
'BookmarkFilterSet',
'ConfigContextFilterSet',
'ConfigContextProfileFilterSet',
'ConfigTemplateFilterSet',
'CustomFieldChoiceSetFilterSet',
'CustomFieldFilterSet',
Expand Down Expand Up @@ -588,11 +589,51 @@ def search(self, queryset, name, value):
)


class ConfigContextProfileFilterSet(NetBoxModelFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
data_source_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(),
label=_('Data source (ID)'),
)
data_file_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(),
label=_('Data file (ID)'),
)

class Meta:
model = ConfigContextProfile
fields = (
'id', 'name', 'description', 'auto_sync_enabled', 'data_synced',
)

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 ConfigContextFilterSet(ChangeLoggedModelFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
profile_id = django_filters.ModelMultipleChoiceFilter(
queryset=ConfigContextProfile.objects.all(),
label=_('Profile (ID)'),
)
profile = django_filters.ModelMultipleChoiceFilter(
field_name='profile__name',
queryset=ConfigContextProfile.objects.all(),
to_field_name='name',
label=_('Profile (name)'),
)
region_id = django_filters.ModelMultipleChoiceFilter(
field_name='regions',
queryset=Region.objects.all(),
Expand Down
29 changes: 28 additions & 1 deletion netbox/extras/forms/bulk_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

__all__ = (
'ConfigContextBulkEditForm',
'ConfigContextProfileBulkEditForm',
'ConfigTemplateBulkEditForm',
'CustomFieldBulkEditForm',
'CustomFieldChoiceSetBulkEditForm',
Expand Down Expand Up @@ -317,6 +318,25 @@ class TagBulkEditForm(ChangelogMessageMixin, BulkEditForm):
nullable_fields = ('description',)


class ConfigContextProfileBulkEditForm(NetBoxModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ConfigContextProfile.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
label=_('Description'),
required=False,
max_length=100
)
comments = CommentField()

model = ConfigContextProfile
fieldsets = (
FieldSet('description',),
)
nullable_fields = ('description',)


class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ConfigContext.objects.all(),
Expand All @@ -327,6 +347,10 @@ class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm):
required=False,
min_value=0
)
profile = DynamicModelChoiceField(
queryset=ConfigContextProfile.objects.all(),
required=False
)
is_active = forms.NullBooleanField(
label=_('Is active'),
required=False,
Expand All @@ -338,7 +362,10 @@ class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm):
max_length=100
)

nullable_fields = ('description',)
fieldsets = (
FieldSet('weight', 'profile', 'is_active', 'description'),
)
nullable_fields = ('profile', 'description')


class ConfigTemplateBulkEditForm(ChangelogMessageMixin, BulkEditForm):
Expand Down
10 changes: 10 additions & 0 deletions netbox/extras/forms/bulk_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
)

__all__ = (
'ConfigContextProfileImportForm',
'ConfigTemplateImportForm',
'CustomFieldChoiceSetImportForm',
'CustomFieldImportForm',
Expand Down Expand Up @@ -149,6 +150,15 @@ class Meta:
)


class ConfigContextProfileImportForm(NetBoxModelImportForm):

class Meta:
model = ConfigContextProfile
fields = [
'name', 'description', 'schema', 'comments', 'tags',
]


class ConfigTemplateImportForm(CSVModelForm):

class Meta:
Expand Down
Loading