Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
206201a
#17413: Remove redundant name & slug fields from Platform model
jeremystretch Aug 12, 2025
f206a22
#17413: Distinguish platforms by manufacturer when bulk importing dev…
jeremystretch Aug 12, 2025
77def92
#19740: Add parent column to PlatformTable
jeremystretch Aug 12, 2025
e4f0611
#19740: Annotate cumulative counts for platform child objects
jeremystretch Aug 12, 2025
a175030
#19740: Add missing advisory lock key
jeremystretch Aug 12, 2025
cd3f930
#18349: Adopt new job logging functionality (#19816)
jeremystretch Aug 12, 2025
4044ae8
#18204: Misc cleanup
jeremystretch Aug 12, 2025
128b6a9
#19231: Add bulk rename support for virtual circuits
jeremystretch Aug 12, 2025
32ee4cf
#19231: Add bulk rename support for image attachments
jeremystretch Aug 12, 2025
6198092
#18990: Add bulk edit & bulk delete support for image attachments
jeremystretch Aug 12, 2025
e4e4bb6
#19829: Update API URL for object type serializer
jeremystretch Aug 12, 2025
9cf023a
#19739: Include tab character as CSV delimiter choice
jeremystretch Aug 13, 2025
a3ce248
Add support for pipe character as delimiting character for bulk imports
jeremystretch Aug 13, 2025
8c1b39e
#19773: Include Django apps in system status view
jeremystretch Aug 13, 2025
d8b935e
#19713: Fix duplicate changelog_message fields on bulk edit forms wit…
jeremystretch Aug 13, 2025
fc8ee27
#19891: Fix duplicate background_job fields on bulk edit forms withou…
jeremystretch Aug 13, 2025
a0f55f6
#19735: Fix get_context() for ObjectAction subclasses
jeremystretch Aug 13, 2025
f083cf7
#19973: lsmodels() should prefix models with app label
jeremystretch Aug 13, 2025
4bd5b35
#19713: Remove changelog_message from bulk import form for unsupporte…
jeremystretch Aug 13, 2025
72c552d
#19816: Capture additional logging under ScriptJob
jeremystretch Aug 13, 2025
7fad58a
#19713: Extend render_form() template tag to support meta fields
jeremystretch Aug 13, 2025
3f475e0
#19924: Expose public & features fields in API serializer and enable …
jeremystretch Aug 14, 2025
e7a14e0
Adjust TODO release targets
jeremystretch Aug 14, 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
6 changes: 1 addition & 5 deletions netbox/circuits/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,7 @@
path('circuit-group-assignments/<int:pk>/', include(get_model_urls('circuits', 'circuitgroupassignment'))),

# Virtual circuits
path('virtual-circuits/', views.VirtualCircuitListView.as_view(), name='virtualcircuit_list'),
path('virtual-circuits/add/', views.VirtualCircuitEditView.as_view(), name='virtualcircuit_add'),
path('virtual-circuits/import/', views.VirtualCircuitBulkImportView.as_view(), name='virtualcircuit_bulk_import'),
path('virtual-circuits/edit/', views.VirtualCircuitBulkEditView.as_view(), name='virtualcircuit_bulk_edit'),
path('virtual-circuits/delete/', views.VirtualCircuitBulkDeleteView.as_view(), name='virtualcircuit_bulk_delete'),
path('virtual-circuits/', include(get_model_urls('circuits', 'virtualcircuit', detail=False))),
path('virtual-circuits/<int:pk>/', include(get_model_urls('circuits', 'virtualcircuit'))),

path('virtual-circuit-types/', include(get_model_urls('circuits', 'virtualcircuittype', detail=False))),
Expand Down
7 changes: 6 additions & 1 deletion netbox/circuits/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,7 @@ class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView):
# Virtual circuits
#

