Closes #14153: Filter ContentTypes by supported feature (#14191)

* WIP

* Remove FeatureQuery

* Standardize use of proxy ContentType for models

* Remove TODO

* Correctly filter BookmarksWidget object_types choices

* Add feature-specific object type validation
This commit is contained in:
Jeremy Stretch 2023-11-16 12:12:51 -05:00 committed by GitHub
parent 69a4c31072
commit e15647a2ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 152 additions and 142 deletions

View File

@ -1,12 +1,10 @@
from django import forms from django import forms
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.choices import * from core.choices import *
from core.models import * from core.models import *
from extras.forms.mixins import SavedFiltersMixin from extras.forms.mixins import SavedFiltersMixin
from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm
from netbox.utils import get_data_backend_choices from netbox.utils import get_data_backend_choices
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
@ -69,7 +67,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
) )
object_type = ContentTypeChoiceField( object_type = ContentTypeChoiceField(
label=_('Object Type'), label=_('Object Type'),
queryset=ContentType.objects.filter(FeatureQuery('jobs').get_query()), queryset=ContentType.objects.with_feature('jobs'),
required=False, required=False,
) )
status = forms.MultipleChoiceField( status = forms.MultipleChoiceField(

View File

@ -4,7 +4,6 @@ from django.conf import settings
import django.core.validators import django.core.validators
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import extras.utils
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -30,7 +29,7 @@ class Migration(migrations.Migration):
('status', models.CharField(default='pending', max_length=30)), ('status', models.CharField(default='pending', max_length=30)),
('data', models.JSONField(blank=True, null=True)), ('data', models.JSONField(blank=True, null=True)),
('job_id', models.UUIDField(unique=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)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
], ],
options={ options={

View File

@ -21,6 +21,24 @@ class ContentTypeManager(ContentTypeManager_):
q |= Q(app_label=app_label, model__in=models) q |= Q(app_label=app_label, model__in=models)
return self.get_queryset().filter(q) 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_): class ContentType(ContentType_):
""" """

View File

@ -6,7 +6,6 @@ from urllib.parse import urlparse
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.db import models from django.db import models
@ -368,7 +367,7 @@ class AutoSyncRecord(models.Model):
related_name='+' related_name='+'
) )
object_type = models.ForeignKey( object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='+' related_name='+'
) )

View File

