Skip to content

Closes #19924: Record model features on ObjectType #19939

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 20 commits into
base: feature
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
30dfe5a
Convert ObjectType to a concrete child model of ContentType
jeremystretch Jul 22, 2025
1871f6f
Add public flag to ObjectType
jeremystretch Jul 22, 2025
28e5543
Catch post_migrate signal to update ObjectTypes
jeremystretch Jul 22, 2025
68edba8
Reference ObjectType records instead of registry for feature support
jeremystretch Jul 22, 2025
e38ba29
Automatically create ObjectTypes
jeremystretch Jul 23, 2025
0d8b6c7
Introduce has_feature() utility function
jeremystretch Jul 23, 2025
34d9ecb
ObjectTypeManager should not inherit from ContentTypeManager
jeremystretch Jul 24, 2025
533fe55
Misc cleanup
jeremystretch Jul 24, 2025
13c3ce3
Don't populate ObjectTypes during migration
jeremystretch Jul 24, 2025
3589f73
Don't automatically create ObjectTypes when a ContentType is created
jeremystretch Jul 24, 2025
9f2ef2b
Fix test
jeremystretch Jul 24, 2025
943f98e
Extend has_feature() to accept a model or OT/CT
jeremystretch Jul 24, 2025
944ea00
Misc cleanup
jeremystretch Jul 24, 2025
b31f185
Deprecate get_for_id() on ObjectTypeManager
jeremystretch Jul 24, 2025
8fd8445
Rename contenttypes.py to object_types.py
jeremystretch Jul 25, 2025
5fe4c96
Add index to features ArrayField
jeremystretch Jul 25, 2025
5695586
Keep FK & M2M fields pointing to ContentType
jeremystretch Jul 25, 2025
b896f71
Add get_for_models() to ObjectTypeManager
jeremystretch Jul 25, 2025
9ccc7d2
Add tests for manager methods & utility functions
jeremystretch Jul 25, 2025
0b5561b
Fix migrations for M2M relations to ObjectType
jeremystretch Jul 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions netbox/core/migrations/0008_contenttype_proxy.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import core.models.contenttypes
import core.models.object_types
from django.db import migrations


Expand All @@ -19,7 +19,7 @@ class Migration(migrations.Migration):
},
bases=('contenttypes.contenttype',),
managers=[
('objects', core.models.contenttypes.ObjectTypeManager()),
('objects', core.models.object_types.ObjectTypeManager()),
],
),
]
62 changes: 62 additions & 0 deletions netbox/core/migrations/0017_concrete_objecttype.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import django.contrib.postgres.fields
import django.contrib.postgres.indexes
import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('core', '0016_job_log_entries'),
]

operations = [
# Delete the proxy model from the migration state
migrations.DeleteModel(
name='ObjectType',
),
# Create the new concrete model
migrations.CreateModel(
name='ObjectType',
fields=[
(
'contenttype_ptr',
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to='contenttypes.contenttype',
related_name='object_type'
)
),
(
'public',
models.BooleanField(
default=False
)
),
(
'features',
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=50),
default=list,
size=None
)
),
],
options={
'verbose_name': 'object type',
'verbose_name_plural': 'object types',
'indexes': [
django.contrib.postgres.indexes.GinIndex(
fields=['features'],
name='core_object_feature_aec4de_gin'
),
]
},
bases=('contenttypes.contenttype',),
managers=[],
),
]
2 changes: 1 addition & 1 deletion netbox/core/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .contenttypes import *
from .object_types import *
from .change_logging import *
from .config import *
from .data import *
Expand Down
4 changes: 2 additions & 2 deletions netbox/core/models/change_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
from core.choices import ObjectChangeActionChoices
from core.querysets import ObjectChangeQuerySet
from netbox.models.features import ChangeLoggingMixin
from netbox.models.features import has_feature
from utilities.data import shallow_compare_dict
from .contenttypes import ObjectType

__all__ = (
'ObjectChange',
Expand Down Expand Up @@ -118,7 +118,7 @@ def clean(self):
super().clean()

# Validate the assigned object type
if self.changed_object_type not in ObjectType.objects.with_feature('change_logging'):
if not has_feature(self.changed_object_type, 'change_logging'):
raise ValidationError(
_("Change logging is not supported for this object type ({type}).").format(
type=self.changed_object_type
Expand Down
81 changes: 3 additions & 78 deletions netbox/core/models/contenttypes.py
Original file line number Diff line number Diff line change
@@ -1,78 +1,3 @@
from django.contrib.contenttypes.models import ContentType, ContentTypeManager
from django.db.models import Q

from netbox.plugins import PluginConfig
from netbox.registry import registry
from utilities.string import title

__all__ = (
'ObjectType',
'ObjectTypeManager',
)


class ObjectTypeManager(ContentTypeManager):

def public(self):
"""
Filter the base queryset to return only ContentTypes corresponding to "public" models; those which are listed
in registry['models'] and intended for reference by other objects.
"""
q = Q()
for app_label, models in registry['models'].items():
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('event_rules')
"""
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 ObjectType(ContentType):
"""
Wrap Django's native ContentType model to use our custom manager.
"""
objects = ObjectTypeManager()

class Meta:
proxy = True

@property
def app_labeled_name(self):
# Override ContentType's "app | model" representation style.
return f"{self.app_verbose_name} > {title(self.model_verbose_name)}"

@property
def app_verbose_name(self):
if model := self.model_class():
return model._meta.app_config.verbose_name

@property
def model_verbose_name(self):
if model := self.model_class():
return model._meta.verbose_name

@property
def model_verbose_name_plural(self):
if model := self.model_class():
return model._meta.verbose_name_plural

@property
def is_plugin_model(self):
if not (model := self.model_class()):
return # Return null if model class is invalid
return isinstance(model._meta.app_config, PluginConfig)
# TODO: Remove this module in NetBox v4.5
# Provided for backward compatibility
from .object_types import *
3 changes: 2 additions & 1 deletion netbox/core/models/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from core.dataclasses import JobLogEntry
from core.models import ObjectType
from core.signals import job_end, job_start
from netbox.models.features import has_feature
from utilities.json import JobLogDecoder
from utilities.querysets import RestrictedQuerySet
from utilities.rqworker import get_queue_for_model
Expand Down Expand Up @@ -148,7 +149,7 @@ def clean(self):
super().clean()

# Validate the assigned object type
if self.object_type and self.object_type not in ObjectType.objects.with_feature('jobs'):
if self.object_type and not has_feature(self.object_type, 'jobs'):
raise ValidationError(
_("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type)
)
Expand Down
Loading