diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 5e456d0df..b3dd583ca 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.5.3 + placeholder: v3.5.4 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index e317dd64c..bd93001e7 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.5.3 + placeholder: v3.5.4 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index f2a3de0e8..7ad333e47 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -1,21 +1,31 @@ # NetBox v3.5 -## v3.5.4 (FUTURE) +## v3.5.5 (FUTURE) + +--- + +## v3.5.4 (2023-06-20) ### Enhancements +* [#12828](https://github.com/netbox-community/netbox/issues/12828) - Define colors for staged change action choices * [#12847](https://github.com/netbox-community/netbox/issues/12847) - Include "add" button on all device & virtual machine component list views * [#12862](https://github.com/netbox-community/netbox/issues/12862) - Add menu navigation button to add wireless links directly +* [#12865](https://github.com/netbox-community/netbox/issues/12865) - Add "add" buttons for reports & scripts to navigation menu ### Bug Fixes +* [#12474](https://github.com/netbox-community/netbox/issues/12474) - Update cable terminations when assigning a location to a new site * [#12622](https://github.com/netbox-community/netbox/issues/12622) - Permit the assignment of non-site VLANs to prefixes assigned to a site * [#12682](https://github.com/netbox-community/netbox/issues/12682) - Correct OpenAPI schema for connected device API endpoint * [#12687](https://github.com/netbox-community/netbox/issues/12687) - Allow the assignment of all /31 IP addresses to interfaces * [#12818](https://github.com/netbox-community/netbox/issues/12818) - Fix permissions evaluation when queuing a data sync job * [#12822](https://github.com/netbox-community/netbox/issues/12822) - Fix encoding of whitespace in custom link URLs * [#12838](https://github.com/netbox-community/netbox/issues/12838) - Correct rounding of rack power utilization values +* [#12845](https://github.com/netbox-community/netbox/issues/12845) - Fix pagination of objects for related IP addresses table * [#12850](https://github.com/netbox-community/netbox/issues/12850) - Fix table configuration modal for the contact assignments list +* [#12885](https://github.com/netbox-community/netbox/issues/12885) - Permit mounting of devices in rack unit 100 +* [#12914](https://github.com/netbox-community/netbox/issues/12914) - Clear stored ordering from user config when cleared by request --- diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index 7c3f2ab09..d8624f6b6 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -1,5 +1,5 @@ from django import forms -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ @@ -105,7 +105,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm): widget=DateTimePicker() ) user = DynamicModelMultipleChoiceField( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), required=False, label=_('User'), widget=APISelectMultiple( diff --git a/netbox/core/management/commands/nbshell.py b/netbox/core/management/commands/nbshell.py index 04a67eb49..674a878c7 100644 --- a/netbox/core/management/commands/nbshell.py +++ b/netbox/core/management/commands/nbshell.py @@ -5,7 +5,7 @@ import sys from django import get_version from django.apps import apps from django.conf import settings -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand @@ -60,7 +60,7 @@ class Command(BaseCommand): # Additional objects to include namespace['ContentType'] = ContentType - namespace['User'] = User + namespace['User'] = get_user_model() # Load convenience commands namespace.update({ diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index a91e75e61..9be06bd6d 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -1,7 +1,7 @@ import uuid import django_rq -from django.contrib.auth.models import User +from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.validators import MinValueValidator @@ -69,7 +69,7 @@ class Job(models.Model): blank=True ) user = models.ForeignKey( - to=User, + to=settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name='+', blank=True, diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 80d7558c9..b3c065b5a 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -11,6 +11,7 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff, # RACK_U_HEIGHT_DEFAULT = 42 +RACK_U_HEIGHT_MAX = 100 RACK_ELEVATION_BORDER_WIDTH = 2 RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30 diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index e87a37847..e53ea8079 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1,5 +1,5 @@ import django_filters -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.utils.translation import gettext as _ from extras.filtersets import LocalConfigContextFilterSet @@ -395,12 +395,12 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet): label=_('Location (slug)'), ) user_id = django_filters.ModelMultipleChoiceFilter( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), label=_('User (ID)'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), to_field_name='username', label=_('User (name)'), ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 11cfd685d..309370bfd 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1,6 +1,6 @@ from django import forms from django.conf import settings -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.utils.translation import gettext as _ from timezone_field import TimeZoneFormField @@ -322,7 +322,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): class RackReservationBulkEditForm(NetBoxModelBulkEditForm): user = forms.ModelChoiceField( - queryset=User.objects.order_by( + queryset=get_user_model().objects.order_by( 'username' ), required=False diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 4edee6014..0a4a22a70 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1,5 +1,5 @@ from django import forms -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.utils.translation import gettext as _ from dcim.choices import * @@ -376,7 +376,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): label=_('Rack') ) user_id = DynamicModelMultipleChoiceField( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), required=False, label=_('User'), widget=APISelectMultiple( diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 56542d70c..eda302736 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1,5 +1,5 @@ from django import forms -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ from timezone_field import TimeZoneFormField @@ -236,7 +236,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm): help_text=_("Comma-separated list of numeric unit IDs. A range may be specified using a hyphen.") ) user = forms.ModelChoiceField( - queryset=User.objects.order_by( + queryset=get_user_model().objects.order_by( 'username' ) ) diff --git a/netbox/dcim/migrations/0154_half_height_rack_units.py b/netbox/dcim/migrations/0154_half_height_rack_units.py index dd21fddcf..f212aa21a 100644 --- a/netbox/dcim/migrations/0154_half_height_rack_units.py +++ b/netbox/dcim/migrations/0154_half_height_rack_units.py @@ -18,6 +18,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='device', name='position', - field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(99.5)]), + field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100.5)]), ), ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 30fafef94..ece02105c 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -555,7 +555,7 @@ class Device(PrimaryModel, ConfigContextModel): decimal_places=1, blank=True, null=True, - validators=[MinValueValidator(1), MaxValueValidator(99.5)], + validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX + 0.5)], verbose_name='Position (U)', help_text=_('The lowest-numbered unit occupied by the device') ) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 54de5c434..5ac223c45 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -1,7 +1,7 @@ import decimal from functools import cached_property -from django.contrib.auth.models import User +from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError @@ -126,7 +126,7 @@ class Rack(PrimaryModel, WeightMixin): u_height = models.PositiveSmallIntegerField( default=RACK_U_HEIGHT_DEFAULT, verbose_name='Height (U)', - validators=[MinValueValidator(1), MaxValueValidator(100)], + validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)], help_text=_('Height in rack units') ) desc_units = models.BooleanField( @@ -505,7 +505,7 @@ class RackReservation(PrimaryModel): null=True ) user = models.ForeignKey( - to=User, + to=settings.AUTH_USER_MODEL, on_delete=models.PROTECT ) description = models.CharField( diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 7ef08d2cc..a51872719 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -27,6 +27,7 @@ def handle_location_site_change(instance, created, **kwargs): Rack.objects.filter(location__in=locations).update(site=instance.site) Device.objects.filter(location__in=locations).update(site=instance.site) PowerPanel.objects.filter(location__in=locations).update(site=instance.site) + CableTermination.objects.filter(_location__in=locations).update(_site=instance.site) @receiver(post_save, sender=Rack) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index af15e1343..ecaf32a06 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.test import override_settings from django.urls import reverse from rest_framework import status @@ -14,6 +14,9 @@ from wireless.choices import WirelessChannelChoices from wireless.models import WirelessLAN +User = get_user_model() + + class AppTest(APITestCase): def test_root(self): diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index aa6860a16..a1e684cb9 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.test import TestCase from dcim.choices import * @@ -12,6 +12,9 @@ from virtualization.models import Cluster, ClusterType from wireless.choices import WirelessChannelChoices, WirelessRoleChoices +User = get_user_model() + + class DeviceComponentFilterSetTests: def test_device_type(self): diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index a327d6400..23683ddce 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -6,7 +6,7 @@ except ImportError: from backports.zoneinfo import ZoneInfo import yaml -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.test import override_settings from django.urls import reverse @@ -22,6 +22,9 @@ from utilities.testing import ViewTestCases, create_tags, create_test_device, po from wireless.models import WirelessLAN +User = get_user_model() + + class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Region diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index cbe4ed56d..a02e933ba 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers @@ -256,7 +256,7 @@ class JournalEntrySerializer(NetBoxModelSerializer): assigned_object = serializers.SerializerMethodField(read_only=True) created_by = serializers.PrimaryKeyRelatedField( allow_null=True, - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), required=False, default=serializers.CurrentUserDefault() ) diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 6fc14b965..63bdbf7db 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -210,7 +210,7 @@ class ChangeActionChoices(ChoiceSet): ACTION_DELETE = 'delete' CHOICES = ( - (ACTION_CREATE, 'Create'), - (ACTION_UPDATE, 'Update'), - (ACTION_DELETE, 'Delete'), + (ACTION_CREATE, 'Create', 'green'), + (ACTION_UPDATE, 'Update', 'blue'), + (ACTION_DELETE, 'Delete', 'red'), ) diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 5253ae7b0..2cbaca5f7 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -1,5 +1,5 @@ import django_filters -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.utils.translation import gettext as _ @@ -159,12 +159,12 @@ class SavedFilterFilterSet(BaseFilterSet): ) content_types = ContentTypeFilter() user_id = django_filters.ModelMultipleChoiceFilter( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), label=_('User (ID)'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), to_field_name='username', label=_('User (name)'), ) @@ -223,12 +223,12 @@ class JournalEntryFilterSet(NetBoxModelFilterSet): queryset=ContentType.objects.all() ) created_by_id = django_filters.ModelMultipleChoiceFilter( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), label=_('User (ID)'), ) created_by = django_filters.ModelMultipleChoiceFilter( field_name='created_by__username', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), to_field_name='username', label=_('User (name)'), ) @@ -510,12 +510,12 @@ class ObjectChangeFilterSet(BaseFilterSet): queryset=ContentType.objects.all() ) user_id = django_filters.ModelMultipleChoiceFilter( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), label=_('User (ID)'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), to_field_name='username', label=_('User name'), ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index fae15d041..53de81ba2 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -1,5 +1,5 @@ from django import forms -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ @@ -385,7 +385,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm): widget=DateTimePicker() ) created_by_id = DynamicModelMultipleChoiceField( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), required=False, label=_('User'), widget=APISelectMultiple( @@ -429,7 +429,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): required=False ) user_id = DynamicModelMultipleChoiceField( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), required=False, label=_('User'), widget=APISelectMultiple( diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index b42e9b47d..d9a9f41ae 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -4,7 +4,7 @@ import sys import traceback import uuid -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand, CommandError from django.db import transaction @@ -63,6 +63,8 @@ class Command(BaseCommand): logger.info(f"Script completed in {job.duration}") + User = get_user_model() + # Params script = options['script'] loglevel = options['loglevel'] diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index e2b118b84..54d72cdd8 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models @@ -24,7 +24,7 @@ class ObjectChange(models.Model): db_index=True ) user = models.ForeignKey( - to=User, + to=settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name='changes', blank=True, diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index e95c0aff3..6c7aac08d 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -3,7 +3,7 @@ import urllib.parse from django.conf import settings from django.contrib import admin -from django.contrib.auth.models import User +from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.cache import cache @@ -419,7 +419,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): blank=True ) user = models.ForeignKey( - to=User, + to=settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, blank=True, null=True @@ -560,7 +560,7 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat fk_field='assigned_object_id' ) created_by = models.ForeignKey( - to=User, + to=settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, blank=True, null=True diff --git a/netbox/extras/models/staging.py b/netbox/extras/models/staging.py index 3d1c149bc..850015be7 100644 --- a/netbox/extras/models/staging.py +++ b/netbox/extras/models/staging.py @@ -113,3 +113,6 @@ class StagedChange(ChangeLoggedModel): logger.info(f'Deleting {self.model._meta.verbose_name} {instance}') instance.delete() apply.alters_data = True + + def get_action_color(self): + return ChangeActionChoices.colors.get(self.action) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index b59481a36..4c48aa73e 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -1,6 +1,6 @@ import datetime -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.utils.timezone import make_aware @@ -15,6 +15,9 @@ from extras.scripts import BooleanVar, IntegerVar, Script, StringVar from utilities.testing import APITestCase, APIViewTestCases +User = get_user_model() + + class AppTest(APITestCase): def test_root(self): diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index e77afd20e..992643530 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime, timezone -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.test import TestCase @@ -18,6 +18,9 @@ from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, cr from virtualization.models import Cluster, ClusterGroup, ClusterType +User = get_user_model() + + class CustomFieldTestCase(TestCase, BaseFilterSetTests): queryset = CustomField.objects.all() filterset = CustomFieldFilterSet diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index ef8e87489..3dcb90875 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -1,7 +1,7 @@ import urllib.parse import uuid -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.urls import reverse @@ -11,6 +11,9 @@ from extras.models import * from utilities.testing import ViewTestCases, TestCase +User = get_user_model() + + class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = CustomField diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 064452667..f59850aa2 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -58,6 +58,7 @@ class AvailableASNSerializer(serializers.Serializer): Representation of an ASN which does not exist in the database. """ asn = serializers.IntegerField(read_only=True) + description = serializers.CharField(required=False) def to_representation(self, asn): rir = NestedRIRSerializer(self.context['range'].rir, context={ @@ -432,6 +433,7 @@ class AvailableIPSerializer(serializers.Serializer): family = serializers.IntegerField(read_only=True) address = serializers.CharField(read_only=True) vrf = NestedVRFSerializer(read_only=True) + description = serializers.CharField(required=False) def to_representation(self, instance): if self.context.get('vrf'): diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index f432e0e6b..c895a706b 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -3,7 +3,9 @@ from django.db import transaction from django.shortcuts import get_object_or_404 from django_pglocks import advisory_lock from drf_spectacular.utils import extend_schema +from netaddr import IPSet from rest_framework import status +from rest_framework.exceptions import ValidationError from rest_framework.response import Response from rest_framework.routers import APIRootView from rest_framework.views import APIView @@ -12,10 +14,12 @@ from circuits.models import Provider from dcim.models import Site from ipam import filtersets from ipam.models import * +from ipam.utils import get_next_available_prefix from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets.mixins import ObjectValidationMixin from netbox.config import get_config from netbox.constants import ADVISORY_LOCK_KEYS +from utilities.api import get_serializer_for_model from utilities.utils import count_related from . import serializers from ipam.models import L2VPN, L2VPNTermination @@ -207,237 +211,233 @@ def get_results_limit(request): return limit -class AvailableASNsView(ObjectValidationMixin, APIView): - queryset = ASN.objects.all() +class AvailableObjectsView(ObjectValidationMixin, APIView): + """ + Return a list of dicts representing child objects that have not yet been created for a parent object. + """ + read_serializer_class = None + write_serializer_class = None + advisory_lock_key = None + + def get_parent(self, request, pk): + """ + Return the parent object. + """ + raise NotImplemented() + + def get_available_objects(self, parent, limit=None): + """ + Return all available objects for the parent. + """ + raise NotImplemented() + + def get_extra_context(self, parent): + """ + Return any extra context data for the serializer. + """ + return {} + + def check_sufficient_available(self, requested_objects, available_objects): + """ + Check if there exist a sufficient number of available objects to satisfy the request. + """ + return len(requested_objects) <= len(available_objects) + + def prep_object_data(self, requested_objects, available_objects, parent): + """ + Prepare data by setting any programmatically determined object attributes (e.g. next available VLAN ID) + on the request data. + """ + return requested_objects - @extend_schema(methods=["get"], responses={200: serializers.AvailableASNSerializer(many=True)}) def get(self, request, pk): - asnrange = get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk) + parent = self.get_parent(request, pk) limit = get_results_limit(request) + available_objects = self.get_available_objects(parent, limit) - available_asns = asnrange.get_available_asns()[:limit] - - serializer = serializers.AvailableASNSerializer(available_asns, many=True, context={ + serializer = self.read_serializer_class(available_objects, many=True, context={ 'request': request, - 'range': asnrange, + **self.get_extra_context(parent), }) return Response(serializer.data) - @extend_schema(methods=["post"], responses={201: serializers.ASNSerializer(many=True)}) - @advisory_lock(ADVISORY_LOCK_KEYS['available-asns']) def post(self, request, pk): self.queryset = self.queryset.restrict(request.user, 'add') - asnrange = get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk) + parent = self.get_parent(request, pk) - # Normalize to a list of objects - requested_asns = request.data if isinstance(request.data, list) else [request.data] + # Normalize request data to a list of objects + requested_objects = request.data if isinstance(request.data, list) else [request.data] - # Determine if the requested number of IPs is available - available_asns = asnrange.get_available_asns() - if len(available_asns) < len(requested_asns): - return Response( - { - "detail": f"An insufficient number of ASNs are available within {asnrange} " - f"({len(requested_asns)} requested, {len(available_asns)} available)" - }, - status=status.HTTP_409_CONFLICT - ) - - # Assign ASNs from the list of available IPs and copy VRF assignment from the parent - for i, requested_asn in enumerate(requested_asns): - requested_asn.update({ - 'rir': asnrange.rir.pk, - 'range': asnrange.pk, - 'asn': available_asns[i], - }) - - # Initialize the serializer with a list or a single object depending on what was requested - context = {'request': request} - if isinstance(request.data, list): - serializer = serializers.ASNSerializer(data=requested_asns, many=True, context=context) - else: - serializer = serializers.ASNSerializer(data=requested_asns[0], context=context) - - # Create the new IP address(es) - if serializer.is_valid(): - try: - with transaction.atomic(): - created = serializer.save() - self._validate_objects(created) - except ObjectDoesNotExist: - raise PermissionDenied() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def get_serializer_class(self): - if self.request.method == "GET": - return serializers.AvailableASNSerializer - - return serializers.ASNSerializer - - -class AvailablePrefixesView(ObjectValidationMixin, APIView): - queryset = Prefix.objects.all() - - @extend_schema(methods=["get"], responses={200: serializers.AvailablePrefixSerializer(many=True)}) - def get(self, request, pk): - prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk) - available_prefixes = prefix.get_available_prefixes() - - serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={ + # Serialize and validate the request data + serializer = self.write_serializer_class(data=requested_objects, many=True, context={ 'request': request, - 'vrf': prefix.vrf, + **self.get_extra_context(parent), }) - - return Response(serializer.data) - - @extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)}) - @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes']) - def post(self, request, pk): - self.queryset = self.queryset.restrict(request.user, 'add') - prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk) - available_prefixes = prefix.get_available_prefixes() - - # Validate Requested Prefixes' length - serializer = serializers.PrefixLengthSerializer( - data=request.data if isinstance(request.data, list) else [request.data], - many=True, - context={ - 'request': request, - 'prefix': prefix, - } - ) if not serializer.is_valid(): return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST ) - requested_prefixes = serializer.validated_data - # Allocate prefixes to the requested objects based on availability within the parent - for i, requested_prefix in enumerate(requested_prefixes): + with advisory_lock(ADVISORY_LOCK_KEYS[self.advisory_lock_key]): + available_objects = self.get_available_objects(parent) - # Find the first available prefix equal to or larger than the requested size - for available_prefix in available_prefixes.iter_cidrs(): - if requested_prefix['prefix_length'] >= available_prefix.prefixlen: - allocated_prefix = '{}/{}'.format(available_prefix.network, requested_prefix['prefix_length']) - requested_prefix['prefix'] = allocated_prefix - requested_prefix['vrf'] = prefix.vrf.pk if prefix.vrf else None - break - else: + # Determine if the requested number of objects is available + if not self.check_sufficient_available(serializer.validated_data, available_objects): return Response( - { - "detail": "Insufficient space is available to accommodate the requested prefix size(s)" - }, + {"detail": f"Insufficient resources are available to satisfy the request"}, status=status.HTTP_409_CONFLICT ) - # Remove the allocated prefix from the list of available prefixes - available_prefixes.remove(allocated_prefix) + # Prepare object data for deserialization + requested_objects = self.prep_object_data(serializer.validated_data, available_objects, parent) - # Initialize the serializer with a list or a single object depending on what was requested - context = {'request': request} - if isinstance(request.data, list): - serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True, context=context) - else: - serializer = serializers.PrefixSerializer(data=requested_prefixes[0], context=context) + # Initialize the serializer with a list or a single object depending on what was requested + serializer_class = get_serializer_for_model(self.queryset.model) + context = {'request': request} + if isinstance(request.data, list): + serializer = serializer_class(data=requested_objects, many=True, context=context) + else: + serializer = serializer_class(data=requested_objects[0], context=context) - # Create the new Prefix(es) - if serializer.is_valid(): + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # Create the new IP address(es) try: with transaction.atomic(): created = serializer.save() self._validate_objects(created) except ObjectDoesNotExist: raise PermissionDenied() - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def get_serializer_class(self): - if self.request.method == "GET": - return serializers.AvailablePrefixSerializer - - return serializers.PrefixLengthSerializer + return Response(serializer.data, status=status.HTTP_201_CREATED) -class AvailableIPAddressesView(ObjectValidationMixin, APIView): - queryset = IPAddress.objects.all() +class AvailableASNsView(AvailableObjectsView): + queryset = ASN.objects.all() + read_serializer_class = serializers.AvailableASNSerializer + write_serializer_class = serializers.AvailableASNSerializer + advisory_lock_key = 'available-asns' def get_parent(self, request, pk): - raise NotImplemented() + return get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk) - @extend_schema(methods=["get"], responses={200: serializers.AvailableIPSerializer(many=True)}) + def get_available_objects(self, parent, limit=None): + return parent.get_available_asns()[:limit] + + def get_extra_context(self, parent): + return { + 'range': parent, + } + + def prep_object_data(self, requested_objects, available_objects, parent): + for i, request_data in enumerate(requested_objects): + request_data.update({ + 'rir': parent.rir.pk, + 'range': parent.pk, + 'asn': available_objects[i], + }) + + return requested_objects + + @extend_schema(methods=["get"], responses={200: serializers.AvailableASNSerializer(many=True)}) def get(self, request, pk): - parent = self.get_parent(request, pk) - limit = get_results_limit(request) + return super().get(request, pk) + @extend_schema(methods=["post"], responses={201: serializers.AvailableASNSerializer(many=True)}) + def post(self, request, pk): + return super().post(request, pk) + + +class AvailablePrefixesView(AvailableObjectsView): + queryset = Prefix.objects.all() + read_serializer_class = serializers.AvailablePrefixSerializer + write_serializer_class = serializers.PrefixLengthSerializer + advisory_lock_key = 'available-prefixes' + + def get_parent(self, request, pk): + return get_object_or_404(Prefix.objects.restrict(request.user), pk=pk) + + def get_available_objects(self, parent, limit=None): + return parent.get_available_prefixes().iter_cidrs() + + def check_sufficient_available(self, requested_objects, available_objects): + available_prefixes = IPSet(available_objects) + for requested_object in requested_objects: + if not get_next_available_prefix(available_prefixes, requested_object['prefix_length']): + return False + return True + + def get_extra_context(self, parent): + return { + 'prefix': parent, + 'vrf': parent.vrf, + } + + def prep_object_data(self, requested_objects, available_objects, parent): + available_prefixes = IPSet(available_objects) + for i, request_data in enumerate(requested_objects): + + # Find the first available prefix equal to or larger than the requested size + if allocated_prefix := get_next_available_prefix(available_prefixes, request_data['prefix_length']): + request_data.update({ + 'prefix': allocated_prefix, + 'vrf': parent.vrf.pk if parent.vrf else None, + }) + else: + raise ValidationError("Insufficient space is available to accommodate the requested prefix size(s)") + + return requested_objects + + @extend_schema(methods=["get"], responses={200: serializers.AvailablePrefixSerializer(many=True)}) + def get(self, request, pk): + return super().get(request, pk) + + @extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)}) + def post(self, request, pk): + return super().post(request, pk) + + +class AvailableIPAddressesView(AvailableObjectsView): + queryset = IPAddress.objects.all() + read_serializer_class = serializers.AvailableIPSerializer + write_serializer_class = serializers.AvailableIPSerializer + advisory_lock_key = 'available-ips' + + def get_available_objects(self, parent, limit=None): # Calculate available IPs within the parent ip_list = [] for index, ip in enumerate(parent.get_available_ips(), start=1): ip_list.append(ip) if index == limit: break - serializer = serializers.AvailableIPSerializer(ip_list, many=True, context={ - 'request': request, + return ip_list + + def get_extra_context(self, parent): + return { 'parent': parent, 'vrf': parent.vrf, - }) + } - return Response(serializer.data) + def prep_object_data(self, requested_objects, available_objects, parent): + available_ips = iter(available_objects) + for i, request_data in enumerate(requested_objects): + request_data.update({ + 'address': f'{next(available_ips)}/{parent.mask_length}', + 'vrf': parent.vrf.pk if parent.vrf else None, + }) + + return requested_objects + + @extend_schema(methods=["get"], responses={200: serializers.AvailableIPSerializer(many=True)}) + def get(self, request, pk): + return super().get(request, pk) @extend_schema(methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)}) - @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) def post(self, request, pk): - self.queryset = self.queryset.restrict(request.user, 'add') - parent = self.get_parent(request, pk) - - # Normalize to a list of objects - requested_ips = request.data if isinstance(request.data, list) else [request.data] - - # Determine if the requested number of IPs is available - available_ips = parent.get_available_ips() - if available_ips.size < len(requested_ips): - return Response( - { - "detail": f"An insufficient number of IP addresses are available within {parent} " - f"({len(requested_ips)} requested, {len(available_ips)} available)" - }, - status=status.HTTP_409_CONFLICT - ) - - # Assign addresses from the list of available IPs and copy VRF assignment from the parent - available_ips = iter(available_ips) - for requested_ip in requested_ips: - requested_ip['address'] = f'{next(available_ips)}/{parent.mask_length}' - requested_ip['vrf'] = parent.vrf.pk if parent.vrf else None - - # Initialize the serializer with a list or a single object depending on what was requested - context = {'request': request} - if isinstance(request.data, list): - serializer = serializers.IPAddressSerializer(data=requested_ips, many=True, context=context) - else: - serializer = serializers.IPAddressSerializer(data=requested_ips[0], context=context) - - # Create the new IP address(es) - if serializer.is_valid(): - try: - with transaction.atomic(): - created = serializer.save() - self._validate_objects(created) - except ObjectDoesNotExist: - raise PermissionDenied() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def get_serializer_class(self): - if self.request.method == "GET": - return serializers.AvailableIPSerializer - - return serializers.IPAddressSerializer + return super().post(request, pk) class PrefixAvailableIPAddressesView(AvailableIPAddressesView): @@ -452,77 +452,36 @@ class IPRangeAvailableIPAddressesView(AvailableIPAddressesView): return get_object_or_404(IPRange.objects.restrict(request.user), pk=pk) -class AvailableVLANsView(ObjectValidationMixin, APIView): +class AvailableVLANsView(AvailableObjectsView): queryset = VLAN.objects.all() + read_serializer_class = serializers.AvailableVLANSerializer + write_serializer_class = serializers.CreateAvailableVLANSerializer + advisory_lock_key = 'available-vlans' + + def get_parent(self, request, pk): + return get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk) + + def get_available_objects(self, parent, limit=None): + return parent.get_available_vids()[:limit] + + def get_extra_context(self, parent): + return { + 'group': parent, + } + + def prep_object_data(self, requested_objects, available_objects, parent): + for i, request_data in enumerate(requested_objects): + request_data.update({ + 'vid': available_objects.pop(0), + 'group': parent.pk, + }) + + return requested_objects @extend_schema(methods=["get"], responses={200: serializers.AvailableVLANSerializer(many=True)}) def get(self, request, pk): - vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk) - limit = get_results_limit(request) - - available_vlans = vlangroup.get_available_vids()[:limit] - serializer = serializers.AvailableVLANSerializer(available_vlans, many=True, context={ - 'request': request, - 'group': vlangroup, - }) - - return Response(serializer.data) + return super().get(request, pk) @extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)}) - @advisory_lock(ADVISORY_LOCK_KEYS['available-vlans']) def post(self, request, pk): - self.queryset = self.queryset.restrict(request.user, 'add') - vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk) - available_vlans = vlangroup.get_available_vids() - many = isinstance(request.data, list) - - # Validate requested VLANs - serializer = serializers.CreateAvailableVLANSerializer( - data=request.data if many else [request.data], - many=True, - context={ - 'request': request, - 'group': vlangroup, - } - ) - if not serializer.is_valid(): - return Response( - serializer.errors, - status=status.HTTP_400_BAD_REQUEST - ) - - requested_vlans = serializer.validated_data - - for i, requested_vlan in enumerate(requested_vlans): - try: - requested_vlan['vid'] = available_vlans.pop(0) - requested_vlan['group'] = vlangroup.pk - except IndexError: - return Response({ - "detail": "The requested number of VLANs is not available" - }, status=status.HTTP_409_CONFLICT) - - # Initialize the serializer with a list or a single object depending on what was requested - context = {'request': request} - if many: - serializer = serializers.VLANSerializer(data=requested_vlans, many=True, context=context) - else: - serializer = serializers.VLANSerializer(data=requested_vlans[0], context=context) - - # Create the new VLAN(s) - if serializer.is_valid(): - try: - with transaction.atomic(): - created = serializer.save() - self._validate_objects(created) - except ObjectDoesNotExist: - raise PermissionDenied() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def get_serializer_class(self): - if self.request.method == "GET": - return serializers.AvailableVLANSerializer - - return serializers.VLANSerializer + return super().post(request, pk) diff --git a/netbox/ipam/utils.py b/netbox/ipam/utils.py index 93a40e5a0..f54c7d41d 100644 --- a/netbox/ipam/utils.py +++ b/netbox/ipam/utils.py @@ -1,7 +1,15 @@ import netaddr from .constants import * -from .models import ASN, Prefix, VLAN +from .models import Prefix, VLAN + +__all__ = ( + 'add_available_ipaddresses', + 'add_available_vlans', + 'add_requested_prefixes', + 'get_next_available_prefix', + 'rebuild_prefixes', +) def add_requested_prefixes(parent, prefix_list, show_available=True, show_assigned=True): @@ -184,3 +192,15 @@ def rebuild_prefixes(vrf): # Final flush of any remaining Prefixes Prefix.objects.bulk_update(update_queue, ['_depth', '_children']) + + +def get_next_available_prefix(ipset, prefix_size): + """ + Given a prefix length, allocate the next available prefix from an IPSet. + """ + for available_prefix in ipset.iter_cidrs(): + if prefix_size >= available_prefix.prefixlen: + allocated_prefix = f"{available_prefix.network}/{prefix_size}" + ipset.remove(allocated_prefix) + return allocated_prefix + return None diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index d139546d9..e009f62f1 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -301,12 +301,14 @@ CUSTOMIZATION_MENU = Menu( MenuItem( link='extras:report_list', link_text=_('Reports'), - permissions=['extras.view_report'] + permissions=['extras.view_report'], + buttons=get_model_buttons('extras', "reportmodule", actions=['add']) ), MenuItem( link='extras:script_list', link_text=_('Scripts'), - permissions=['extras.view_script'] + permissions=['extras.view_script'], + buttons=get_model_buttons('extras', "scriptmodule", actions=['add']) ), ), ), diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e77ac43c0..31363144f 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW # Environment setup # -VERSION = '3.5.4-dev' +VERSION = '3.5.5-dev' # Hostname HOSTNAME = platform.node() diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 839d85996..20eab822d 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -140,10 +140,14 @@ class BaseTable(tables.Table): if request.user.is_authenticated: table_name = self.__class__.__name__ if self.prefixed_order_by_field in request.GET: - # If an ordering has been specified as a query parameter, save it as the - # user's preferred ordering for this table. - ordering = request.GET.getlist(self.prefixed_order_by_field) - request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True) + if request.GET[self.prefixed_order_by_field]: + # If an ordering has been specified as a query parameter, save it as the + # user's preferred ordering for this table. + ordering = request.GET.getlist(self.prefixed_order_by_field) + request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True) + else: + # If the ordering has been set to none (empty), clear any existing preference. + request.user.config.clear(f'tables.{table_name}.ordering', commit=True) elif ordering := request.user.config.get(f'tables.{table_name}.ordering'): # If no ordering has been specified, set the preferred ordering (if any). self.order_by = ordering diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 4e46996b5..1804087d1 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -1,7 +1,8 @@ import datetime from django.conf import settings -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from django.test import Client from django.test.utils import override_settings @@ -16,6 +17,9 @@ from utilities.testing import TestCase from utilities.testing.api import APITestCase +User = get_user_model() + + class TokenAuthenticationTestCase(APITestCase): @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) diff --git a/netbox/templates/ipam/ipaddress/ip_addresses.html b/netbox/templates/ipam/ipaddress/ip_addresses.html index 7034329aa..b82ec2375 100644 --- a/netbox/templates/ipam/ipaddress/ip_addresses.html +++ b/netbox/templates/ipam/ipaddress/ip_addresses.html @@ -2,18 +2,18 @@ {% load helpers %} {% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %} -
-{% endblock content %} + {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %} + +{% endblock %} {% block modals %} - {{ block.super }} - {% table_config_form table %} + {{ block.super }} + {% table_config_form table %} {% endblock modals %} diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index 3510184ae..5e15fa41a 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -1,4 +1,5 @@ -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from drf_spectacular.types import OpenApiTypes @@ -28,7 +29,7 @@ class NestedUserSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:user-detail') class Meta: - model = User + model = get_user_model() fields = ['id', 'url', 'display', 'username'] @extend_schema_field(OpenApiTypes.STR) diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 1b975791f..1f4bf4ea0 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -1,5 +1,6 @@ from django.conf import settings -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from drf_spectacular.types import OpenApiTypes @@ -30,7 +31,7 @@ class UserSerializer(ValidatedModelSerializer): ) class Meta: - model = User + model = get_user_model() fields = ( 'id', 'url', 'display', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined', 'groups', @@ -124,7 +125,7 @@ class ObjectPermissionSerializer(ValidatedModelSerializer): many=True ) users = SerializedPKRelatedField( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), serializer=NestedUserSerializer, required=False, many=True diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index 04b3ae336..4a8e1b154 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -1,5 +1,6 @@ from django.contrib.auth import authenticate -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.db.models import Count from drf_spectacular.utils import extend_schema from drf_spectacular.types import OpenApiTypes @@ -32,7 +33,7 @@ class UsersRootView(APIRootView): # class UserViewSet(NetBoxModelViewSet): - queryset = RestrictedQuerySet(model=User).prefetch_related('groups').order_by('username') + queryset = RestrictedQuerySet(model=get_user_model()).prefetch_related('groups').order_by('username') serializer_class = serializers.UserSerializer filterset_class = filtersets.UserFilterSet diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 4ae9df89a..44ad98cc2 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -1,5 +1,6 @@ import django_filters -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.db.models import Q from django.utils.translation import gettext as _ @@ -47,7 +48,7 @@ class UserFilterSet(BaseFilterSet): ) class Meta: - model = User + model = get_user_model() fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active'] def search(self, queryset, name, value): @@ -68,12 +69,12 @@ class TokenFilterSet(BaseFilterSet): ) user_id = django_filters.ModelMultipleChoiceFilter( field_name='user', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), label=_('User'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), to_field_name='username', label=_('User (name)'), ) @@ -116,12 +117,12 @@ class ObjectPermissionFilterSet(BaseFilterSet): ) user_id = django_filters.ModelMultipleChoiceFilter( field_name='users', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), label=_('User'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='users__username', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), to_field_name='username', label=_('User (name)'), ) diff --git a/netbox/users/graphql/schema.py b/netbox/users/graphql/schema.py index 3b04d8418..f033a535a 100644 --- a/netbox/users/graphql/schema.py +++ b/netbox/users/graphql/schema.py @@ -1,6 +1,7 @@ import graphene -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from netbox.graphql.fields import ObjectField, ObjectListField from .types import * from utilities.graphql_optimizer import gql_query_optimizer @@ -17,4 +18,4 @@ class UsersQuery(graphene.ObjectType): user_list = ObjectListField(UserType) def resolve_user_list(root, info, **kwargs): - return gql_query_optimizer(User.objects.all(), info) + return gql_query_optimizer(get_user_model().objects.all(), info) diff --git a/netbox/users/graphql/types.py b/netbox/users/graphql/types.py index d948686c6..4254f1791 100644 --- a/netbox/users/graphql/types.py +++ b/netbox/users/graphql/types.py @@ -1,4 +1,5 @@ -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from graphene_django import DjangoObjectType from users import filtersets @@ -25,7 +26,7 @@ class GroupType(DjangoObjectType): class UserType(DjangoObjectType): class Meta: - model = User + model = get_user_model() fields = ( 'id', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined', 'groups', @@ -34,4 +35,4 @@ class UserType(DjangoObjectType): @classmethod def get_queryset(cls, queryset, info): - return RestrictedQuerySet(model=User).restrict(info.context.user, 'view') + return RestrictedQuerySet(model=get_user_model()).restrict(info.context.user, 'view') diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index 281f656d2..2de243775 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -1,4 +1,5 @@ -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from django.urls import reverse @@ -7,6 +8,9 @@ from utilities.testing import APIViewTestCases, APITestCase from utilities.utils import deepmerge +User = get_user_model() + + class AppTest(APITestCase): def test_root(self): diff --git a/netbox/users/tests/test_filtersets.py b/netbox/users/tests/test_filtersets.py index 33ed7e7ba..d632687ef 100644 --- a/netbox/users/tests/test_filtersets.py +++ b/netbox/users/tests/test_filtersets.py @@ -1,6 +1,7 @@ import datetime -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from django.test import TestCase from django.utils.timezone import make_aware @@ -10,6 +11,9 @@ from users.models import ObjectPermission, Token from utilities.testing import BaseFilterSetTests +User = get_user_model() + + class UserTestCase(TestCase, BaseFilterSetTests): queryset = User.objects.all() filterset = filtersets.UserFilterSet diff --git a/netbox/users/tests/test_models.py b/netbox/users/tests/test_models.py index 7a2337f33..791ea8fb4 100644 --- a/netbox/users/tests/test_models.py +++ b/netbox/users/tests/test_models.py @@ -1,7 +1,10 @@ -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.test import TestCase +User = get_user_model() + + class UserConfigTest(TestCase): @classmethod diff --git a/netbox/users/tests/test_preferences.py b/netbox/users/tests/test_preferences.py index f1e947d67..203a67bdd 100644 --- a/netbox/users/tests/test_preferences.py +++ b/netbox/users/tests/test_preferences.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.test import override_settings from django.test.client import RequestFactory from django.urls import reverse @@ -16,6 +16,9 @@ DEFAULT_USER_PREFERENCES = { } +User = get_user_model() + + class UserPreferencesTest(TestCase): user_permissions = ['dcim.view_site'] diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 7f24c86b8..8cfe1cdd7 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -2,7 +2,7 @@ import inspect import json from django.conf import settings -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.test import override_settings @@ -26,6 +26,9 @@ __all__ = ( ) +User = get_user_model() + + # # REST/GraphQL API Tests # diff --git a/netbox/utilities/testing/base.py b/netbox/utilities/testing/base.py index 04ceca1e2..76a9fac06 100644 --- a/netbox/utilities/testing/base.py +++ b/netbox/utilities/testing/base.py @@ -1,6 +1,6 @@ import json -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.exceptions import FieldDoesNotExist @@ -27,7 +27,7 @@ class TestCase(_TestCase): def setUp(self): # Create the test user and assign permissions - self.user = User.objects.create_user(username='testuser') + self.user = get_user_model().objects.create_user(username='testuser') self.add_permissions(*self.user_permissions) # Initialize the test client diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index 52ccd002d..87fc3319c 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -2,7 +2,8 @@ import logging import re from contextlib import contextmanager -from django.contrib.auth.models import Permission, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission from django.utils.text import slugify from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site @@ -63,7 +64,7 @@ def create_test_user(username='testuser', permissions=None): """ Create a User with the given permissions. """ - user = User.objects.create_user(username=username) + user = get_user_model().objects.create_user(username=username) if permissions is None: permissions = () for perm_name in permissions: diff --git a/requirements.txt b/requirements.txt index a39803113..1d6dd2405 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,19 @@ bleach==6.0.0 -boto3==1.26.145 +boto3==1.26.156 Django==4.2.2 -django-cors-headers==4.0.0 +django-cors-headers==4.1.0 django-debug-toolbar==4.1.0 django-filter==23.2 django-graphiql-debug-toolbar==0.2.0 django-mptt==0.14 django-pglocks==1.0.4 django-prometheus==2.3.1 -django-redis==5.2.0 -django-rich==1.5.0 +django-redis==5.3.0 +django-rich==1.6.0 django-rq==2.8.1 django-tables2==2.5.3 django-taggit==4.0.0 -django-timezone-field==5.0 +django-timezone-field==5.1 djangorestframework==3.14.0 drf-spectacular==0.26.2 drf-spectacular-sidecar==2023.6.1 @@ -23,7 +23,7 @@ graphene-django==3.0.0 gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.3.7 -mkdocs-material==9.1.15 +mkdocs-material==9.1.16 mkdocstrings[python-legacy]==0.22.0 netaddr==0.8.0 Pillow==9.5.0 @@ -31,9 +31,9 @@ psycopg==3.1.9 psycopg-binary==3.1.9 psycopg-pool==3.1.7 PyYAML==6.0 -sentry-sdk==1.25.0 +sentry-sdk==1.25.1 social-auth-app-django==5.2.0 social-auth-core[openidconnect]==4.4.2 svgwrite==1.4.3 -tablib==3.4.0 +tablib==3.5.0 tzdata==2023.3