@ -3,7 +3,7 @@ import uuid
import django_rq import django_rq
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey 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.core.validators import MinValueValidator
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@ -11,8 +11,8 @@ from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from core.choices import JobStatusChoices from core.choices import JobStatusChoices
from core.models import ContentType
from extras.constants import EVENT_JOB_END, EVENT_JOB_START from extras.constants import EVENT_JOB_END, EVENT_JOB_START
from extras.utils import FeatureQuery
from netbox.config import get_config from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT from netbox.constants import RQ_QUEUE_DEFAULT
from utilities.querysets import RestrictedQuerySet 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). Tracks the lifecycle of a job which represents a background task (e.g. the execution of a custom script).
""" """
object_type = models.ForeignKey( object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
related_name='jobs', related_name='jobs',
limit_choices_to=FeatureQuery('jobs'),
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
object_id = models.PositiveBigIntegerField( object_id = models.PositiveBigIntegerField(
@ -123,6 +122,15 @@ class Job(models.Model):
def get_status_color(self): def get_status_color(self):
return JobStatusChoices.colors.get(self.status) 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 @property
def duration(self): def duration(self):
if not self.completed: if not self.completed:

View File

@ -2,7 +2,6 @@ import itertools
from collections import defaultdict from collections import defaultdict
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import Sum from django.db.models import Sum
@ -10,12 +9,12 @@ from django.dispatch import Signal
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import ContentType
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.fields import PathField from dcim.fields import PathField
from dcim.utils import decompile_path_node, object_to_path_node from dcim.utils import decompile_path_node, object_to_path_node
from netbox.models import ChangeLoggedModel, PrimaryModel from netbox.models import ChangeLoggedModel, PrimaryModel
from utilities.fields import ColorField from utilities.fields import ColorField
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.utils import to_meters from utilities.utils import to_meters
@ -258,7 +257,7 @@ class CableTermination(ChangeLoggedModel):
verbose_name=_('end') verbose_name=_('end')
) )
termination_type = models.ForeignKey( termination_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
limit_choices_to=CABLE_TERMINATION_MODELS, limit_choices_to=CABLE_TERMINATION_MODELS,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+' related_name='+'

View File

@ -1,5 +1,4 @@
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
@ -709,7 +708,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
db_index=True db_index=True
) )
component_type = models.ForeignKey( component_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS, limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+', related_name='+',

View File

@ -1,7 +1,6 @@
from functools import cached_property from functools import cached_property
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
@ -1181,7 +1180,7 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
db_index=True db_index=True
) )
component_type = models.ForeignKey( component_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
limit_choices_to=MODULAR_COMPONENT_MODELS, limit_choices_to=MODULAR_COMPONENT_MODELS,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+', related_name='+',

View File

@ -1,10 +1,10 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from rest_framework import serializers from rest_framework import serializers
from core.api.serializers import JobSerializer from core.api.serializers import JobSerializer
from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer
from core.models import ContentType
from dcim.api.nested_serializers import ( from dcim.api.nested_serializers import (
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer, NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
@ -14,7 +14,6 @@ from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from extras.utils import FeatureQuery
from netbox.api.exceptions import SerializerNotFound from netbox.api.exceptions import SerializerNotFound
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
@ -64,7 +63,7 @@ __all__ = (
class WebhookSerializer(NetBoxModelSerializer): class WebhookSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail')
content_types = ContentTypeField( content_types = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()), queryset=ContentType.objects.with_feature('webhooks'),
many=True many=True
) )
@ -85,7 +84,7 @@ class WebhookSerializer(NetBoxModelSerializer):
class CustomFieldSerializer(ValidatedModelSerializer): class CustomFieldSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
content_types = ContentTypeField( content_types = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()), queryset=ContentType.objects.with_feature('custom_fields'),
many=True many=True
) )
type = ChoiceField(choices=CustomFieldTypeChoices) type = ChoiceField(choices=CustomFieldTypeChoices)
@ -151,7 +150,7 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
class CustomLinkSerializer(ValidatedModelSerializer): class CustomLinkSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
content_types = ContentTypeField( content_types = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()), queryset=ContentType.objects.with_feature('custom_links'),
many=True many=True
) )
@ -170,7 +169,7 @@ class CustomLinkSerializer(ValidatedModelSerializer):
class ExportTemplateSerializer(ValidatedModelSerializer): class ExportTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
content_types = ContentTypeField( content_types = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), queryset=ContentType.objects.with_feature('export_templates'),
many=True many=True
) )
data_source = NestedDataSourceSerializer( data_source = NestedDataSourceSerializer(
@ -215,7 +214,7 @@ class SavedFilterSerializer(ValidatedModelSerializer):
class BookmarkSerializer(ValidatedModelSerializer): class BookmarkSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
object_type = ContentTypeField( object_type = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('bookmarks').get_query()), queryset=ContentType.objects.with_feature('bookmarks'),
) )
object = serializers.SerializerMethodField(read_only=True) object = serializers.SerializerMethodField(read_only=True)
user = NestedUserSerializer() user = NestedUserSerializer()
@ -239,7 +238,7 @@ class BookmarkSerializer(ValidatedModelSerializer):
class TagSerializer(ValidatedModelSerializer): class TagSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
object_types = ContentTypeField( object_types = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), queryset=ContentType.objects.with_feature('tags'),
many=True, many=True,
required=False required=False
) )

View File

