Merge branch 'feature' into 13299-custom-field-hiding

This commit is contained in:
Jeremy Stretch 2023-11-16 13:46:29 -05:00
commit 164b2324ce
40 changed files with 255 additions and 176 deletions

View File

@ -53,6 +53,10 @@ This store maintains all registered items for plugins, such as navigation menus,
A dictionary mapping each model (identified by its app and label) to its search index class, if one has been registered for it. A dictionary mapping each model (identified by its app and label) to its search index class, if one has been registered for it.
### `tables`
A dictionary mapping table classes to lists of extra columns that have been registered by plugins using the `register_table_column()` utility function. Each column is defined as a tuple of name and column instance.
### `views` ### `views`
A hierarchical mapping of registered views for each model. Mappings are added using the `register_model_view()` decorator, and URLs paths can be generated from these using `get_model_urls()`. A hierarchical mapping of registered views for each model. Mappings are added using the `register_model_view()` decorator, and URLs paths can be generated from these using `get_model_urls()`.

View File

@ -87,3 +87,28 @@ The table column classes listed below are supported for use in plugins. These cl
options: options:
members: members:
- __init__ - __init__
## Extending Core Tables
!!! info "This feature was introduced in NetBox v3.7."
Plugins can register their own custom columns on core tables using the `register_table_column()` utility function. This allows a plugin to attach additional information, such as relationships to its own models, to built-in object lists.
```python
import django_tables2
from django.utils.translation import gettext_lazy as _
from dcim.tables import SiteTable
from utilities.tables import register_table_column
mycol = django_tables2.Column(
verbose_name=_('My Column'),
accessor=django_tables2.A('description')
)
register_table_column(mycol, 'foo', SiteTable)
```
You'll typically want to define an accessor identifying the desired model field or relationship when defining a custom column. See the [django-tables2 documentation](https://django-tables2.readthedocs.io/) for more information on creating custom columns.
::: utilities.tables.register_table_column

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)
@ -152,7 +151,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
) )
@ -171,7 +170,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(
@ -216,7 +215,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()
@ -240,7 +239,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(
@ -95,8 +93,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")
) )
@ -111,8 +108,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")
) )
@ -149,8 +145,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')
) )
@ -113,7 +112,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(
@ -156,7 +155,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')
) )
@ -184,7 +183,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(
@ -233,7 +232,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')
) )
@ -289,12 +288,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,7 +5,6 @@ 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
@ -13,9 +12,9 @@ from django.urls import reverse
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
@ -59,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(
@ -72,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 * from extras.choices import *
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

@ -28,6 +28,7 @@ registry = Registry({
'models': collections.defaultdict(set), 'models': collections.defaultdict(set),
'plugins': dict(), 'plugins': dict(),
'search': dict(), 'search': dict(),
'tables': collections.defaultdict(dict),
'views': collections.defaultdict(dict), 'views': collections.defaultdict(dict),
'widgets': dict(), 'widgets': dict(),
}) })

View File

