Skip to content

Commit e15647a

Browse files
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
1 parent 69a4c31 commit e15647a

30 files changed

+152
-142
lines changed

netbox/core/forms/filtersets.py

+1-3
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
from django import forms
22
from django.contrib.auth import get_user_model
3-
from django.contrib.contenttypes.models import ContentType
43
from django.utils.translation import gettext_lazy as _
54

65
from core.choices import *
76
from core.models import *
87
from extras.forms.mixins import SavedFiltersMixin
9-
from extras.utils import FeatureQuery
108
from netbox.forms import NetBoxModelFilterSetForm
119
from netbox.utils import get_data_backend_choices
1210
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
@@ -69,7 +67,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
6967
)
7068
object_type = ContentTypeChoiceField(
7169
label=_('Object Type'),
72-
queryset=ContentType.objects.filter(FeatureQuery('jobs').get_query()),
70+
queryset=ContentType.objects.with_feature('jobs'),
7371
required=False,
7472
)
7573
status = forms.MultipleChoiceField(

netbox/core/migrations/0003_job.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import django.core.validators
55
from django.db import migrations, models
66
import django.db.models.deletion
7-
import extras.utils
87

98

109
class Migration(migrations.Migration):
@@ -30,7 +29,7 @@ class Migration(migrations.Migration):
3029
('status', models.CharField(default='pending', max_length=30)),
3130
('data', models.JSONField(blank=True, null=True)),
3231
('job_id', models.UUIDField(unique=True)),
33-
('object_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('jobs'), on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype')),
32+
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype')),
3433
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
3534
],
3635
options={

netbox/core/models/contenttypes.py

+18
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,24 @@ def public(self):
2121
q |= Q(app_label=app_label, model__in=models)
2222
return self.get_queryset().filter(q)
2323

24+
def with_feature(self, feature):
25+
"""
26+
Return the ContentTypes only for models which are registered as supporting the specified feature. For example,
27+
we can find all ContentTypes for models which support webhooks with
28+
29+
ContentType.objects.with_feature('webhooks')
30+
"""
31+
if feature not in registry['model_features']:
32+
raise KeyError(
33+
f"{feature} is not a registered model feature! Valid features are: {registry['model_features'].keys()}"
34+
)
35+
36+
q = Q()
37+
for app_label, models in registry['model_features'][feature].items():
38+
q |= Q(app_label=app_label, model__in=models)
39+
40+
return self.get_queryset().filter(q)
41+
2442

2543
class ContentType(ContentType_):
2644
"""

netbox/core/models/data.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
from django.conf import settings
88
from django.contrib.contenttypes.fields import GenericForeignKey
9-
from django.contrib.contenttypes.models import ContentType
109
from django.core.exceptions import ValidationError
1110
from django.core.validators import RegexValidator
1211
from django.db import models
@@ -368,7 +367,7 @@ class AutoSyncRecord(models.Model):
368367
related_name='+'
369368
)
370369
object_type = models.ForeignKey(
371-
to=ContentType,
370+
to='contenttypes.ContentType',
372371
on_delete=models.CASCADE,
373372
related_name='+'
374373
)

netbox/core/models/jobs.py

+12-4
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,16 @@
33
import django_rq
44
from django.conf import settings
55
from django.contrib.contenttypes.fields import GenericForeignKey
6-
from django.contrib.contenttypes.models import ContentType
6+
from django.core.exceptions import ValidationError
77
from django.core.validators import MinValueValidator
88
from django.db import models
99
from django.urls import reverse
1010
from django.utils import timezone
1111
from django.utils.translation import gettext as _
1212

1313
from core.choices import JobStatusChoices
14+
from core.models import ContentType
1415
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
15-
from extras.utils import FeatureQuery
1616
from netbox.config import get_config
1717
from netbox.constants import RQ_QUEUE_DEFAULT
1818
from utilities.querysets import RestrictedQuerySet
@@ -28,9 +28,8 @@ class Job(models.Model):
2828
Tracks the lifecycle of a job which represents a background task (e.g. the execution of a custom script).
2929
"""
3030
object_type = models.ForeignKey(
31-
to=ContentType,
31+
to='contenttypes.ContentType',
3232
related_name='jobs',
33-
limit_choices_to=FeatureQuery('jobs'),
3433
on_delete=models.CASCADE,
3534
)
3635
object_id = models.PositiveBigIntegerField(
@@ -123,6 +122,15 @@ def get_absolute_url(self):
123122
def get_status_color(self):
124123
return JobStatusChoices.colors.get(self.status)
125124

125+
def clean(self):
126+
super().clean()
127+
128+
# Validate the assigned object type
129+
if self.object_type not in ContentType.objects.with_feature('jobs'):
130+
raise ValidationError(
131+
_("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type)
132+
)
133+
126134
@property
127135
def duration(self):
128136
if not self.completed:

netbox/dcim/models/cables.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,19 @@
22
from collections import defaultdict
33

44
from django.contrib.contenttypes.fields import GenericForeignKey
5-
from django.contrib.contenttypes.models import ContentType
65
from django.core.exceptions import ValidationError
76
from django.db import models
87
from django.db.models import Sum
98
from django.dispatch import Signal
109
from django.urls import reverse
1110
from django.utils.translation import gettext_lazy as _
1211

12+
from core.models import ContentType
1313
from dcim.choices import *
1414
from dcim.constants import *
1515
from dcim.fields import PathField
1616
from dcim.utils import decompile_path_node, object_to_path_node
1717
from netbox.models import ChangeLoggedModel, PrimaryModel
18-
1918
from utilities.fields import ColorField
2019
from utilities.querysets import RestrictedQuerySet
2120
from utilities.utils import to_meters
@@ -258,7 +257,7 @@ class CableTermination(ChangeLoggedModel):
258257
verbose_name=_('end')
259258
)
260259
termination_type = models.ForeignKey(
261-
to=ContentType,
260+
to='contenttypes.ContentType',
262261
limit_choices_to=CABLE_TERMINATION_MODELS,
263262
on_delete=models.PROTECT,
264263
related_name='+'

netbox/dcim/models/device_component_templates.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from django.contrib.contenttypes.fields import GenericForeignKey
2-
from django.contrib.contenttypes.models import ContentType
32
from django.core.exceptions import ValidationError
43
from django.core.validators import MaxValueValidator, MinValueValidator
54
from django.db import models
@@ -709,7 +708,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
709708
db_index=True
710709
)
711710
component_type = models.ForeignKey(
712-
to=ContentType,
711+
to='contenttypes.ContentType',
713712
limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS,
714713
on_delete=models.PROTECT,
715714
related_name='+',

netbox/dcim/models/device_components.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from functools import cached_property
22

33
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
4-
from django.contrib.contenttypes.models import ContentType
54
from django.core.exceptions import ValidationError
65
from django.core.validators import MaxValueValidator, MinValueValidator
76
from django.db import models
@@ -1181,7 +1180,7 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
11811180
db_index=True
11821181
)
11831182
component_type = models.ForeignKey(
1184-
to=ContentType,
1183+
to='contenttypes.ContentType',
11851184
limit_choices_to=MODULAR_COMPONENT_MODELS,
11861185
on_delete=models.PROTECT,
11871186
related_name='+',

netbox/extras/api/serializers.py

+7-8
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from django.contrib.auth import get_user_model
2-
from django.contrib.contenttypes.models import ContentType
32
from django.core.exceptions import ObjectDoesNotExist
43
from rest_framework import serializers
54

65
from core.api.serializers import JobSerializer
76
from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer
7+
from core.models import ContentType
88
from dcim.api.nested_serializers import (
99
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
1010
NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
@@ -14,7 +14,6 @@
1414
from drf_spectacular.types import OpenApiTypes
1515
from extras.choices import *
1616
from extras.models import *
17-
from extras.utils import FeatureQuery
1817
from netbox.api.exceptions import SerializerNotFound
1918
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
2019
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
@@ -64,7 +63,7 @@
6463
class WebhookSerializer(NetBoxModelSerializer):
6564
url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail')
6665
content_types = ContentTypeField(
67-
queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()),
66+
queryset=ContentType.objects.with_feature('webhooks'),
6867
many=True
6968
)
7069

@@ -85,7 +84,7 @@ class Meta:
8584
class CustomFieldSerializer(ValidatedModelSerializer):
8685
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
8786
content_types = ContentTypeField(
88-
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
87+
queryset=ContentType.objects.with_feature('custom_fields'),
8988
many=True
9089
)
9190
type = ChoiceField(choices=CustomFieldTypeChoices)
@@ -151,7 +150,7 @@ class Meta:
151150
class CustomLinkSerializer(ValidatedModelSerializer):
152151
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
153152
content_types = ContentTypeField(
154-
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
153+
queryset=ContentType.objects.with_feature('custom_links'),
155154
many=True
156155
)
157156

@@ -170,7 +169,7 @@ class Meta:
170169
class ExportTemplateSerializer(ValidatedModelSerializer):
171170
url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
172171
content_types = ContentTypeField(
173-
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
172+
queryset=ContentType.objects.with_feature('export_templates'),
174173
many=True
175174
)
176175
data_source = NestedDataSourceSerializer(
@@ -215,7 +214,7 @@ class Meta:
215214
class BookmarkSerializer(ValidatedModelSerializer):
216215
url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
217216
object_type = ContentTypeField(
218-
queryset=ContentType.objects.filter(FeatureQuery('bookmarks').get_query()),
217+
queryset=ContentType.objects.with_feature('bookmarks'),
219218
)
220219
object = serializers.SerializerMethodField(read_only=True)
221220
user = NestedUserSerializer()
@@ -239,7 +238,7 @@ def get_object(self, instance):
239238
class TagSerializer(ValidatedModelSerializer):
240239
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
241240
object_types = ContentTypeField(
242-
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
241+
queryset=ContentType.objects.with_feature('tags'),
243242
many=True,
244243
required=False
245244
)

netbox/extras/dashboard/widgets.py

+11-5
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,20 @@
3232
)
3333

3434

35-
def get_content_type_labels():
35+
def get_object_type_choices():
3636
return [
3737
(content_type_identifier(ct), content_type_name(ct))
3838
for ct in ContentType.objects.public().order_by('app_label', 'model')
3939
]
4040

4141

42+
def get_bookmarks_object_type_choices():
43+
return [
44+
(content_type_identifier(ct), content_type_name(ct))
45+
for ct in ContentType.objects.with_feature('bookmarks').order_by('app_label', 'model')
46+
]
47+
48+
4249
def get_models_from_content_types(content_types):
4350
"""
4451
Return a list of models corresponding to the given content types, identified by natural key.
@@ -158,7 +165,7 @@ class ObjectCountsWidget(DashboardWidget):
158165

159166
class ConfigForm(WidgetConfigForm):
160167
models = forms.MultipleChoiceField(
161-
choices=get_content_type_labels
168+
choices=get_object_type_choices
162169
)
163170
filters = forms.JSONField(
164171
required=False,
@@ -207,7 +214,7 @@ class ObjectListWidget(DashboardWidget):
207214

208215
class ConfigForm(WidgetConfigForm):
209216
model = forms.ChoiceField(
210-
choices=get_content_type_labels
217+
choices=get_object_type_choices
211218
)
212219
page_size = forms.IntegerField(
213220
required=False,
@@ -343,8 +350,7 @@ class BookmarksWidget(DashboardWidget):
343350

344351
class ConfigForm(WidgetConfigForm):
345352
object_types = forms.MultipleChoiceField(
346-
# TODO: Restrict the choices by FeatureQuery('bookmarks')
347-
choices=get_content_type_labels,
353+
choices=get_bookmarks_object_type_choices,
348354
required=False
349355
)
350356
order_by = forms.ChoiceField(

netbox/extras/forms/bulk_import.py

+4-9
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from core.models import ContentType
77
from extras.choices import *
88
from extras.models import *
9-
from extras.utils import FeatureQuery
109
from netbox.forms import NetBoxModelImportForm
1110
from utilities.forms import CSVModelForm
1211
from utilities.forms.fields import (
@@ -29,8 +28,7 @@
2928
class CustomFieldImportForm(CSVModelForm):
3029
content_types = CSVMultipleContentTypeField(
3130
label=_('Content types'),
32-
queryset=ContentType.objects.all(),
33-
limit_choices_to=FeatureQuery('custom_fields'),
31+
queryset=ContentType.objects.with_feature('custom_fields'),
3432
help_text=_("One or more assigned object types")
3533
)
3634
type = CSVChoiceField(
@@ -88,8 +86,7 @@ class Meta:
8886
class CustomLinkImportForm(CSVModelForm):
8987
content_types = CSVMultipleContentTypeField(
9088
label=_('Content types'),
91-
queryset=ContentType.objects.all(),
92-
limit_choices_to=FeatureQuery('custom_links'),
89+
queryset=ContentType.objects.with_feature('custom_links'),
9390
help_text=_("One or more assigned object types")
9491
)
9592

@@ -104,8 +101,7 @@ class Meta:
104101
class ExportTemplateImportForm(CSVModelForm):
105102
content_types = CSVMultipleContentTypeField(
106103
label=_('Content types'),
107-
queryset=ContentType.objects.all(),
108-
limit_choices_to=FeatureQuery('export_templates'),
104+
queryset=ContentType.objects.with_feature('export_templates'),
109105
help_text=_("One or more assigned object types")
110106
)
111107

@@ -142,8 +138,7 @@ class Meta:
142138
class WebhookImportForm(NetBoxModelImportForm):
143139
content_types = CSVMultipleContentTypeField(
144140
label=_('Content types'),
145-
queryset=ContentType.objects.all(),
146-
limit_choices_to=FeatureQuery('webhooks'),
141+
queryset=ContentType.objects.with_feature('webhooks'),
147142
help_text=_("One or more assigned object types")
148143
)
149144

0 commit comments

Comments
 (0)