@ -32,13 +32,20 @@ __all__ = (
) )
def get_content_type_labels(): def get_object_type_choices():
return [ return [
(content_type_identifier(ct), content_type_name(ct)) (content_type_identifier(ct), content_type_name(ct))
for ct in ContentType.objects.public().order_by('app_label', 'model') 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): def get_models_from_content_types(content_types):
""" """
Return a list of models corresponding to the given content types, identified by natural key. 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): class ConfigForm(WidgetConfigForm):
models = forms.MultipleChoiceField( models = forms.MultipleChoiceField(
choices=get_content_type_labels choices=get_object_type_choices
) )
filters = forms.JSONField( filters = forms.JSONField(
required=False, required=False,
@ -207,7 +214,7 @@ class ObjectListWidget(DashboardWidget):
class ConfigForm(WidgetConfigForm): class ConfigForm(WidgetConfigForm):
model = forms.ChoiceField( model = forms.ChoiceField(
choices=get_content_type_labels choices=get_object_type_choices
) )
page_size = forms.IntegerField( page_size = forms.IntegerField(
required=False, required=False,
@ -343,8 +350,7 @@ class BookmarksWidget(DashboardWidget):
class ConfigForm(WidgetConfigForm): class ConfigForm(WidgetConfigForm):
object_types = forms.MultipleChoiceField( object_types = forms.MultipleChoiceField(
# TODO: Restrict the choices by FeatureQuery('bookmarks') choices=get_bookmarks_object_type_choices,
choices=get_content_type_labels,
required=False required=False
) )
order_by = forms.ChoiceField( order_by = forms.ChoiceField(

View File

@ -6,7 +6,6 @@ from django.utils.translation import gettext_lazy as _
from core.models import ContentType from core.models import ContentType
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelImportForm from netbox.forms import NetBoxModelImportForm
from utilities.forms import CSVModelForm from utilities.forms import CSVModelForm
from utilities.forms.fields import ( from utilities.forms.fields import (
@ -29,8 +28,7 @@ __all__ = (
class CustomFieldImportForm(CSVModelForm): class CustomFieldImportForm(CSVModelForm):
content_types = CSVMultipleContentTypeField( content_types = CSVMultipleContentTypeField(
label=_('Content types'), label=_('Content types'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('custom_fields'),
limit_choices_to=FeatureQuery('custom_fields'),
help_text=_("One or more assigned object types") help_text=_("One or more assigned object types")
) )
type = CSVChoiceField( type = CSVChoiceField(
@ -88,8 +86,7 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
class CustomLinkImportForm(CSVModelForm): class CustomLinkImportForm(CSVModelForm):
content_types = CSVMultipleContentTypeField( content_types = CSVMultipleContentTypeField(
label=_('Content types'), label=_('Content types'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('custom_links'),
limit_choices_to=FeatureQuery('custom_links'),
help_text=_("One or more assigned object types") help_text=_("One or more assigned object types")
) )
@ -104,8 +101,7 @@ class CustomLinkImportForm(CSVModelForm):
class ExportTemplateImportForm(CSVModelForm): class ExportTemplateImportForm(CSVModelForm):
content_types = CSVMultipleContentTypeField( content_types = CSVMultipleContentTypeField(
label=_('Content types'), label=_('Content types'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('export_templates'),
limit_choices_to=FeatureQuery('export_templates'),
help_text=_("One or more assigned object types") help_text=_("One or more assigned object types")
) )
@ -142,8 +138,7 @@ class SavedFilterImportForm(CSVModelForm):
class WebhookImportForm(NetBoxModelImportForm): class WebhookImportForm(NetBoxModelImportForm):
content_types = CSVMultipleContentTypeField( content_types = CSVMultipleContentTypeField(
label=_('Content types'), label=_('Content types'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('webhooks'),
limit_choices_to=FeatureQuery('webhooks'),
help_text=_("One or more assigned object types") help_text=_("One or more assigned object types")
) )

View File

@ -6,7 +6,6 @@ from core.models import ContentType, DataFile, DataSource
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from extras.utils import FeatureQuery
from netbox.forms.base import NetBoxModelFilterSetForm from netbox.forms.base import NetBoxModelFilterSetForm
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
@ -44,7 +43,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
)), )),
) )
content_type_id = ContentTypeMultipleChoiceField( content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()), queryset=ContentType.objects.with_feature('custom_fields'),
required=False, required=False,
label=_('Object type') label=_('Object type')
) )
@ -108,7 +107,7 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
) )
content_types = ContentTypeMultipleChoiceField( content_types = ContentTypeMultipleChoiceField(
label=_('Content types'), label=_('Content types'),
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()), queryset=ContentType.objects.with_feature('custom_links'),
required=False required=False
) )
enabled = forms.NullBooleanField( enabled = forms.NullBooleanField(
@ -151,7 +150,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
} }
) )
content_type_id = ContentTypeMultipleChoiceField( content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), queryset=ContentType.objects.with_feature('export_templates'),
required=False, required=False,
label=_('Content types') label=_('Content types')
) )
@ -179,7 +178,7 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
) )
content_type_id = ContentTypeChoiceField( content_type_id = ContentTypeChoiceField(
label=_('Content type'), label=_('Content type'),
queryset=ContentType.objects.filter(FeatureQuery('image_attachments').get_query()), queryset=ContentType.objects.with_feature('image_attachments'),
required=False required=False
) )
name = forms.CharField( name = forms.CharField(
@ -228,7 +227,7 @@ class WebhookFilterForm(NetBoxModelFilterSetForm):
(_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')), (_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')),
) )
content_type_id = ContentTypeMultipleChoiceField( content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()), queryset=ContentType.objects.with_feature('webhooks'),
required=False, required=False,
label=_('Object type') label=_('Object type')
) )
@ -284,12 +283,12 @@ class WebhookFilterForm(NetBoxModelFilterSetForm):
class TagFilterForm(SavedFiltersMixin, FilterForm): class TagFilterForm(SavedFiltersMixin, FilterForm):
model = Tag model = Tag
content_type_id = ContentTypeMultipleChoiceField( content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), queryset=ContentType.objects.with_feature('tags'),
required=False, required=False,
label=_('Tagged object type') label=_('Tagged object type')
) )
for_object_type_id = ContentTypeChoiceField( for_object_type_id = ContentTypeChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), queryset=ContentType.objects.with_feature('tags'),
required=False, required=False,
label=_('Allowed object type') label=_('Allowed object type')
) )