@ -1,3 +1,5 @@
from copy import deepcopy
import django_tables2 as tables import django_tables2 as tables
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
@ -10,8 +12,9 @@ from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_tables2.data import TableQuerysetData from django_tables2.data import TableQuerysetData
from extras.models import CustomField, CustomLink
from extras.choices import * from extras.choices import *
from extras.models import CustomField, CustomLink
from netbox.registry import registry
from netbox.tables import columns from netbox.tables import columns
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import get_viewname, highlight_string, title from utilities.utils import get_viewname, highlight_string, title
@ -191,12 +194,17 @@ class NetBoxTable(BaseTable):
if extra_columns is None: if extra_columns is None:
extra_columns = [] extra_columns = []
if registered_columns := registry['tables'].get(self.__class__):
extra_columns.extend([
# Create a copy to avoid modifying the original Column
(name, deepcopy(column)) for name, column in registered_columns.items()
])
# Add custom field & custom link columns # Add custom field & custom link columns
content_type = ContentType.objects.get_for_model(self._meta.model) content_type = ContentType.objects.get_for_model(self._meta.model)
custom_fields = CustomField.objects.filter( custom_fields = CustomField.objects.filter(
content_types=content_type content_types=content_type
).exclude(ui_visible=CustomFieldUIVisibleChoices.HIDDEN) ).exclude(ui_visible=CustomFieldUIVisibleChoices.HIDDEN)
extra_columns.extend([ extra_columns.extend([
(f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
]) ])

View File

@ -0,0 +1,11 @@
import django_tables2 as tables
from dcim.tables import SiteTable
from utilities.tables import register_table_column
mycol = tables.Column(
verbose_name='My column',
accessor=tables.A('description')
)
register_table_column(mycol, 'foo', SiteTable)

View File

@ -4,6 +4,8 @@ from django.views.generic import View
from dcim.models import Site from dcim.models import Site
from utilities.views import register_model_view from utilities.views import register_model_view
from .models import DummyModel from .models import DummyModel
# Trigger registration of custom column
from .tables import mycol
class DummyModelsView(View): class DummyModelsView(View):

View File

@ -97,6 +97,16 @@ class PluginTest(TestCase):
self.assertIn(SiteContent, registry['plugins']['template_extensions']['dcim.site']) self.assertIn(SiteContent, registry['plugins']['template_extensions']['dcim.site'])
def test_registered_columns(self):
"""
Check that a plugin can register a custom column on a core model table.
"""
from dcim.models import Site
from dcim.tables import SiteTable
table = SiteTable(Site.objects.all())
self.assertIn('foo', table.columns.names())
def test_user_preferences(self): def test_user_preferences(self):
""" """
Check that plugin UserPreferences are registered. Check that plugin UserPreferences are registered.

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

@ -2,14 +2,9 @@ from django.contrib.contenttypes.models import ContentType
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from circuits.models import Circuit
from dcim.models import Cable, Device, Location, PowerFeed, Rack, RackReservation, Site, VirtualDeviceContext
from ipam.models import Aggregate, ASN, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF
from netbox.views import generic from netbox.views import generic
from utilities.utils import count_related from utilities.utils import count_related, get_related_models
from utilities.views import register_model_view, ViewTab from utilities.views import register_model_view, ViewTab
from virtualization.models import VirtualMachine, Cluster
from wireless.models import WirelessLAN, WirelessLink
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .models import * from .models import *
@ -132,32 +127,8 @@ class TenantView(generic.ObjectView):
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = [ related_models = [
# DCIM (model.objects.restrict(request.user, 'view').filter(tenant=instance), f'{field}_id')
(Site.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), for model, field in get_related_models(Tenant)
(Rack.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(RackReservation.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(Location.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(Device.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(VirtualDeviceContext.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(Cable.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(PowerFeed.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
# IPAM
(VRF.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(Prefix.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(IPRange.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(ASN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(VLAN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(L2VPN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
# Circuits
(Circuit.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
# Virtualization
(VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(Cluster.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
# Wireless
(WirelessLAN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(WirelessLink.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
] ]
return { return {

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'
) )

View File

@ -1,6 +1,9 @@
from netbox.registry import registry
__all__ = ( __all__ = (
'get_table_ordering', 'get_table_ordering',
'linkify_phone', 'linkify_phone',
'register_table_column'
) )
@ -26,3 +29,19 @@ def linkify_phone(value):
if value is None: if value is None:
return None return None
return f"tel:{value}" return f"tel:{value}"
def register_table_column(column, name, *tables):
"""
Register a custom column for use on one or more tables.
Args:
column: The column instance to register
name: The name of the table column
tables: One or more table classes
"""
for table in tables:
reg = registry['tables'][table]
if name in reg:
raise ValueError(f"A column named {name} is already defined for table {table.__name__}")
reg[name] = column

View File

@ -8,7 +8,7 @@ from itertools import count, groupby
import bleach import bleach
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core import serializers from django.core import serializers
from django.db.models import Count, OuterRef, Subquery from django.db.models import Count, ManyToOneRel, OuterRef, Subquery
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.http import QueryDict from django.http import QueryDict
from django.utils import timezone from django.utils import timezone
@ -567,3 +567,20 @@ def local_now():
Return the current date & time in the system timezone. Return the current date & time in the system timezone.
""" """
return localtime(timezone.now()) return localtime(timezone.now())
def get_related_models(model, ordered=True):
"""
Return a list of all models which have a ForeignKey to the given model and the name of the field. For example,
`get_related_models(Tenant)` will return all models which have a ForeignKey relationship to Tenant.
"""
related_models = [
(field.related_model, field.remote_field.name)
for field in model._meta.related_objects
if type(field) is ManyToOneRel
]
if ordered:
return sorted(related_models, key=lambda x: x[0]._meta.verbose_name)
return related_models