Skip to content

Commit

Permalink
Closes #14153: Filter ContentTypes by supported feature (#14191)
Browse files Browse the repository at this point in the history
* WIP

* Remove FeatureQuery

* Standardize use of proxy ContentType for models

* Remove TODO

* Correctly filter BookmarksWidget object_types choices

* Add feature-specific object type validation
  • Loading branch information
jeremystretch authored Nov 16, 2023
1 parent 69a4c31 commit e15647a
Show file tree
Hide file tree
Showing 30 changed files with 152 additions and 142 deletions.
4 changes: 1 addition & 3 deletions netbox/core/forms/filtersets.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 1 addition & 2 deletions netbox/core/migrations/0003_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import extras.utils


class Migration(migrations.Migration):
Expand All @@ -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={
Expand Down
18 changes: 18 additions & 0 deletions netbox/core/models/contenttypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,24 @@ def public(self):
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_):
"""
Expand Down
3 changes: 1 addition & 2 deletions netbox/core/models/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

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
Expand Down Expand Up @@ -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='+'
)
Expand Down
16 changes: 12 additions & 4 deletions netbox/core/models/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@
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
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
Expand All @@ -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(
Expand Down Expand Up @@ -123,6 +122,15 @@ def get_absolute_url(self):
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:
Expand Down
5 changes: 2 additions & 3 deletions netbox/dcim/models/cables.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,19 @@
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
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
Expand Down Expand Up @@ -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='+'
Expand Down
3 changes: 1 addition & 2 deletions netbox/dcim/models/device_component_templates.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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='+',
Expand Down
3 changes: 1 addition & 2 deletions netbox/dcim/models/device_components.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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='+',
Expand Down
15 changes: 7 additions & 8 deletions netbox/extras/api/serializers.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -14,7 +14,6 @@
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
Expand Down Expand Up @@ -64,7 +63,7 @@
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
)

Expand All @@ -85,7 +84,7 @@ class Meta:
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)
Expand Down Expand Up @@ -151,7 +150,7 @@ class Meta:
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
)

Expand All @@ -170,7 +169,7 @@ class Meta:
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(
Expand Down Expand Up @@ -215,7 +214,7 @@ class Meta:
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()
Expand All @@ -239,7 +238,7 @@ def get_object(self, instance):
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
)
Expand Down
16 changes: 11 additions & 5 deletions netbox/extras/dashboard/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,20 @@
)


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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
13 changes: 4 additions & 9 deletions netbox/extras/forms/bulk_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
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 (
Expand All @@ -29,8 +28,7 @@
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(
Expand Down Expand Up @@ -88,8 +86,7 @@ class Meta:
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")
)

Expand All @@ -104,8 +101,7 @@ class Meta:
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")
)

Expand Down Expand Up @@ -142,8 +138,7 @@ class Meta:
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")
)

Expand Down
Loading

0 comments on commit e15647a

Please sign in to comment.