@register_model_view(VirtualCircuit, 'list', path='', detail=False)
class VirtualCircuitListView(generic.ObjectListView):
queryset = VirtualCircuit.objects.annotate(
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
Expand All @@ -701,6 +702,7 @@ class VirtualCircuitView(generic.ObjectView):
queryset = VirtualCircuit.objects.all()


@register_model_view(VirtualCircuit, 'add', detail=False)
@register_model_view(VirtualCircuit, 'edit')
class VirtualCircuitEditView(generic.ObjectEditView):
queryset = VirtualCircuit.objects.all()
Expand All @@ -712,6 +714,7 @@ class VirtualCircuitDeleteView(generic.ObjectDeleteView):
queryset = VirtualCircuit.objects.all()


@register_model_view(VirtualCircuit, 'bulk_import', path='import', detail=False)
class VirtualCircuitBulkImportView(generic.BulkImportView):
queryset = VirtualCircuit.objects.all()
model_form = forms.VirtualCircuitImportForm
Expand All @@ -727,6 +730,7 @@ def prep_related_object_data(self, parent, data):
return data


@register_model_view(VirtualCircuit, 'bulk_edit', path='edit', detail=False)
class VirtualCircuitBulkEditView(generic.BulkEditView):
queryset = VirtualCircuit.objects.annotate(
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
Expand All @@ -737,11 +741,12 @@ class VirtualCircuitBulkEditView(generic.BulkEditView):


@register_model_view(VirtualCircuit, 'bulk_rename', path='rename', detail=False)
class VirtualCircuitulkRenameView(generic.BulkRenameView):
class VirtualCircuitBulkRenameView(generic.BulkRenameView):
queryset = VirtualCircuit.objects.all()
field_name = 'cid'


@register_model_view(VirtualCircuit, 'bulk_delete', path='delete', detail=False)
class VirtualCircuitBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualCircuit.objects.annotate(
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
Expand Down
7 changes: 4 additions & 3 deletions netbox/core/api/serializers_/object_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@


class ObjectTypeSerializer(BaseModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:objecttype-detail')
url = serializers.HyperlinkedIdentityField(view_name='core-api:objecttype-detail')
app_name = serializers.CharField(source='app_verbose_name', read_only=True)
model_name = serializers.CharField(source='model_verbose_name', read_only=True)
model_name_plural = serializers.CharField(source='model_verbose_name_plural', read_only=True)
Expand All @@ -26,9 +26,10 @@ class ObjectTypeSerializer(BaseModelSerializer):
class Meta:
model = ObjectType
fields = [
'id', 'url', 'display', 'app_label', 'app_name', 'model', 'model_name', 'model_name_plural',
'is_plugin_model', 'rest_api_endpoint', 'description',
'id', 'url', 'display', 'app_label', 'app_name', 'model', 'model_name', 'model_name_plural', 'public',
'features', 'is_plugin_model', 'rest_api_endpoint', 'description',
]
read_only_fields = ['public', 'features']

@extend_schema_field(OpenApiTypes.STR)
def get_rest_api_endpoint(self, obj):
Expand Down
10 changes: 8 additions & 2 deletions netbox/core/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,15 +134,18 @@ def search(self, queryset, name, value):
)


class ObjectTypeFilterSet(django_filters.FilterSet):
class ObjectTypeFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
features = django_filters.CharFilter(
method='filter_features'
)

class Meta:
model = ObjectType
fields = ('id', 'app_label', 'model')
fields = ('id', 'app_label', 'model', 'public')

def search(self, queryset, name, value):
if not value.strip():
Expand All @@ -152,6 +155,9 @@ def search(self, queryset, name, value):
Q(model__icontains=value)
)

def filter_features(self, queryset, name, value):
return queryset.filter(features__icontains=value)


class ObjectChangeFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
Expand Down
72 changes: 35 additions & 37 deletions netbox/core/jobs.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import logging
import sys
from datetime import timedelta
from importlib import import_module
Expand All @@ -17,8 +16,6 @@
from .choices import DataSourceStatusChoices, JobIntervalChoices
from .models import DataSource

logger = logging.getLogger(__name__)


class SyncDataSourceJob(JobRunner):
"""
Expand Down Expand Up @@ -69,7 +66,11 @@ class Meta:

def run(self, *args, **kwargs):
# Skip if running in development or test mode
if settings.DEBUG or 'test' in sys.argv:
if settings.DEBUG:
self.logger.warning("Aborting execution: Debug is enabled")
return
if 'test' in sys.argv:
self.logger.warning("Aborting execution: Tests are running")
return

self.send_census_report()
Expand All @@ -78,17 +79,16 @@ def run(self, *args, **kwargs):
self.delete_expired_jobs()
self.check_for_new_releases()

@staticmethod
def send_census_report():
def send_census_report(self):
"""
Send a census report (if enabled).
"""
logging.info("Reporting census data...")
self.logger.info("Reporting census data...")
if settings.ISOLATED_DEPLOYMENT:
logging.info("ISOLATED_DEPLOYMENT is enabled; skipping")
self.logger.info("ISOLATED_DEPLOYMENT is enabled; skipping")
return
if not settings.CENSUS_REPORTING_ENABLED:
logging.info("CENSUS_REPORTING_ENABLED is disabled; skipping")
self.logger.info("CENSUS_REPORTING_ENABLED is disabled; skipping")
return

census_data = {
Expand All @@ -106,73 +106,71 @@ def send_census_report():
except requests.exceptions.RequestException:
pass

@staticmethod
def clear_expired_sessions():
def clear_expired_sessions(self):
"""
Clear any expired sessions from the database.
"""
logging.info("Clearing expired sessions...")
self.logger.info("Clearing expired sessions...")
engine = import_module(settings.SESSION_ENGINE)
try:
engine.SessionStore.clear_expired()
logging.info("Sessions cleared.")
self.logger.info("Sessions cleared.")
except NotImplementedError:
logging.warning(
self.logger.warning(
f"The configured session engine ({settings.SESSION_ENGINE}) does not support "
f"clearing sessions; skipping."
)

@staticmethod
def prune_changelog():
def prune_changelog(self):
"""
Delete any ObjectChange records older than the configured changelog retention time (if any).
"""
logging.info("Pruning old changelog entries...")
self.logger.info("Pruning old changelog entries...")
config = Config()
if not config.CHANGELOG_RETENTION:
logging.info("No retention period specified; skipping.")
self.logger.info("No retention period specified; skipping.")
return

cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION)
logging.debug(f"Retention period: {config.CHANGELOG_RETENTION} days")
logging.debug(f"Cut-off time: {cutoff}")
self.logger.debug(
f"Changelog retention period: {config.CHANGELOG_RETENTION} days ({cutoff:%Y-%m-%d %H:%M:%S})"
)

count = ObjectChange.objects.filter(time__lt=cutoff).delete()[0]
logging.info(f"Deleted {count} expired records")
self.logger.info(f"Deleted {count} expired changelog records")

@staticmethod
def delete_expired_jobs():
def delete_expired_jobs(self):
"""
Delete any jobs older than the configured retention period (if any).
"""
logging.info("Deleting expired jobs...")
self.logger.info("Deleting expired jobs...")
config = Config()
if not config.JOB_RETENTION:
logging.info("No retention period specified; skipping.")
self.logger.info("No retention period specified; skipping.")
return

cutoff = timezone.now() - timedelta(days=config.JOB_RETENTION)
logging.debug(f"Retention period: {config.CHANGELOG_RETENTION} days")
logging.debug(f"Cut-off time: {cutoff}")
self.logger.debug(
f"Job retention period: {config.JOB_RETENTION} days ({cutoff:%Y-%m-%d %H:%M:%S})"
)

count = Job.objects.filter(created__lt=cutoff).delete()[0]
logging.info(f"Deleted {count} expired records")
self.logger.info(f"Deleted {count} expired jobs")

@staticmethod
def check_for_new_releases():
def check_for_new_releases(self):
"""
Check for new releases and cache the latest release.
"""
logging.info("Checking for new releases...")
self.logger.info("Checking for new releases...")
if settings.ISOLATED_DEPLOYMENT:
logging.info("ISOLATED_DEPLOYMENT is enabled; skipping")
self.logger.info("ISOLATED_DEPLOYMENT is enabled; skipping")
return
if not settings.RELEASE_CHECK_URL:
logging.info("RELEASE_CHECK_URL is not set; skipping")
self.logger.info("RELEASE_CHECK_URL is not set; skipping")
return

# Fetch the latest releases
logging.debug(f"Release check URL: {settings.RELEASE_CHECK_URL}")
self.logger.debug(f"Release check URL: {settings.RELEASE_CHECK_URL}")
try:
response = requests.get(
url=settings.RELEASE_CHECK_URL,
Expand All @@ -181,7 +179,7 @@ def check_for_new_releases():
)
response.raise_for_status()
except requests.exceptions.RequestException as exc:
logging.error(f"Error fetching release: {exc}")
self.logger.error(f"Error fetching release: {exc}")
return

# Determine the most recent stable release
Expand All @@ -191,8 +189,8 @@ def check_for_new_releases():
continue
releases.append((version.parse(release['tag_name']), release.get('html_url')))
latest_release = max(releases)
logging.debug(f"Found {len(response.json())} releases; {len(releases)} usable")
logging.info(f"Latest release: {latest_release[0]}")
self.logger.debug(f"Found {len(response.json())} releases; {len(releases)} usable")
self.logger.info(f"Latest release: {latest_release[0]}")

# Cache the most recent release
cache.set('latest_release', latest_release, None)
4 changes: 2 additions & 2 deletions netbox/core/management/commands/nbshell.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ def _lsmodels(self, app_label=None):
for app_label in app_labels:
app_name = apps.get_app_config(app_label).verbose_name
print(f'{app_name}:')
for m in self.django_models[app_label]:
print(f' {m}')
for model in self.django_models[app_label]:
print(f' {app_label}.{model}')

def get_namespace(self):
namespace = defaultdict(SimpleNamespace)
Expand Down
45 changes: 45 additions & 0 deletions netbox/core/tests/test_filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,48 @@ def test_changed_object_type(self):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)


class ObjectTypeTestCase(TestCase, BaseFilterSetTests):
queryset = ObjectType.objects.all()
filterset = ObjectTypeFilterSet
ignore_fields = (
'custom_fields',
'custom_links',
'event_rules',
'export_templates',
'object_permissions',
'saved_filters',
)

def test_q(self):
params = {'q': 'vrf'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)

def test_app_label(self):
self.assertEqual(
self.filterset({'app_label': ['dcim']}, self.queryset).qs.count(),
ObjectType.objects.filter(app_label='dcim').count(),
)

def test_model(self):
self.assertEqual(
self.filterset({'model': ['site']}, self.queryset).qs.count(),
ObjectType.objects.filter(model='site').count(),
)

def test_public(self):
self.assertEqual(
self.filterset({'public': True}, self.queryset).qs.count(),
ObjectType.objects.filter(public=True).count(),
)
self.assertEqual(
self.filterset({'public': False}, self.queryset).qs.count(),
ObjectType.objects.filter(public=False).count(),
)

def test_feature(self):
self.assertEqual(
self.filterset({'features': 'tags'}, self.queryset).qs.count(),
ObjectType.objects.filter(features__contains=['tags']).count(),
)
6 changes: 6 additions & 0 deletions netbox/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from netbox.views import generic
from netbox.views.generic.base import BaseObjectView
from netbox.views.generic.mixins import TableMixin
from utilities.apps import get_installed_apps
from utilities.data import shallow_compare_dict
from utilities.forms import ConfirmationForm
from utilities.htmx import htmx_partial
Expand Down Expand Up @@ -569,6 +570,9 @@ def get(self, request):
'rq_worker_count': Worker.count(get_connection('default')),
}

# Django apps
django_apps = get_installed_apps()

# Configuration
config = get_config()

Expand All @@ -587,6 +591,7 @@ def get(self, request):
params = [param.name for param in PARAMS]
data = {
**stats,
'django_apps': django_apps,
'plugins': plugins,
'config': {
k: getattr(config, k) for k in sorted(params)
Expand All @@ -606,6 +611,7 @@ def get(self, request):

return render(request, 'core/system.html', {
'stats': stats,
'django_apps': django_apps,
'config': config,
'plugins': plugins,
'objects': objects,
Expand Down
7 changes: 4 additions & 3 deletions netbox/dcim/api/serializers_/platforms.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from rest_framework import serializers

from dcim.models import Platform
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
from netbox.api.fields import RelatedObjectCountField
from netbox.api.serializers import NestedGroupModelSerializer
from .manufacturers import ManufacturerSerializer
from .nested import NestedPlatformSerializer
Expand All @@ -16,8 +17,8 @@ class PlatformSerializer(NestedGroupModelSerializer):
config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)

# Related object counts
device_count = RelatedObjectCountField('devices')
virtualmachine_count = RelatedObjectCountField('virtual_machines')
device_count = serializers.IntegerField(read_only=True, default=0)
virtualmachine_count = serializers.IntegerField(read_only=True, default=0)

class Meta:
model = Platform
Expand Down
Loading