diff --git a/docs/development/application-registry.md b/docs/development/application-registry.md index c7ac6ca46..c845cd5a7 100644 --- a/docs/development/application-registry.md +++ b/docs/development/application-registry.md @@ -53,6 +53,10 @@ This store maintains all registered items for plugins, such as navigation menus, A dictionary mapping each model (identified by its app and label) to its search index class, if one has been registered for it. +### `tables` + +A dictionary mapping table classes to lists of extra columns that have been registered by plugins using the `register_table_column()` utility function. Each column is defined as a tuple of name and column instance. + ### `views` A hierarchical mapping of registered views for each model. Mappings are added using the `register_model_view()` decorator, and URLs paths can be generated from these using `get_model_urls()`. diff --git a/docs/plugins/development/tables.md b/docs/plugins/development/tables.md index f846139f0..9d57a9603 100644 --- a/docs/plugins/development/tables.md +++ b/docs/plugins/development/tables.md @@ -87,3 +87,28 @@ The table column classes listed below are supported for use in plugins. These cl options: members: - __init__ + +## Extending Core Tables + +!!! info "This feature was introduced in NetBox v3.7." + +Plugins can register their own custom columns on core tables using the `register_table_column()` utility function. This allows a plugin to attach additional information, such as relationships to its own models, to built-in object lists. + +```python +import django_tables2 +from django.utils.translation import gettext_lazy as _ + +from dcim.tables import SiteTable +from utilities.tables import register_table_column + +mycol = django_tables2.Column( + verbose_name=_('My Column'), + accessor=django_tables2.A('description') +) + +register_table_column(mycol, 'foo', SiteTable) +``` + +You'll typically want to define an accessor identifying the desired model field or relationship when defining a custom column. See the [django-tables2 documentation](https://django-tables2.readthedocs.io/) for more information on creating custom columns. + +::: utilities.tables.register_table_column diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index 4d0acbb77..a567a9fed 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -1,12 +1,10 @@ from django import forms from django.contrib.auth import get_user_model -from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ from core.choices import * from core.models import * from extras.forms.mixins import SavedFiltersMixin -from extras.utils import FeatureQuery from netbox.forms import NetBoxModelFilterSetForm from netbox.utils import get_data_backend_choices from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm @@ -69,7 +67,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm): ) object_type = ContentTypeChoiceField( label=_('Object Type'), - queryset=ContentType.objects.filter(FeatureQuery('jobs').get_query()), + queryset=ContentType.objects.with_feature('jobs'), required=False, ) status = forms.MultipleChoiceField( diff --git a/netbox/core/migrations/0003_job.py b/netbox/core/migrations/0003_job.py index ab6f058ff..f2fe41afb 100644 --- a/netbox/core/migrations/0003_job.py +++ b/netbox/core/migrations/0003_job.py @@ -4,7 +4,6 @@ from django.conf import settings import django.core.validators from django.db import migrations, models import django.db.models.deletion -import extras.utils class Migration(migrations.Migration): @@ -30,7 +29,7 @@ class Migration(migrations.Migration): ('status', models.CharField(default='pending', max_length=30)), ('data', models.JSONField(blank=True, null=True)), ('job_id', models.UUIDField(unique=True)), - ('object_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('jobs'), on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype')), + ('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype')), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), ], options={ diff --git a/netbox/core/models/contenttypes.py b/netbox/core/models/contenttypes.py index 18c16a1c2..0731871ec 100644 --- a/netbox/core/models/contenttypes.py +++ b/netbox/core/models/contenttypes.py @@ -21,6 +21,24 @@ class ContentTypeManager(ContentTypeManager_): q |= Q(app_label=app_label, model__in=models) return self.get_queryset().filter(q) + def with_feature(self, feature): + """ + Return the ContentTypes only for models which are registered as supporting the specified feature. For example, + we can find all ContentTypes for models which support webhooks with + + ContentType.objects.with_feature('webhooks') + """ + if feature not in registry['model_features']: + raise KeyError( + f"{feature} is not a registered model feature! Valid features are: {registry['model_features'].keys()}" + ) + + q = Q() + for app_label, models in registry['model_features'][feature].items(): + q |= Q(app_label=app_label, model__in=models) + + return self.get_queryset().filter(q) + class ContentType(ContentType_): """ diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 78f05e462..cf40c0bd5 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -6,7 +6,6 @@ from urllib.parse import urlparse from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.validators import RegexValidator from django.db import models @@ -368,7 +367,7 @@ class AutoSyncRecord(models.Model): related_name='+' ) object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE, related_name='+' ) diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 4e9a93bfb..5b9b41e53 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -3,7 +3,7 @@ import uuid import django_rq from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models from django.urls import reverse @@ -11,8 +11,8 @@ from django.utils import timezone from django.utils.translation import gettext as _ from core.choices import JobStatusChoices +from core.models import ContentType from extras.constants import EVENT_JOB_END, EVENT_JOB_START -from extras.utils import FeatureQuery from netbox.config import get_config from netbox.constants import RQ_QUEUE_DEFAULT from utilities.querysets import RestrictedQuerySet @@ -28,9 +28,8 @@ class Job(models.Model): Tracks the lifecycle of a job which represents a background task (e.g. the execution of a custom script). """ object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', related_name='jobs', - limit_choices_to=FeatureQuery('jobs'), on_delete=models.CASCADE, ) object_id = models.PositiveBigIntegerField( @@ -123,6 +122,15 @@ class Job(models.Model): def get_status_color(self): return JobStatusChoices.colors.get(self.status) + def clean(self): + super().clean() + + # Validate the assigned object type + if self.object_type not in ContentType.objects.with_feature('jobs'): + raise ValidationError( + _("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type) + ) + @property def duration(self): if not self.completed: diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 9bcd824e6..e276ae3e5 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -2,7 +2,6 @@ import itertools from collections import defaultdict from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models from django.db.models import Sum @@ -10,12 +9,12 @@ from django.dispatch import Signal from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from core.models import ContentType from dcim.choices import * from dcim.constants import * from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node from netbox.models import ChangeLoggedModel, PrimaryModel - from utilities.fields import ColorField from utilities.querysets import RestrictedQuerySet from utilities.utils import to_meters @@ -258,7 +257,7 @@ class CableTermination(ChangeLoggedModel): verbose_name=_('end') ) termination_type = models.ForeignKey( - to=ContentType, + 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 5110835f4..fb3d6333e 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -1,5 +1,4 @@ from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -709,7 +708,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): db_index=True ) component_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS, on_delete=models.PROTECT, related_name='+', diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 94568459e..c24ed4d86 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1,7 +1,6 @@ from functools import cached_property from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -1181,7 +1180,7 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin): db_index=True ) component_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', limit_choices_to=MODULAR_COMPONENT_MODELS, on_delete=models.PROTECT, related_name='+', diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 73b42f4c2..4e1b47503 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,10 +1,10 @@ from django.contrib.auth import get_user_model -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers from core.api.serializers import JobSerializer from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer +from core.models import ContentType from dcim.api.nested_serializers import ( NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer, NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, @@ -14,7 +14,6 @@ from drf_spectacular.utils import extend_schema_field from drf_spectacular.types import OpenApiTypes from extras.choices import * from extras.models import * -from extras.utils import FeatureQuery from netbox.api.exceptions import SerializerNotFound from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer @@ -64,7 +63,7 @@ __all__ = ( class WebhookSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail') content_types = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()), + queryset=ContentType.objects.with_feature('webhooks'), many=True ) @@ -85,7 +84,7 @@ class WebhookSerializer(NetBoxModelSerializer): class CustomFieldSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail') content_types = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()), + queryset=ContentType.objects.with_feature('custom_fields'), many=True ) type = ChoiceField(choices=CustomFieldTypeChoices) @@ -152,7 +151,7 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer): class CustomLinkSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') content_types = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()), + queryset=ContentType.objects.with_feature('custom_links'), many=True ) @@ -171,7 +170,7 @@ class CustomLinkSerializer(ValidatedModelSerializer): class ExportTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail') content_types = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), + queryset=ContentType.objects.with_feature('export_templates'), many=True ) data_source = NestedDataSourceSerializer( @@ -216,7 +215,7 @@ class SavedFilterSerializer(ValidatedModelSerializer): class BookmarkSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail') object_type = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('bookmarks').get_query()), + queryset=ContentType.objects.with_feature('bookmarks'), ) object = serializers.SerializerMethodField(read_only=True) user = NestedUserSerializer() @@ -240,7 +239,7 @@ class BookmarkSerializer(ValidatedModelSerializer): class TagSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail') object_types = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), + queryset=ContentType.objects.with_feature('tags'), many=True, required=False ) diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index 7f0229f88..8cfbb4c61 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -32,13 +32,20 @@ __all__ = ( ) -def get_content_type_labels(): +def get_object_type_choices(): return [ (content_type_identifier(ct), content_type_name(ct)) for ct in ContentType.objects.public().order_by('app_label', 'model') ] +def get_bookmarks_object_type_choices(): + return [ + (content_type_identifier(ct), content_type_name(ct)) + for ct in ContentType.objects.with_feature('bookmarks').order_by('app_label', 'model') + ] + + def get_models_from_content_types(content_types): """ Return a list of models corresponding to the given content types, identified by natural key. @@ -158,7 +165,7 @@ class ObjectCountsWidget(DashboardWidget): class ConfigForm(WidgetConfigForm): models = forms.MultipleChoiceField( - choices=get_content_type_labels + choices=get_object_type_choices ) filters = forms.JSONField( required=False, @@ -207,7 +214,7 @@ class ObjectListWidget(DashboardWidget): class ConfigForm(WidgetConfigForm): model = forms.ChoiceField( - choices=get_content_type_labels + choices=get_object_type_choices ) page_size = forms.IntegerField( required=False, @@ -343,8 +350,7 @@ class BookmarksWidget(DashboardWidget): class ConfigForm(WidgetConfigForm): object_types = forms.MultipleChoiceField( - # TODO: Restrict the choices by FeatureQuery('bookmarks') - choices=get_content_type_labels, + choices=get_bookmarks_object_type_choices, required=False ) order_by = forms.ChoiceField( diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index ecfe641a5..181b1f8d3 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -6,7 +6,6 @@ from django.utils.translation import gettext_lazy as _ from core.models import ContentType from extras.choices import * from extras.models import * -from extras.utils import FeatureQuery from netbox.forms import NetBoxModelImportForm from utilities.forms import CSVModelForm from utilities.forms.fields import ( @@ -29,8 +28,7 @@ __all__ = ( class CustomFieldImportForm(CSVModelForm): content_types = CSVMultipleContentTypeField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_fields'), + queryset=ContentType.objects.with_feature('custom_fields'), help_text=_("One or more assigned object types") ) type = CSVChoiceField( @@ -95,8 +93,7 @@ class CustomFieldChoiceSetImportForm(CSVModelForm): class CustomLinkImportForm(CSVModelForm): content_types = CSVMultipleContentTypeField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_links'), + queryset=ContentType.objects.with_feature('custom_links'), help_text=_("One or more assigned object types") ) @@ -111,8 +108,7 @@ class CustomLinkImportForm(CSVModelForm): class ExportTemplateImportForm(CSVModelForm): content_types = CSVMultipleContentTypeField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('export_templates'), + queryset=ContentType.objects.with_feature('export_templates'), help_text=_("One or more assigned object types") ) @@ -149,8 +145,7 @@ class SavedFilterImportForm(CSVModelForm): class WebhookImportForm(NetBoxModelImportForm): content_types = CSVMultipleContentTypeField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('webhooks'), + queryset=ContentType.objects.with_feature('webhooks'), help_text=_("One or more assigned object types") ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index a6e4135d4..5da3ba1e6 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -6,7 +6,6 @@ from core.models import ContentType, DataFile, DataSource from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * -from extras.utils import FeatureQuery from netbox.forms.base import NetBoxModelFilterSetForm from tenancy.models import Tenant, TenantGroup from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice @@ -44,7 +43,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): )), ) content_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()), + queryset=ContentType.objects.with_feature('custom_fields'), required=False, label=_('Object type') ) @@ -113,7 +112,7 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm): ) content_types = ContentTypeMultipleChoiceField( label=_('Content types'), - queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()), + queryset=ContentType.objects.with_feature('custom_links'), required=False ) enabled = forms.NullBooleanField( @@ -156,7 +155,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): } ) content_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), + queryset=ContentType.objects.with_feature('export_templates'), required=False, label=_('Content types') ) @@ -184,7 +183,7 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm): ) content_type_id = ContentTypeChoiceField( label=_('Content type'), - queryset=ContentType.objects.filter(FeatureQuery('image_attachments').get_query()), + queryset=ContentType.objects.with_feature('image_attachments'), required=False ) name = forms.CharField( @@ -233,7 +232,7 @@ class WebhookFilterForm(NetBoxModelFilterSetForm): (_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')), ) content_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()), + queryset=ContentType.objects.with_feature('webhooks'), required=False, label=_('Object type') ) @@ -289,12 +288,12 @@ class WebhookFilterForm(NetBoxModelFilterSetForm): class TagFilterForm(SavedFiltersMixin, FilterForm): model = Tag content_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), + queryset=ContentType.objects.with_feature('tags'), required=False, label=_('Tagged object type') ) for_object_type_id = ContentTypeChoiceField( - queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), + queryset=ContentType.objects.with_feature('tags'), required=False, label=_('Allowed object type') ) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index eff0edf86..1a4d45f9a 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -10,7 +10,6 @@ from core.models import ContentType from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * -from extras.utils import FeatureQuery from netbox.config import get_config, PARAMS from netbox.forms import NetBoxModelForm from tenancy.models import Tenant, TenantGroup @@ -43,8 +42,7 @@ __all__ = ( class CustomFieldForm(BootstrapMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_fields'), + queryset=ContentType.objects.with_feature('custom_fields') ) object_type = ContentTypeChoiceField( label=_('Object type'), @@ -114,8 +112,7 @@ class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm): class CustomLinkForm(BootstrapMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_links') + queryset=ContentType.objects.with_feature('custom_links') ) fieldsets = ( @@ -142,8 +139,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm): class ExportTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('export_templates') + queryset=ContentType.objects.with_feature('export_templates') ) template_code = forms.CharField( label=_('Template code'), @@ -210,8 +206,7 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm): class BookmarkForm(BootstrapMixin, forms.ModelForm): object_type = ContentTypeChoiceField( label=_('Object type'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('bookmarks').get_query() + queryset=ContentType.objects.with_feature('bookmarks') ) class Meta: @@ -222,8 +217,7 @@ class BookmarkForm(BootstrapMixin, forms.ModelForm): class WebhookForm(NetBoxModelForm): content_types = ContentTypeMultipleChoiceField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('webhooks') + queryset=ContentType.objects.with_feature('webhooks') ) fieldsets = ( @@ -257,8 +251,7 @@ class TagForm(BootstrapMixin, forms.ModelForm): slug = SlugField() object_types = ContentTypeMultipleChoiceField( label=_('Object types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('tags'), + queryset=ContentType.objects.with_feature('tags'), required=False ) diff --git a/netbox/extras/migrations/0001_squashed.py b/netbox/extras/migrations/0001_squashed.py index 2fdcc07eb..6f1f77e53 100644 --- a/netbox/extras/migrations/0001_squashed.py +++ b/netbox/extras/migrations/0001_squashed.py @@ -88,7 +88,7 @@ class Migration(migrations.Migration): ('secret', models.CharField(blank=True, max_length=255)), ('ssl_verification', models.BooleanField(default=True)), ('ca_file_path', models.CharField(blank=True, max_length=4096, null=True)), - ('content_types', models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuery('webhooks'), related_name='webhooks', to='contenttypes.ContentType')), + ('content_types', models.ManyToManyField(related_name='webhooks', to='contenttypes.ContentType')), ], options={ 'ordering': ('name',), @@ -151,7 +151,7 @@ class Migration(migrations.Migration): ('status', models.CharField(default='pending', max_length=30)), ('data', models.JSONField(blank=True, null=True)), ('job_id', models.UUIDField(unique=True)), - ('obj_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('jobs'), on_delete=django.db.models.deletion.CASCADE, related_name='job_results', to='contenttypes.contenttype')), + ('obj_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_results', to='contenttypes.contenttype')), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), ], options={ @@ -184,7 +184,7 @@ class Migration(migrations.Migration): ('mime_type', models.CharField(blank=True, max_length=50)), ('file_extension', models.CharField(blank=True, max_length=15)), ('as_attachment', models.BooleanField(default=True)), - ('content_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('export_templates'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), ], options={ 'ordering': ['content_type', 'name'], @@ -201,7 +201,7 @@ class Migration(migrations.Migration): ('group_name', models.CharField(blank=True, max_length=50)), ('button_class', models.CharField(default='default', max_length=30)), ('new_window', models.BooleanField(default=False)), - ('content_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('custom_links'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), ], options={ 'ordering': ['group_name', 'weight', 'name'], @@ -223,7 +223,7 @@ class Migration(migrations.Migration): ('validation_maximum', models.PositiveIntegerField(blank=True, null=True)), ('validation_regex', models.CharField(blank=True, max_length=500, validators=[utilities.validators.validate_regex])), ('choices', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, null=True, size=None)), - ('content_types', models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuery('custom_fields'), related_name='custom_fields', to='contenttypes.ContentType')), + ('content_types', models.ManyToManyField(related_name='custom_fields', to='contenttypes.ContentType')), ], options={ 'ordering': ['weight', 'name'], diff --git a/netbox/extras/migrations/0094_tag_object_types.py b/netbox/extras/migrations/0094_tag_object_types.py index 944ef64b2..8bb760980 100644 --- a/netbox/extras/migrations/0094_tag_object_types.py +++ b/netbox/extras/migrations/0094_tag_object_types.py @@ -1,5 +1,4 @@ from django.db import migrations, models -import extras.utils class Migration(migrations.Migration): @@ -13,7 +12,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='tag', name='object_types', - field=models.ManyToManyField(blank=True, limit_choices_to=extras.utils.FeatureQuery('tags'), related_name='+', to='contenttypes.contenttype'), + field=models.ManyToManyField(blank=True, related_name='+', to='contenttypes.contenttype'), ), migrations.RenameIndex( model_name='taggeditem', diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index ac9c60998..5db0bba57 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -1,10 +1,11 @@ from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from core.models import ContentType from extras.choices import * from ..querysets import ObjectChangeQuerySet @@ -48,7 +49,7 @@ class ObjectChange(models.Model): choices=ObjectChangeActionChoices ) changed_object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.PROTECT, related_name='+' ) @@ -58,7 +59,7 @@ class ObjectChange(models.Model): fk_field='changed_object_id' ) related_object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.PROTECT, related_name='+', blank=True, @@ -104,6 +105,17 @@ class ObjectChange(models.Model): self.user_name ) + def clean(self): + super().clean() + + # Validate the assigned object type + if self.changed_object_type not in ContentType.objects.with_feature('change_logging'): + raise ValidationError( + _("Change logging is not supported for this object type ({type}).").format( + type=self.changed_object_type + ) + ) + def save(self, *args, **kwargs): # Record the user's name and the object's representation as static strings diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 8d8ad545f..08190d20f 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -5,7 +5,6 @@ from datetime import datetime, date import django_filters from django import forms from django.conf import settings -from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.validators import RegexValidator, ValidationError from django.db import models @@ -13,9 +12,9 @@ from django.urls import reverse from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ +from core.models import ContentType from extras.choices import * from extras.data import CHOICE_SETS -from extras.utils import FeatureQuery from netbox.models import ChangeLoggedModel from netbox.models.features import CloningMixin, ExportTemplatesMixin from netbox.search import FieldTypes @@ -59,9 +58,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): content_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', related_name='custom_fields', - limit_choices_to=FeatureQuery('custom_fields'), help_text=_('The object(s) to which this field applies.') ) type = models.CharField( @@ -72,7 +70,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): help_text=_('The type of data this custom field holds') ) object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.PROTECT, blank=True, null=True, diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 90e8027b4..67b455ab4 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -3,7 +3,6 @@ import urllib.parse from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.core.validators import ValidationError from django.db import models @@ -14,10 +13,11 @@ from django.utils.formats import date_format from django.utils.translation import gettext, gettext_lazy as _ from rest_framework.utils.encoders import JSONEncoder +from core.models import ContentType from extras.choices import * from extras.conditions import ConditionSet from extras.constants import * -from extras.utils import FeatureQuery, image_upload +from extras.utils import image_upload from netbox.config import get_config from netbox.models import ChangeLoggedModel from netbox.models.features import ( @@ -45,10 +45,9 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedMo Each Webhook can be limited to firing only on certain actions or certain object types. """ content_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', related_name='webhooks', verbose_name=_('object types'), - limit_choices_to=FeatureQuery('webhooks'), help_text=_("The object(s) to which this Webhook applies.") ) name = models.CharField( @@ -235,7 +234,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): code to be rendered with an object as context. """ content_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', related_name='custom_links', help_text=_('The object type(s) to which this link applies.') ) @@ -331,7 +330,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): content_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', related_name='export_templates', help_text=_('The object type(s) to which this template applies.') ) @@ -440,7 +439,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): A set of predefined keyword parameters that can be reused to filter for specific objects. """ content_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', related_name='saved_filters', help_text=_('The object type(s) to which this filter applies.') ) @@ -520,7 +519,7 @@ class ImageAttachment(ChangeLoggedModel): An uploaded image which is associated with an object. """ content_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE ) object_id = models.PositiveBigIntegerField() @@ -560,6 +559,15 @@ class ImageAttachment(ChangeLoggedModel): filename = self.image.name.rsplit('/', 1)[-1] return filename.split('_', 2)[2] + def clean(self): + super().clean() + + # Validate the assigned object type + if self.content_type not in ContentType.objects.with_feature('image_attachments'): + raise ValidationError( + _("Image attachments cannot be assigned to this object type ({type}).").format(type=self.content_type) + ) + def delete(self, *args, **kwargs): _name = self.image.name @@ -605,7 +613,7 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat might record a new journal entry when a device undergoes maintenance, or when a prefix is expanded. """ assigned_object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE ) assigned_object_id = models.PositiveBigIntegerField() @@ -644,9 +652,8 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat def clean(self): super().clean() - # Prevent the creation of journal entries on unsupported models - permitted_types = ContentType.objects.filter(FeatureQuery('journaling').get_query()) - if self.assigned_object_type not in permitted_types: + # Validate the assigned object type + if self.assigned_object_type not in ContentType.objects.with_feature('journaling'): raise ValidationError( _("Journaling is not supported for this object type ({type}).").format(type=self.assigned_object_type) ) @@ -664,7 +671,7 @@ class Bookmark(models.Model): auto_now_add=True ) object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.PROTECT ) object_id = models.PositiveBigIntegerField() @@ -695,6 +702,15 @@ class Bookmark(models.Model): return str(self.object) return super().__str__() + def clean(self): + super().clean() + + # Validate the assigned object type + if self.object_type not in ContentType.objects.with_feature('bookmarks'): + raise ValidationError( + _("Bookmarks cannot be assigned to this object type ({type}).").format(type=self.object_type) + ) + class ConfigRevision(models.Model): """ diff --git a/netbox/extras/models/search.py b/netbox/extras/models/search.py index bebcabd31..9ba779642 100644 --- a/netbox/extras/models/search.py +++ b/netbox/extras/models/search.py @@ -1,6 +1,5 @@ import uuid -from django.contrib.contenttypes.models import ContentType from django.db import models from django.utils.translation import gettext_lazy as _ @@ -27,7 +26,7 @@ class CachedValue(models.Model): editable=False ) object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE, related_name='+' ) diff --git a/netbox/extras/models/staging.py b/netbox/extras/models/staging.py index b0df9e26e..2e848a817 100644 --- a/netbox/extras/models/staging.py +++ b/netbox/extras/models/staging.py @@ -2,7 +2,6 @@ import logging from django.contrib.auth import get_user_model from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.db import models, transaction from django.utils.translation import gettext_lazy as _ @@ -71,7 +70,7 @@ class StagedChange(ChangeLoggedModel): choices=ChangeActionChoices ) object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE, related_name='+' ) diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index de4f15509..3aba6df60 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -1,13 +1,10 @@ from django.conf import settings -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from taggit.models import TagBase, GenericTaggedItemBase -from extras.utils import FeatureQuery from netbox.models import ChangeLoggedModel from netbox.models.features import CloningMixin, ExportTemplatesMixin from utilities.choices import ColorChoices @@ -37,9 +34,8 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase): blank=True, ) object_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', related_name='+', - limit_choices_to=FeatureQuery('tags'), blank=True, help_text=_("The object type(s) to which this this tag can be applied.") ) diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index 7b9356efb..c6b2de188 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -1,5 +1,3 @@ -from django.db.models import Q -from django.utils.deconstruct import deconstructible from taggit.managers import _TaggableManager from netbox.registry import registry @@ -31,29 +29,6 @@ def image_upload(instance, filename): return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename) -@deconstructible -class FeatureQuery: - """ - Helper class that delays evaluation of the registry contents for the functionality store - until it has been populated. - """ - def __init__(self, feature): - self.feature = feature - - def __call__(self): - return self.get_query() - - def get_query(self): - """ - Given an extras feature, return a Q object for content type lookup - """ - query = Q() - for app_label, models in registry['model_features'][self.feature].items(): - query |= Q(app_label=app_label, model__in=models) - - return query - - def register_features(model, features): """ Register model features in the application registry. diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 5d355102f..1e4e7dac3 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -1,13 +1,12 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation -from django.contrib.contenttypes.models import ContentType from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from netbox.models import ChangeLoggedModel, PrimaryModel from ipam.choices import * from ipam.constants import * +from netbox.models import ChangeLoggedModel, PrimaryModel __all__ = ( 'FHRPGroup', @@ -78,7 +77,7 @@ class FHRPGroup(PrimaryModel): class FHRPGroupAssignment(ChangeLoggedModel): interface_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE ) interface_id = models.PositiveBigIntegerField() diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 934cb98c7..7dc0ac445 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -1,6 +1,5 @@ import netaddr from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models from django.db.models import F @@ -9,6 +8,7 @@ from django.urls import reverse from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ +from core.models import ContentType from ipam.choices import * from ipam.constants import * from ipam.fields import IPNetworkField, IPAddressField @@ -740,7 +740,7 @@ class IPAddress(PrimaryModel): help_text=_('The functional role of this IP') ) assigned_object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS, on_delete=models.PROTECT, related_name='+', diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py index 3072fc6c3..a2742a8f3 100644 --- a/netbox/ipam/models/l2vpn.py +++ b/netbox/ipam/models/l2vpn.py @@ -1,11 +1,11 @@ from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ +from core.models import ContentType from ipam.choices import L2VPNTypeChoices from ipam.constants import L2VPN_ASSIGNMENT_MODELS from netbox.models import NetBoxModel, PrimaryModel @@ -86,7 +86,7 @@ class L2VPNTermination(NetBoxModel): related_name='terminations' ) assigned_object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', limit_choices_to=L2VPN_ASSIGNMENT_MODELS, on_delete=models.PROTECT, related_name='+' diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 675d03ee5..b6aed5398 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -1,5 +1,4 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -32,7 +31,7 @@ class VLANGroup(OrganizationalModel): max_length=100 ) scope_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE, limit_choices_to=Q(model__in=VLANGROUP_SCOPE_TYPES), blank=True, diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 18186abac..f39f35620 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -3,7 +3,6 @@ from collections import defaultdict from functools import cached_property from django.contrib.contenttypes.fields import GenericRelation -from django.contrib.contenttypes.models import ContentType from django.core.validators import ValidationError from django.db import models from django.db.models.signals import class_prepared @@ -13,6 +12,7 @@ from django.utils.translation import gettext_lazy as _ from taggit.managers import TaggableManager from core.choices import JobStatusChoices +from core.models import ContentType from extras.choices import * from extras.utils import is_taggable, register_features from netbox.registry import registry diff --git a/netbox/netbox/registry.py b/netbox/netbox/registry.py index 151eb2f6b..ad8c18dcf 100644 --- a/netbox/netbox/registry.py +++ b/netbox/netbox/registry.py @@ -28,6 +28,7 @@ registry = Registry({ 'models': collections.defaultdict(set), 'plugins': dict(), 'search': dict(), + 'tables': collections.defaultdict(dict), 'views': collections.defaultdict(dict), 'widgets': dict(), }) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 29899d25f..495e56991 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -1,3 +1,5 @@ +from copy import deepcopy + import django_tables2 as tables from django.contrib.auth.models import AnonymousUser from django.contrib.contenttypes.fields import GenericForeignKey @@ -10,8 +12,9 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from django_tables2.data import TableQuerysetData -from extras.models import CustomField, CustomLink from extras.choices import * +from extras.models import CustomField, CustomLink +from netbox.registry import registry from netbox.tables import columns from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.utils import get_viewname, highlight_string, title @@ -191,12 +194,17 @@ class NetBoxTable(BaseTable): if extra_columns is None: extra_columns = [] + if registered_columns := registry['tables'].get(self.__class__): + extra_columns.extend([ + # Create a copy to avoid modifying the original Column + (name, deepcopy(column)) for name, column in registered_columns.items() + ]) + # Add custom field & custom link columns content_type = ContentType.objects.get_for_model(self._meta.model) custom_fields = CustomField.objects.filter( content_types=content_type ).exclude(ui_visible=CustomFieldUIVisibleChoices.HIDDEN) - extra_columns.extend([ (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields ]) diff --git a/netbox/netbox/tests/dummy_plugin/tables.py b/netbox/netbox/tests/dummy_plugin/tables.py new file mode 100644 index 000000000..0f1e823d7 --- /dev/null +++ b/netbox/netbox/tests/dummy_plugin/tables.py @@ -0,0 +1,11 @@ +import django_tables2 as tables + +from dcim.tables import SiteTable +from utilities.tables import register_table_column + +mycol = tables.Column( + verbose_name='My column', + accessor=tables.A('description') +) + +register_table_column(mycol, 'foo', SiteTable) diff --git a/netbox/netbox/tests/dummy_plugin/views.py b/netbox/netbox/tests/dummy_plugin/views.py index 8713102c5..03a83b585 100644 --- a/netbox/netbox/tests/dummy_plugin/views.py +++ b/netbox/netbox/tests/dummy_plugin/views.py @@ -4,6 +4,8 @@ from django.views.generic import View from dcim.models import Site from utilities.views import register_model_view from .models import DummyModel +# Trigger registration of custom column +from .tables import mycol class DummyModelsView(View): diff --git a/netbox/netbox/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py index 046436a86..40bf8b0ea 100644 --- a/netbox/netbox/tests/test_plugins.py +++ b/netbox/netbox/tests/test_plugins.py @@ -97,6 +97,16 @@ class PluginTest(TestCase): self.assertIn(SiteContent, registry['plugins']['template_extensions']['dcim.site']) + def test_registered_columns(self): + """ + Check that a plugin can register a custom column on a core model table. + """ + from dcim.models import Site + from dcim.tables import SiteTable + + table = SiteTable(Site.objects.all()) + self.assertIn('foo', table.columns.names()) + def test_user_preferences(self): """ Check that plugin UserPreferences are registered. diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 692b8963f..77e945542 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -1,8 +1,7 @@ from django import forms -from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ -from extras.utils import FeatureQuery +from core.models import ContentType from netbox.forms import NetBoxModelFilterSetForm from tenancy.choices import * from tenancy.models import * @@ -87,8 +86,7 @@ class ContactAssignmentFilterForm(NetBoxModelFilterSetForm): (_('Assignment'), ('content_type_id', 'group_id', 'contact_id', 'role_id', 'priority')), ) content_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('contacts'), + queryset=ContentType.objects.with_feature('contacts'), required=False, label=_('Object type') ) diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 6cc26fa83..e7f319051 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -1,9 +1,10 @@ from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from core.models import ContentType from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel from netbox.models.features import CustomFieldsMixin, TagsMixin from tenancy.choices import * @@ -111,7 +112,7 @@ class Contact(PrimaryModel): class ContactAssignment(CustomFieldsMixin, TagsMixin, ChangeLoggedModel): content_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE ) object_id = models.PositiveBigIntegerField() @@ -157,6 +158,15 @@ class ContactAssignment(CustomFieldsMixin, TagsMixin, ChangeLoggedModel): def get_absolute_url(self): return reverse('tenancy:contact', args=[self.contact.pk]) + def clean(self): + super().clean() + + # Validate the assigned object type + if self.content_type not in ContentType.objects.with_feature('contacts'): + raise ValidationError( + _("Contacts cannot be assigned to this object type ({type}).").format(type=self.content_type) + ) + def to_objectchange(self, action): objectchange = super().to_objectchange(action) objectchange.related_object = self.object diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 4217f8d15..27d5750ac 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -2,14 +2,9 @@ from django.contrib.contenttypes.models import ContentType from django.shortcuts import get_object_or_404 from django.utils.translation import gettext as _ -from circuits.models import Circuit -from dcim.models import Cable, Device, Location, PowerFeed, Rack, RackReservation, Site, VirtualDeviceContext -from ipam.models import Aggregate, ASN, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF from netbox.views import generic -from utilities.utils import count_related +from utilities.utils import count_related, get_related_models from utilities.views import register_model_view, ViewTab -from virtualization.models import VirtualMachine, Cluster -from wireless.models import WirelessLAN, WirelessLink from . import filtersets, forms, tables from .models import * @@ -132,32 +127,8 @@ class TenantView(generic.ObjectView): def get_extra_context(self, request, instance): related_models = [ - # DCIM - (Site.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Rack.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (RackReservation.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Location.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Device.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (VirtualDeviceContext.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Cable.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (PowerFeed.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - # IPAM - (VRF.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Prefix.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (IPRange.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (ASN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (VLAN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (L2VPN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - # Circuits - (Circuit.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - # Virtualization - (VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Cluster.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - # Wireless - (WirelessLAN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (WirelessLink.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + (model.objects.restrict(request.user, 'view').filter(tenant=instance), f'{field}_id') + for model, field in get_related_models(Tenant) ] return { diff --git a/netbox/users/models.py b/netbox/users/models.py index 2a345653d..d77d4932c 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -3,7 +3,6 @@ import os from django.conf import settings from django.contrib.auth.models import Group, GroupManager, User, UserManager -from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator @@ -15,6 +14,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from netaddr import IPNetwork +from core.models import ContentType from ipam.fields import IPNetworkField from netbox.config import get_config from utilities.querysets import RestrictedQuerySet @@ -353,7 +353,7 @@ class ObjectPermission(models.Model): default=True ) object_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES, related_name='object_permissions' ) diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 489b90f10..654eb02be 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -1,6 +1,9 @@ +from netbox.registry import registry + __all__ = ( 'get_table_ordering', 'linkify_phone', + 'register_table_column' ) @@ -26,3 +29,19 @@ def linkify_phone(value): if value is None: return None return f"tel:{value}" + + +def register_table_column(column, name, *tables): + """ + Register a custom column for use on one or more tables. + + Args: + column: The column instance to register + name: The name of the table column + tables: One or more table classes + """ + for table in tables: + reg = registry['tables'][table] + if name in reg: + raise ValueError(f"A column named {name} is already defined for table {table.__name__}") + reg[name] = column diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index feb28c2d8..d7232d41b 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -8,7 +8,7 @@ from itertools import count, groupby import bleach from django.contrib.contenttypes.models import ContentType from django.core import serializers -from django.db.models import Count, OuterRef, Subquery +from django.db.models import Count, ManyToOneRel, OuterRef, Subquery from django.db.models.functions import Coalesce from django.http import QueryDict from django.utils import timezone @@ -567,3 +567,20 @@ def local_now(): Return the current date & time in the system timezone. """ return localtime(timezone.now()) + + +def get_related_models(model, ordered=True): + """ + Return a list of all models which have a ForeignKey to the given model and the name of the field. For example, + `get_related_models(Tenant)` will return all models which have a ForeignKey relationship to Tenant. + """ + related_models = [ + (field.related_model, field.remote_field.name) + for field in model._meta.related_objects + if type(field) is ManyToOneRel + ] + + if ordered: + return sorted(related_models, key=lambda x: x[0]._meta.verbose_name) + + return related_models