View File

@ -10,7 +10,6 @@ from core.models import ContentType
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from extras.utils import FeatureQuery
from netbox.config import get_config, PARAMS from netbox.config import get_config, PARAMS
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
@ -43,8 +42,7 @@ __all__ = (
class CustomFieldForm(BootstrapMixin, forms.ModelForm): class CustomFieldForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField( content_types = ContentTypeMultipleChoiceField(
label=_('Content types'), label=_('Content types'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('custom_fields')
limit_choices_to=FeatureQuery('custom_fields'),
) )
object_type = ContentTypeChoiceField( object_type = ContentTypeChoiceField(
label=_('Object type'), label=_('Object type'),
@ -114,8 +112,7 @@ class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
class CustomLinkForm(BootstrapMixin, forms.ModelForm): class CustomLinkForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField( content_types = ContentTypeMultipleChoiceField(
label=_('Content types'), label=_('Content types'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('custom_links')
limit_choices_to=FeatureQuery('custom_links')
) )
fieldsets = ( fieldsets = (
@ -142,8 +139,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
class ExportTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm): class ExportTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField( content_types = ContentTypeMultipleChoiceField(
label=_('Content types'), label=_('Content types'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('export_templates')
limit_choices_to=FeatureQuery('export_templates')
) )
template_code = forms.CharField( template_code = forms.CharField(
label=_('Template code'), label=_('Template code'),
@ -210,8 +206,7 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
class BookmarkForm(BootstrapMixin, forms.ModelForm): class BookmarkForm(BootstrapMixin, forms.ModelForm):
object_type = ContentTypeChoiceField( object_type = ContentTypeChoiceField(
label=_('Object type'), label=_('Object type'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('bookmarks')
limit_choices_to=FeatureQuery('bookmarks').get_query()
) )
class Meta: class Meta:
@ -222,8 +217,7 @@ class BookmarkForm(BootstrapMixin, forms.ModelForm):
class WebhookForm(NetBoxModelForm): class WebhookForm(NetBoxModelForm):
content_types = ContentTypeMultipleChoiceField( content_types = ContentTypeMultipleChoiceField(
label=_('Content types'), label=_('Content types'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('webhooks')
limit_choices_to=FeatureQuery('webhooks')
) )
fieldsets = ( fieldsets = (
@ -257,8 +251,7 @@ class TagForm(BootstrapMixin, forms.ModelForm):
slug = SlugField() slug = SlugField()
object_types = ContentTypeMultipleChoiceField( object_types = ContentTypeMultipleChoiceField(
label=_('Object types'), label=_('Object types'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('tags'),
limit_choices_to=FeatureQuery('tags'),
required=False required=False
) )

View File

@ -88,7 +88,7 @@ class Migration(migrations.Migration):
('secret', models.CharField(blank=True, max_length=255)), ('secret', models.CharField(blank=True, max_length=255)),
('ssl_verification', models.BooleanField(default=True)), ('ssl_verification', models.BooleanField(default=True)),
('ca_file_path', models.CharField(blank=True, max_length=4096, null=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={ options={
'ordering': ('name',), 'ordering': ('name',),
@ -151,7 +151,7 @@ class Migration(migrations.Migration):
('status', models.CharField(default='pending', max_length=30)), ('status', models.CharField(default='pending', max_length=30)),
('data', models.JSONField(blank=True, null=True)), ('data', models.JSONField(blank=True, null=True)),
('job_id', models.UUIDField(unique=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)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
], ],
options={ options={
@ -184,7 +184,7 @@ class Migration(migrations.Migration):
('mime_type', models.CharField(blank=True, max_length=50)), ('mime_type', models.CharField(blank=True, max_length=50)),
('file_extension', models.CharField(blank=True, max_length=15)), ('file_extension', models.CharField(blank=True, max_length=15)),
('as_attachment', models.BooleanField(default=True)), ('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={ options={
'ordering': ['content_type', 'name'], 'ordering': ['content_type', 'name'],
@ -201,7 +201,7 @@ class Migration(migrations.Migration):
('group_name', models.CharField(blank=True, max_length=50)), ('group_name', models.CharField(blank=True, max_length=50)),
('button_class', models.CharField(default='default', max_length=30)), ('button_class', models.CharField(default='default', max_length=30)),
('new_window', models.BooleanField(default=False)), ('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={ options={
'ordering': ['group_name', 'weight', 'name'], 'ordering': ['group_name', 'weight', 'name'],
@ -223,7 +223,7 @@ class Migration(migrations.Migration):
('validation_maximum', models.PositiveIntegerField(blank=True, null=True)), ('validation_maximum', models.PositiveIntegerField(blank=True, null=True)),
('validation_regex', models.CharField(blank=True, max_length=500, validators=[utilities.validators.validate_regex])), ('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)), ('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={ options={
'ordering': ['weight', 'name'], 'ordering': ['weight', 'name'],

View File

@ -1,5 +1,4 @@
from django.db import migrations, models from django.db import migrations, models
import extras.utils
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -13,7 +12,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='tag', model_name='tag',
name='object_types', 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( migrations.RenameIndex(
model_name='taggeditem', model_name='taggeditem',

View File

@ -1,10 +1,11 @@
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey 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 import models
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import ContentType
from extras.choices import * from extras.choices import *
from ..querysets import ObjectChangeQuerySet from ..querysets import ObjectChangeQuerySet
@ -48,7 +49,7 @@ class ObjectChange(models.Model):
choices=ObjectChangeActionChoices choices=ObjectChangeActionChoices
) )
changed_object_type = models.ForeignKey( changed_object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+' related_name='+'
) )
@ -58,7 +59,7 @@ class ObjectChange(models.Model):
fk_field='changed_object_id' fk_field='changed_object_id'
) )
related_object_type = models.ForeignKey( related_object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+', related_name='+',
blank=True, blank=True,
@ -104,6 +105,17 @@ class ObjectChange(models.Model):
self.user_name 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): def save(self, *args, **kwargs):
# Record the user's name and the object's representation as static strings # Record the user's name and the object's representation as static strings

View File

@ -5,18 +5,16 @@ from datetime import datetime, date
import django_filters import django_filters
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.core.validators import RegexValidator, ValidationError from django.core.validators import RegexValidator, ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import ContentType
from extras.choices import * from extras.choices import *
from extras.data import CHOICE_SETS from extras.data import CHOICE_SETS
from extras.utils import FeatureQuery
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin from netbox.models.features import CloningMixin, ExportTemplatesMixin
from netbox.search import FieldTypes from netbox.search import FieldTypes
@ -60,9 +58,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
content_types = models.ManyToManyField( content_types = models.ManyToManyField(
to=ContentType, to='contenttypes.ContentType',
related_name='custom_fields', related_name='custom_fields',
limit_choices_to=FeatureQuery('custom_fields'),
help_text=_('The object(s) to which this field applies.') help_text=_('The object(s) to which this field applies.')
) )
type = models.CharField( type = models.CharField(
@ -73,7 +70,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
help_text=_('The type of data this custom field holds') help_text=_('The type of data this custom field holds')
) )
object_type = models.ForeignKey( object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.PROTECT, on_delete=models.PROTECT,
blank=True, blank=True,
null=True, null=True,

View File

@ -3,7 +3,6 @@ import urllib.parse
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache from django.core.cache import cache
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models 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 django.utils.translation import gettext, gettext_lazy as _
from rest_framework.utils.encoders import JSONEncoder from rest_framework.utils.encoders import JSONEncoder
from core.models import ContentType
from extras.choices import * from extras.choices import *
from extras.conditions import ConditionSet from extras.conditions import ConditionSet
from extras.constants import * 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.config import get_config
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from netbox.models.features import ( 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. Each Webhook can be limited to firing only on certain actions or certain object types.
""" """
content_types = models.ManyToManyField( content_types = models.ManyToManyField(
to=ContentType, to='contenttypes.ContentType',
related_name='webhooks', related_name='webhooks',
verbose_name=_('object types'), verbose_name=_('object types'),
limit_choices_to=FeatureQuery('webhooks'),
help_text=_("The object(s) to which this Webhook applies.") help_text=_("The object(s) to which this Webhook applies.")
) )
name = models.CharField( name = models.CharField(
@ -235,7 +234,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
code to be rendered with an object as context. code to be rendered with an object as context.
""" """
content_types = models.ManyToManyField( content_types = models.ManyToManyField(
to=ContentType, to='contenttypes.ContentType',
related_name='custom_links', related_name='custom_links',
help_text=_('The object type(s) to which this link applies.') 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): class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
content_types = models.ManyToManyField( content_types = models.ManyToManyField(
to=ContentType, to='contenttypes.ContentType',
related_name='export_templates', related_name='export_templates',
help_text=_('The object type(s) to which this template applies.') 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. A set of predefined keyword parameters that can be reused to filter for specific objects.
""" """
content_types = models.ManyToManyField( content_types = models.ManyToManyField(
to=ContentType, to='contenttypes.ContentType',
related_name='saved_filters', related_name='saved_filters',
help_text=_('The object type(s) to which this filter applies.') 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. An uploaded image which is associated with an object.
""" """
content_type = models.ForeignKey( content_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.CASCADE on_delete=models.CASCADE
) )
object_id = models.PositiveBigIntegerField() object_id = models.PositiveBigIntegerField()
@ -560,6 +559,15 @@ class ImageAttachment(ChangeLoggedModel):
filename = self.image.name.rsplit('/', 1)[-1] filename = self.image.name.rsplit('/', 1)[-1]
return filename.split('_', 2)[2] 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): def delete(self, *args, **kwargs):
_name = self.image.name _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. might record a new journal entry when a device undergoes maintenance, or when a prefix is expanded.
""" """
assigned_object_type = models.ForeignKey( assigned_object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.CASCADE on_delete=models.CASCADE
) )
assigned_object_id = models.PositiveBigIntegerField() assigned_object_id = models.PositiveBigIntegerField()
@ -644,9 +652,8 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
def clean(self): def clean(self):
super().clean() super().clean()
# Prevent the creation of journal entries on unsupported models # Validate the assigned object type
permitted_types = ContentType.objects.filter(FeatureQuery('journaling').get_query()) if self.assigned_object_type not in ContentType.objects.with_feature('journaling'):
if self.assigned_object_type not in permitted_types:
raise ValidationError( raise ValidationError(
_("Journaling is not supported for this object type ({type}).").format(type=self.assigned_object_type) _("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 auto_now_add=True
) )
object_type = models.ForeignKey( object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.PROTECT on_delete=models.PROTECT
) )
object_id = models.PositiveBigIntegerField() object_id = models.PositiveBigIntegerField()
@ -695,6 +702,15 @@ class Bookmark(models.Model):
return str(self.object) return str(self.object)
return super().__str__() 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): class ConfigRevision(models.Model):
""" """

View File

@ -1,6 +1,5 @@
import uuid import uuid
from django.contrib.contenttypes.models import ContentType
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -27,7 +26,7 @@ class CachedValue(models.Model):
editable=False editable=False
) )
object_type = models.ForeignKey( object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='+' related_name='+'
) )

View File

@ -2,7 +2,6 @@ import logging
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models, transaction from django.db import models, transaction
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -71,7 +70,7 @@ class StagedChange(ChangeLoggedModel):
choices=ChangeActionChoices choices=ChangeActionChoices
) )
object_type = models.ForeignKey( object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='+' related_name='+'
) )

View File

@ -1,13 +1,10 @@
from django.conf import settings 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.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from taggit.models import TagBase, GenericTaggedItemBase from taggit.models import TagBase, GenericTaggedItemBase
from extras.utils import FeatureQuery
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin from netbox.models.features import CloningMixin, ExportTemplatesMixin
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
@ -37,9 +34,8 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
blank=True, blank=True,
) )
object_types = models.ManyToManyField( object_types = models.ManyToManyField(
to=ContentType, to='contenttypes.ContentType',
related_name='+', related_name='+',
limit_choices_to=FeatureQuery('tags'),
blank=True, blank=True,
help_text=_("The object type(s) to which this this tag can be applied.") help_text=_("The object type(s) to which this this tag can be applied.")
) )

View File

@ -1,5 +1,3 @@
from django.db.models import Q
from django.utils.deconstruct import deconstructible
from taggit.managers import _TaggableManager from taggit.managers import _TaggableManager
from netbox.registry import registry 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) 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): def register_features(model, features):
""" """
Register model features in the application registry. Register model features in the application registry.

View File

@ -1,13 +1,12 @@
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from netbox.models import ChangeLoggedModel, PrimaryModel
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from netbox.models import ChangeLoggedModel, PrimaryModel
__all__ = ( __all__ = (
'FHRPGroup', 'FHRPGroup',
@ -78,7 +77,7 @@ class FHRPGroup(PrimaryModel):
class FHRPGroupAssignment(ChangeLoggedModel): class FHRPGroupAssignment(ChangeLoggedModel):
interface_type = models.ForeignKey( interface_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.CASCADE on_delete=models.CASCADE
) )
interface_id = models.PositiveBigIntegerField() interface_id = models.PositiveBigIntegerField()

View File

@ -1,6 +1,5 @@
import netaddr import netaddr
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import F 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.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import ContentType
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.fields import IPNetworkField, IPAddressField from ipam.fields import IPNetworkField, IPAddressField
@ -740,7 +740,7 @@ class IPAddress(PrimaryModel):
help_text=_('The functional role of this IP') help_text=_('The functional role of this IP')
) )
assigned_object_type = models.ForeignKey( assigned_object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS, limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+', related_name='+',

View File

@ -1,11 +1,11 @@
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import ContentType
from ipam.choices import L2VPNTypeChoices from ipam.choices import L2VPNTypeChoices
from ipam.constants import L2VPN_ASSIGNMENT_MODELS from ipam.constants import L2VPN_ASSIGNMENT_MODELS
from netbox.models import NetBoxModel, PrimaryModel from netbox.models import NetBoxModel, PrimaryModel
@ -86,7 +86,7 @@ class L2VPNTermination(NetBoxModel):
related_name='terminations' related_name='terminations'
) )
assigned_object_type = models.ForeignKey( assigned_object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
limit_choices_to=L2VPN_ASSIGNMENT_MODELS, limit_choices_to=L2VPN_ASSIGNMENT_MODELS,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+' related_name='+'

View File

@ -1,5 +1,4 @@
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
@ -32,7 +31,7 @@ class VLANGroup(OrganizationalModel):
max_length=100 max_length=100
) )
scope_type = models.ForeignKey( scope_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.CASCADE, on_delete=models.CASCADE,
limit_choices_to=Q(model__in=VLANGROUP_SCOPE_TYPES), limit_choices_to=Q(model__in=VLANGROUP_SCOPE_TYPES),
blank=True, blank=True,

View File

@ -3,7 +3,6 @@ from collections import defaultdict
from functools import cached_property from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models from django.db import models
from django.db.models.signals import class_prepared 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 taggit.managers import TaggableManager
from core.choices import JobStatusChoices from core.choices import JobStatusChoices
from core.models import ContentType
from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
from extras.utils import is_taggable, register_features from extras.utils import is_taggable, register_features
from netbox.registry import registry from netbox.registry import registry

View File

@ -1,8 +1,7 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from extras.utils import FeatureQuery from core.models import ContentType
from netbox.forms import NetBoxModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm
from tenancy.choices import * from tenancy.choices import *
from tenancy.models import * from tenancy.models import *
@ -87,8 +86,7 @@ class ContactAssignmentFilterForm(NetBoxModelFilterSetForm):
(_('Assignment'), ('content_type_id', 'group_id', 'contact_id', 'role_id', 'priority')), (_('Assignment'), ('content_type_id', 'group_id', 'contact_id', 'role_id', 'priority')),
) )
content_type_id = ContentTypeMultipleChoiceField( content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('contacts'),
limit_choices_to=FeatureQuery('contacts'),
required=False, required=False,
label=_('Object type') label=_('Object type')
) )

View File

@ -1,9 +1,10 @@
from django.contrib.contenttypes.fields import GenericForeignKey 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 import models
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import ContentType
from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
from netbox.models.features import CustomFieldsMixin, TagsMixin from netbox.models.features import CustomFieldsMixin, TagsMixin
from tenancy.choices import * from tenancy.choices import *
@ -111,7 +112,7 @@ class Contact(PrimaryModel):
class ContactAssignment(CustomFieldsMixin, TagsMixin, ChangeLoggedModel): class ContactAssignment(CustomFieldsMixin, TagsMixin, ChangeLoggedModel):
content_type = models.ForeignKey( content_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.CASCADE on_delete=models.CASCADE
) )
object_id = models.PositiveBigIntegerField() object_id = models.PositiveBigIntegerField()
@ -157,6 +158,15 @@ class ContactAssignment(CustomFieldsMixin, TagsMixin, ChangeLoggedModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('tenancy:contact', args=[self.contact.pk]) 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): def to_objectchange(self, action):
objectchange = super().to_objectchange(action) objectchange = super().to_objectchange(action)
objectchange.related_object = self.object objectchange.related_object = self.object

View File

@ -3,7 +3,6 @@ import os
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group, GroupManager, User, UserManager 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.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinLengthValidator from django.core.validators import MinLengthValidator
@ -15,6 +14,7 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from netaddr import IPNetwork from netaddr import IPNetwork
from core.models import ContentType
from ipam.fields import IPNetworkField from ipam.fields import IPNetworkField
from netbox.config import get_config from netbox.config import get_config
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
@ -353,7 +353,7 @@ class ObjectPermission(models.Model):
default=True default=True
) )
object_types = models.ManyToManyField( object_types = models.ManyToManyField(
to=ContentType, to='contenttypes.ContentType',
limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES, limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES,
related_name='object_permissions' related_name='object_permissions'
) )