Merge branch 'feature' into 12237-django-4.2-2

This commit is contained in:
Jeremy Stretch 2023-06-22 08:38:25 -04:00 committed by GitHub
commit 2091fef53b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 400 additions and 350 deletions

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.5.3 placeholder: v3.5.4
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.5.3 placeholder: v3.5.4
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -1,21 +1,31 @@
# NetBox v3.5 # NetBox v3.5
## v3.5.4 (FUTURE) ## v3.5.5 (FUTURE)
---
## v3.5.4 (2023-06-20)
### Enhancements ### 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 * [#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 * [#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 ### 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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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
--- ---

View File

@ -1,5 +1,5 @@
from django import forms 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.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -105,7 +105,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
widget=DateTimePicker() widget=DateTimePicker()
) )
user = DynamicModelMultipleChoiceField( user = DynamicModelMultipleChoiceField(
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
required=False, required=False,
label=_('User'), label=_('User'),
widget=APISelectMultiple( widget=APISelectMultiple(

View File

@ -5,7 +5,7 @@ import sys
from django import get_version from django import get_version
from django.apps import apps from django.apps import apps
from django.conf import settings 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.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
@ -60,7 +60,7 @@ class Command(BaseCommand):
# Additional objects to include # Additional objects to include
namespace['ContentType'] = ContentType namespace['ContentType'] = ContentType
namespace['User'] = User namespace['User'] = get_user_model()
# Load convenience commands # Load convenience commands
namespace.update({ namespace.update({

View File

@ -1,7 +1,7 @@
import uuid import uuid
import django_rq 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.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
@ -69,7 +69,7 @@ class Job(models.Model):
blank=True blank=True
) )
user = models.ForeignKey( user = models.ForeignKey(
to=User, to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name='+', related_name='+',
blank=True, blank=True,

View File

@ -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_DEFAULT = 42
RACK_U_HEIGHT_MAX = 100
RACK_ELEVATION_BORDER_WIDTH = 2 RACK_ELEVATION_BORDER_WIDTH = 2
RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30 RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30

View File

@ -1,5 +1,5 @@
import django_filters 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 django.utils.translation import gettext as _
from extras.filtersets import LocalConfigContextFilterSet from extras.filtersets import LocalConfigContextFilterSet
@ -395,12 +395,12 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
label=_('Location (slug)'), label=_('Location (slug)'),
) )
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
label=_('User (ID)'), label=_('User (ID)'),
) )
user = django_filters.ModelMultipleChoiceFilter( user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username', field_name='user__username',
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
to_field_name='username', to_field_name='username',
label=_('User (name)'), label=_('User (name)'),
) )

View File

@ -1,6 +1,6 @@
from django import forms from django import forms
from django.conf import settings 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 django.utils.translation import gettext as _
from timezone_field import TimeZoneFormField from timezone_field import TimeZoneFormField
@ -322,7 +322,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
class RackReservationBulkEditForm(NetBoxModelBulkEditForm): class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
user = forms.ModelChoiceField( user = forms.ModelChoiceField(
queryset=User.objects.order_by( queryset=get_user_model().objects.order_by(
'username' 'username'
), ),
required=False required=False

View File

@ -1,5 +1,5 @@
from django import forms 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 django.utils.translation import gettext as _
from dcim.choices import * from dcim.choices import *
@ -376,7 +376,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
label=_('Rack') label=_('Rack')
) )
user_id = DynamicModelMultipleChoiceField( user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
required=False, required=False,
label=_('User'), label=_('User'),
widget=APISelectMultiple( widget=APISelectMultiple(

View File

@ -1,5 +1,5 @@
from django import forms 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.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from timezone_field import TimeZoneFormField 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.") help_text=_("Comma-separated list of numeric unit IDs. A range may be specified using a hyphen.")
) )
user = forms.ModelChoiceField( user = forms.ModelChoiceField(
queryset=User.objects.order_by( queryset=get_user_model().objects.order_by(
'username' 'username'
) )
) )

View File

@ -18,6 +18,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='device', model_name='device',
name='position', 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)]),
), ),
] ]

View File

@ -555,7 +555,7 @@ class Device(PrimaryModel, ConfigContextModel):
decimal_places=1, decimal_places=1,
blank=True, blank=True,
null=True, null=True,
validators=[MinValueValidator(1), MaxValueValidator(99.5)], validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX + 0.5)],
verbose_name='Position (U)', verbose_name='Position (U)',
help_text=_('The lowest-numbered unit occupied by the device') help_text=_('The lowest-numbered unit occupied by the device')
) )

View File

@ -1,7 +1,7 @@
import decimal import decimal
from functools import cached_property 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.contenttypes.fields import GenericRelation
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
@ -126,7 +126,7 @@ class Rack(PrimaryModel, WeightMixin):
u_height = models.PositiveSmallIntegerField( u_height = models.PositiveSmallIntegerField(
default=RACK_U_HEIGHT_DEFAULT, default=RACK_U_HEIGHT_DEFAULT,
verbose_name='Height (U)', verbose_name='Height (U)',
validators=[MinValueValidator(1), MaxValueValidator(100)], validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)],
help_text=_('Height in rack units') help_text=_('Height in rack units')
) )
desc_units = models.BooleanField( desc_units = models.BooleanField(
@ -505,7 +505,7 @@ class RackReservation(PrimaryModel):
null=True null=True
) )
user = models.ForeignKey( user = models.ForeignKey(
to=User, to=settings.AUTH_USER_MODEL,
on_delete=models.PROTECT on_delete=models.PROTECT
) )
description = models.CharField( description = models.CharField(

View File

@ -27,6 +27,7 @@ def handle_location_site_change(instance, created, **kwargs):
Rack.objects.filter(location__in=locations).update(site=instance.site) Rack.objects.filter(location__in=locations).update(site=instance.site)
Device.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) 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) @receiver(post_save, sender=Rack)

View File

@ -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 import override_settings
from django.urls import reverse from django.urls import reverse
from rest_framework import status from rest_framework import status
@ -14,6 +14,9 @@ from wireless.choices import WirelessChannelChoices
from wireless.models import WirelessLAN from wireless.models import WirelessLAN
User = get_user_model()
class AppTest(APITestCase): class AppTest(APITestCase):
def test_root(self): def test_root(self):

View File

@ -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 django.test import TestCase
from dcim.choices import * from dcim.choices import *
@ -12,6 +12,9 @@ from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
User = get_user_model()
class DeviceComponentFilterSetTests: class DeviceComponentFilterSetTests:
def test_device_type(self): def test_device_type(self):

View File

@ -6,7 +6,7 @@ except ImportError:
from backports.zoneinfo import ZoneInfo from backports.zoneinfo import ZoneInfo
import yaml 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.contrib.contenttypes.models import ContentType
from django.test import override_settings from django.test import override_settings
from django.urls import reverse 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 from wireless.models import WirelessLAN
User = get_user_model()
class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Region model = Region

View File

@ -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.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
@ -256,7 +256,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
assigned_object = serializers.SerializerMethodField(read_only=True) assigned_object = serializers.SerializerMethodField(read_only=True)
created_by = serializers.PrimaryKeyRelatedField( created_by = serializers.PrimaryKeyRelatedField(
allow_null=True, allow_null=True,
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
required=False, required=False,
default=serializers.CurrentUserDefault() default=serializers.CurrentUserDefault()
) )

View File

@ -210,7 +210,7 @@ class ChangeActionChoices(ChoiceSet):
ACTION_DELETE = 'delete' ACTION_DELETE = 'delete'
CHOICES = ( CHOICES = (
(ACTION_CREATE, 'Create'), (ACTION_CREATE, 'Create', 'green'),
(ACTION_UPDATE, 'Update'), (ACTION_UPDATE, 'Update', 'blue'),
(ACTION_DELETE, 'Delete'), (ACTION_DELETE, 'Delete', 'red'),
) )

View File

@ -1,5 +1,5 @@
import django_filters 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.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -159,12 +159,12 @@ class SavedFilterFilterSet(BaseFilterSet):
) )
content_types = ContentTypeFilter() content_types = ContentTypeFilter()
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
label=_('User (ID)'), label=_('User (ID)'),
) )
user = django_filters.ModelMultipleChoiceFilter( user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username', field_name='user__username',
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
to_field_name='username', to_field_name='username',
label=_('User (name)'), label=_('User (name)'),
) )
@ -223,12 +223,12 @@ class JournalEntryFilterSet(NetBoxModelFilterSet):
queryset=ContentType.objects.all() queryset=ContentType.objects.all()
) )
created_by_id = django_filters.ModelMultipleChoiceFilter( created_by_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
label=_('User (ID)'), label=_('User (ID)'),
) )
created_by = django_filters.ModelMultipleChoiceFilter( created_by = django_filters.ModelMultipleChoiceFilter(
field_name='created_by__username', field_name='created_by__username',
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
to_field_name='username', to_field_name='username',
label=_('User (name)'), label=_('User (name)'),
) )
@ -510,12 +510,12 @@ class ObjectChangeFilterSet(BaseFilterSet):
queryset=ContentType.objects.all() queryset=ContentType.objects.all()
) )
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
label=_('User (ID)'), label=_('User (ID)'),
) )
user = django_filters.ModelMultipleChoiceFilter( user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username', field_name='user__username',
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
to_field_name='username', to_field_name='username',
label=_('User name'), label=_('User name'),
) )

View File

@ -1,5 +1,5 @@
from django import forms 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.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -385,7 +385,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
widget=DateTimePicker() widget=DateTimePicker()
) )
created_by_id = DynamicModelMultipleChoiceField( created_by_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
required=False, required=False,
label=_('User'), label=_('User'),
widget=APISelectMultiple( widget=APISelectMultiple(
@ -429,7 +429,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
required=False required=False
) )
user_id = DynamicModelMultipleChoiceField( user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
required=False, required=False,
label=_('User'), label=_('User'),
widget=APISelectMultiple( widget=APISelectMultiple(

View File

@ -4,7 +4,7 @@ import sys
import traceback import traceback
import uuid 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.core.management.base import BaseCommand, CommandError
from django.db import transaction from django.db import transaction
@ -63,6 +63,8 @@ class Command(BaseCommand):
logger.info(f"Script completed in {job.duration}") logger.info(f"Script completed in {job.duration}")
User = get_user_model()
# Params # Params
script = options['script'] script = options['script']
loglevel = options['loglevel'] loglevel = options['loglevel']

View File

@ -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.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import models from django.db import models
@ -24,7 +24,7 @@ class ObjectChange(models.Model):
db_index=True db_index=True
) )
user = models.ForeignKey( user = models.ForeignKey(
to=User, to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name='changes', related_name='changes',
blank=True, blank=True,

View File

@ -3,7 +3,7 @@ import urllib.parse
from django.conf import settings from django.conf import settings
from django.contrib import admin 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.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache from django.core.cache import cache
@ -419,7 +419,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
blank=True blank=True
) )
user = models.ForeignKey( user = models.ForeignKey(
to=User, to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
blank=True, blank=True,
null=True null=True
@ -560,7 +560,7 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
fk_field='assigned_object_id' fk_field='assigned_object_id'
) )
created_by = models.ForeignKey( created_by = models.ForeignKey(
to=User, to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
blank=True, blank=True,
null=True null=True

View File

@ -113,3 +113,6 @@ class StagedChange(ChangeLoggedModel):
logger.info(f'Deleting {self.model._meta.verbose_name} {instance}') logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
instance.delete() instance.delete()
apply.alters_data = True apply.alters_data = True
def get_action_color(self):
return ChangeActionChoices.colors.get(self.action)

View File

@ -1,6 +1,6 @@
import datetime 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.contrib.contenttypes.models import ContentType
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import make_aware 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 from utilities.testing import APITestCase, APIViewTestCases
User = get_user_model()
class AppTest(APITestCase): class AppTest(APITestCase):
def test_root(self): def test_root(self):

View File

@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime, timezone 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.contrib.contenttypes.models import ContentType
from django.test import TestCase from django.test import TestCase
@ -18,6 +18,9 @@ from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, cr
from virtualization.models import Cluster, ClusterGroup, ClusterType from virtualization.models import Cluster, ClusterGroup, ClusterType
User = get_user_model()
class CustomFieldTestCase(TestCase, BaseFilterSetTests): class CustomFieldTestCase(TestCase, BaseFilterSetTests):
queryset = CustomField.objects.all() queryset = CustomField.objects.all()
filterset = CustomFieldFilterSet filterset = CustomFieldFilterSet

View File

@ -1,7 +1,7 @@
import urllib.parse import urllib.parse
import uuid 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.contrib.contenttypes.models import ContentType
from django.urls import reverse from django.urls import reverse
@ -11,6 +11,9 @@ from extras.models import *
from utilities.testing import ViewTestCases, TestCase from utilities.testing import ViewTestCases, TestCase
User = get_user_model()
class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CustomField model = CustomField

View File

@ -58,6 +58,7 @@ class AvailableASNSerializer(serializers.Serializer):
Representation of an ASN which does not exist in the database. Representation of an ASN which does not exist in the database.
""" """
asn = serializers.IntegerField(read_only=True) asn = serializers.IntegerField(read_only=True)
description = serializers.CharField(required=False)
def to_representation(self, asn): def to_representation(self, asn):
rir = NestedRIRSerializer(self.context['range'].rir, context={ rir = NestedRIRSerializer(self.context['range'].rir, context={
@ -432,6 +433,7 @@ class AvailableIPSerializer(serializers.Serializer):
family = serializers.IntegerField(read_only=True) family = serializers.IntegerField(read_only=True)
address = serializers.CharField(read_only=True) address = serializers.CharField(read_only=True)
vrf = NestedVRFSerializer(read_only=True) vrf = NestedVRFSerializer(read_only=True)
description = serializers.CharField(required=False)
def to_representation(self, instance): def to_representation(self, instance):
if self.context.get('vrf'): if self.context.get('vrf'):

View File

@ -3,7 +3,9 @@ from django.db import transaction
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django_pglocks import advisory_lock from django_pglocks import advisory_lock
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from netaddr import IPSet
from rest_framework import status from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from rest_framework.views import APIView from rest_framework.views import APIView
@ -12,10 +14,12 @@ from circuits.models import Provider
from dcim.models import Site from dcim.models import Site
from ipam import filtersets from ipam import filtersets
from ipam.models import * from ipam.models import *
from ipam.utils import get_next_available_prefix
from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets import NetBoxModelViewSet
from netbox.api.viewsets.mixins import ObjectValidationMixin from netbox.api.viewsets.mixins import ObjectValidationMixin
from netbox.config import get_config from netbox.config import get_config
from netbox.constants import ADVISORY_LOCK_KEYS from netbox.constants import ADVISORY_LOCK_KEYS
from utilities.api import get_serializer_for_model
from utilities.utils import count_related from utilities.utils import count_related
from . import serializers from . import serializers
from ipam.models import L2VPN, L2VPNTermination from ipam.models import L2VPN, L2VPNTermination
@ -207,237 +211,233 @@ def get_results_limit(request):
return limit return limit
class AvailableASNsView(ObjectValidationMixin, APIView): class AvailableObjectsView(ObjectValidationMixin, APIView):
queryset = ASN.objects.all() """
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): 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) limit = get_results_limit(request)
available_objects = self.get_available_objects(parent, limit)
available_asns = asnrange.get_available_asns()[:limit] serializer = self.read_serializer_class(available_objects, many=True, context={
serializer = serializers.AvailableASNSerializer(available_asns, many=True, context={
'request': request, 'request': request,
'range': asnrange, **self.get_extra_context(parent),
}) })
return Response(serializer.data) 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): def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add') 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 # Normalize request data to a list of objects
requested_asns = request.data if isinstance(request.data, list) else [request.data] requested_objects = request.data if isinstance(request.data, list) else [request.data]
# Determine if the requested number of IPs is available # Serialize and validate the request data
available_asns = asnrange.get_available_asns() serializer = self.write_serializer_class(data=requested_objects, many=True, context={
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={
'request': request, '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(): if not serializer.is_valid():
return Response( return Response(
serializer.errors, serializer.errors,
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
requested_prefixes = serializer.validated_data with advisory_lock(ADVISORY_LOCK_KEYS[self.advisory_lock_key]):
# Allocate prefixes to the requested objects based on availability within the parent available_objects = self.get_available_objects(parent)
for i, requested_prefix in enumerate(requested_prefixes):
# Find the first available prefix equal to or larger than the requested size # Determine if the requested number of objects is available
for available_prefix in available_prefixes.iter_cidrs(): if not self.check_sufficient_available(serializer.validated_data, available_objects):
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:
return Response( return Response(
{ {"detail": f"Insufficient resources are available to satisfy the request"},
"detail": "Insufficient space is available to accommodate the requested prefix size(s)"
},
status=status.HTTP_409_CONFLICT status=status.HTTP_409_CONFLICT
) )
# Remove the allocated prefix from the list of available prefixes # Prepare object data for deserialization
available_prefixes.remove(allocated_prefix) 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 # Initialize the serializer with a list or a single object depending on what was requested
context = {'request': request} serializer_class = get_serializer_for_model(self.queryset.model)
if isinstance(request.data, list): context = {'request': request}
serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True, context=context) if isinstance(request.data, list):
else: serializer = serializer_class(data=requested_objects, many=True, context=context)
serializer = serializers.PrefixSerializer(data=requested_prefixes[0], context=context) else:
serializer = serializer_class(data=requested_objects[0], context=context)
# Create the new Prefix(es) if not serializer.is_valid():
if serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Create the new IP address(es)
try: try:
with transaction.atomic(): with transaction.atomic():
created = serializer.save() created = serializer.save()
self._validate_objects(created) self._validate_objects(created)
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise PermissionDenied() raise PermissionDenied()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.data, status=status.HTTP_201_CREATED)
def get_serializer_class(self):
if self.request.method == "GET":
return serializers.AvailablePrefixSerializer
return serializers.PrefixLengthSerializer
class AvailableIPAddressesView(ObjectValidationMixin, APIView): class AvailableASNsView(AvailableObjectsView):
queryset = IPAddress.objects.all() 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): 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): def get(self, request, pk):
parent = self.get_parent(request, pk) return super().get(request, pk)
limit = get_results_limit(request)
@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 # Calculate available IPs within the parent
ip_list = [] ip_list = []
for index, ip in enumerate(parent.get_available_ips(), start=1): for index, ip in enumerate(parent.get_available_ips(), start=1):
ip_list.append(ip) ip_list.append(ip)
if index == limit: if index == limit:
break break
serializer = serializers.AvailableIPSerializer(ip_list, many=True, context={ return ip_list
'request': request,
def get_extra_context(self, parent):
return {
'parent': parent, 'parent': parent,
'vrf': parent.vrf, '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)}) @extend_schema(methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)})
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def post(self, request, pk): def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add') return super().post(request, pk)
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
class PrefixAvailableIPAddressesView(AvailableIPAddressesView): class PrefixAvailableIPAddressesView(AvailableIPAddressesView):
@ -452,77 +452,36 @@ class IPRangeAvailableIPAddressesView(AvailableIPAddressesView):
return get_object_or_404(IPRange.objects.restrict(request.user), pk=pk) return get_object_or_404(IPRange.objects.restrict(request.user), pk=pk)
class AvailableVLANsView(ObjectValidationMixin, APIView): class AvailableVLANsView(AvailableObjectsView):
queryset = VLAN.objects.all() 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)}) @extend_schema(methods=["get"], responses={200: serializers.AvailableVLANSerializer(many=True)})
def get(self, request, pk): def get(self, request, pk):
vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk) return super().get(request, 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)
@extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)}) @extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)})
@advisory_lock(ADVISORY_LOCK_KEYS['available-vlans'])
def post(self, request, pk): def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add') return super().post(request, pk)
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

View File

@ -1,7 +1,15 @@
import netaddr import netaddr
from .constants import * 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): 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 # Final flush of any remaining Prefixes
Prefix.objects.bulk_update(update_queue, ['_depth', '_children']) 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

View File

@ -301,12 +301,14 @@ CUSTOMIZATION_MENU = Menu(
MenuItem( MenuItem(
link='extras:report_list', link='extras:report_list',
link_text=_('Reports'), link_text=_('Reports'),
permissions=['extras.view_report'] permissions=['extras.view_report'],
buttons=get_model_buttons('extras', "reportmodule", actions=['add'])
), ),
MenuItem( MenuItem(
link='extras:script_list', link='extras:script_list',
link_text=_('Scripts'), link_text=_('Scripts'),
permissions=['extras.view_script'] permissions=['extras.view_script'],
buttons=get_model_buttons('extras', "scriptmodule", actions=['add'])
), ),
), ),
), ),

View File

@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup # Environment setup
# #
VERSION = '3.5.4-dev' VERSION = '3.5.5-dev'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()

View File

@ -140,10 +140,14 @@ class BaseTable(tables.Table):
if request.user.is_authenticated: if request.user.is_authenticated:
table_name = self.__class__.__name__ table_name = self.__class__.__name__
if self.prefixed_order_by_field in request.GET: if self.prefixed_order_by_field in request.GET:
# If an ordering has been specified as a query parameter, save it as the if request.GET[self.prefixed_order_by_field]:
# user's preferred ordering for this table. # If an ordering has been specified as a query parameter, save it as the
ordering = request.GET.getlist(self.prefixed_order_by_field) # user's preferred ordering for this table.
request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True) 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'): elif ordering := request.user.config.get(f'tables.{table_name}.ordering'):
# If no ordering has been specified, set the preferred ordering (if any). # If no ordering has been specified, set the preferred ordering (if any).
self.order_by = ordering self.order_by = ordering

View File

@ -1,7 +1,8 @@
import datetime import datetime
from django.conf import settings 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.contrib.contenttypes.models import ContentType
from django.test import Client from django.test import Client
from django.test.utils import override_settings from django.test.utils import override_settings
@ -16,6 +17,9 @@ from utilities.testing import TestCase
from utilities.testing.api import APITestCase from utilities.testing.api import APITestCase
User = get_user_model()
class TokenAuthenticationTestCase(APITestCase): class TokenAuthenticationTestCase(APITestCase):
@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])

View File

@ -2,18 +2,18 @@
{% load helpers %} {% load helpers %}
{% block content %} {% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %} {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<div class="card"> <div class="card">
<div class="card-body" id="object_list"> <div class="card-body htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %} {% include 'htmx/table.html' %}
</div> </div>
</div> </div>
</form> </form>
{% endblock content %} {% endblock %}
{% block modals %} {% block modals %}
{{ block.super }} {{ block.super }}
{% table_config_form table %} {% table_config_form table %}
{% endblock modals %} {% endblock modals %}

View File

@ -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.contrib.contenttypes.models import ContentType
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
@ -28,7 +29,7 @@ class NestedUserSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='users-api:user-detail') url = serializers.HyperlinkedIdentityField(view_name='users-api:user-detail')
class Meta: class Meta:
model = User model = get_user_model()
fields = ['id', 'url', 'display', 'username'] fields = ['id', 'url', 'display', 'username']
@extend_schema_field(OpenApiTypes.STR) @extend_schema_field(OpenApiTypes.STR)

View File

@ -1,5 +1,6 @@
from django.conf import settings 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.contrib.contenttypes.models import ContentType
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
@ -30,7 +31,7 @@ class UserSerializer(ValidatedModelSerializer):
) )
class Meta: class Meta:
model = User model = get_user_model()
fields = ( fields = (
'id', 'url', 'display', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'id', 'url', 'display', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active',
'date_joined', 'groups', 'date_joined', 'groups',
@ -124,7 +125,7 @@ class ObjectPermissionSerializer(ValidatedModelSerializer):
many=True many=True
) )
users = SerializedPKRelatedField( users = SerializedPKRelatedField(
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
serializer=NestedUserSerializer, serializer=NestedUserSerializer,
required=False, required=False,
many=True many=True

View File

@ -1,5 +1,6 @@
from django.contrib.auth import authenticate 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 django.db.models import Count
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
@ -32,7 +33,7 @@ class UsersRootView(APIRootView):
# #
class UserViewSet(NetBoxModelViewSet): 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 serializer_class = serializers.UserSerializer
filterset_class = filtersets.UserFilterSet filterset_class = filtersets.UserFilterSet

View File

@ -1,5 +1,6 @@
import django_filters 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.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -47,7 +48,7 @@ class UserFilterSet(BaseFilterSet):
) )
class Meta: class Meta:
model = User model = get_user_model()
fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active'] fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active']
def search(self, queryset, name, value): def search(self, queryset, name, value):
@ -68,12 +69,12 @@ class TokenFilterSet(BaseFilterSet):
) )
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
field_name='user', field_name='user',
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
label=_('User'), label=_('User'),
) )
user = django_filters.ModelMultipleChoiceFilter( user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username', field_name='user__username',
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
to_field_name='username', to_field_name='username',
label=_('User (name)'), label=_('User (name)'),
) )
@ -116,12 +117,12 @@ class ObjectPermissionFilterSet(BaseFilterSet):
) )
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
field_name='users', field_name='users',
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
label=_('User'), label=_('User'),
) )
user = django_filters.ModelMultipleChoiceFilter( user = django_filters.ModelMultipleChoiceFilter(
field_name='users__username', field_name='users__username',
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
to_field_name='username', to_field_name='username',
label=_('User (name)'), label=_('User (name)'),
) )

View File

@ -1,6 +1,7 @@
import graphene 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 netbox.graphql.fields import ObjectField, ObjectListField
from .types import * from .types import *
from utilities.graphql_optimizer import gql_query_optimizer from utilities.graphql_optimizer import gql_query_optimizer
@ -17,4 +18,4 @@ class UsersQuery(graphene.ObjectType):
user_list = ObjectListField(UserType) user_list = ObjectListField(UserType)
def resolve_user_list(root, info, **kwargs): 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)

View File

@ -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 graphene_django import DjangoObjectType
from users import filtersets from users import filtersets
@ -25,7 +26,7 @@ class GroupType(DjangoObjectType):
class UserType(DjangoObjectType): class UserType(DjangoObjectType):
class Meta: class Meta:
model = User model = get_user_model()
fields = ( fields = (
'id', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined', 'id', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined',
'groups', 'groups',
@ -34,4 +35,4 @@ class UserType(DjangoObjectType):
@classmethod @classmethod
def get_queryset(cls, queryset, info): 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')

View File

@ -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.contrib.contenttypes.models import ContentType
from django.urls import reverse from django.urls import reverse
@ -7,6 +8,9 @@ from utilities.testing import APIViewTestCases, APITestCase
from utilities.utils import deepmerge from utilities.utils import deepmerge
User = get_user_model()
class AppTest(APITestCase): class AppTest(APITestCase):
def test_root(self): def test_root(self):

View File

@ -1,6 +1,7 @@
import datetime 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.contrib.contenttypes.models import ContentType
from django.test import TestCase from django.test import TestCase
from django.utils.timezone import make_aware from django.utils.timezone import make_aware
@ -10,6 +11,9 @@ from users.models import ObjectPermission, Token
from utilities.testing import BaseFilterSetTests from utilities.testing import BaseFilterSetTests
User = get_user_model()
class UserTestCase(TestCase, BaseFilterSetTests): class UserTestCase(TestCase, BaseFilterSetTests):
queryset = User.objects.all() queryset = User.objects.all()
filterset = filtersets.UserFilterSet filterset = filtersets.UserFilterSet

View File

@ -1,7 +1,10 @@
from django.contrib.auth.models import User from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
User = get_user_model()
class UserConfigTest(TestCase): class UserConfigTest(TestCase):
@classmethod @classmethod

View File

@ -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 import override_settings
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.urls import reverse from django.urls import reverse
@ -16,6 +16,9 @@ DEFAULT_USER_PREFERENCES = {
} }
User = get_user_model()
class UserPreferencesTest(TestCase): class UserPreferencesTest(TestCase):
user_permissions = ['dcim.view_site'] user_permissions = ['dcim.view_site']

View File

@ -2,7 +2,7 @@ import inspect
import json import json
from django.conf import settings 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.contrib.contenttypes.models import ContentType
from django.urls import reverse from django.urls import reverse
from django.test import override_settings from django.test import override_settings
@ -26,6 +26,9 @@ __all__ = (
) )
User = get_user_model()
# #
# REST/GraphQL API Tests # REST/GraphQL API Tests
# #

View File

@ -1,6 +1,6 @@
import json 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.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import FieldDoesNotExist from django.core.exceptions import FieldDoesNotExist
@ -27,7 +27,7 @@ class TestCase(_TestCase):
def setUp(self): def setUp(self):
# Create the test user and assign permissions # 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) self.add_permissions(*self.user_permissions)
# Initialize the test client # Initialize the test client

View File

@ -2,7 +2,8 @@ import logging
import re import re
from contextlib import contextmanager 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 django.utils.text import slugify
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site 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. 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: if permissions is None:
permissions = () permissions = ()
for perm_name in permissions: for perm_name in permissions:

View File

@ -1,19 +1,19 @@
bleach==6.0.0 bleach==6.0.0
boto3==1.26.145 boto3==1.26.156
Django==4.2.2 Django==4.2.2
django-cors-headers==4.0.0 django-cors-headers==4.1.0
django-debug-toolbar==4.1.0 django-debug-toolbar==4.1.0
django-filter==23.2 django-filter==23.2
django-graphiql-debug-toolbar==0.2.0 django-graphiql-debug-toolbar==0.2.0
django-mptt==0.14 django-mptt==0.14
django-pglocks==1.0.4 django-pglocks==1.0.4
django-prometheus==2.3.1 django-prometheus==2.3.1
django-redis==5.2.0 django-redis==5.3.0
django-rich==1.5.0 django-rich==1.6.0
django-rq==2.8.1 django-rq==2.8.1
django-tables2==2.5.3 django-tables2==2.5.3
django-taggit==4.0.0 django-taggit==4.0.0
django-timezone-field==5.0 django-timezone-field==5.1
djangorestframework==3.14.0 djangorestframework==3.14.0
drf-spectacular==0.26.2 drf-spectacular==0.26.2
drf-spectacular-sidecar==2023.6.1 drf-spectacular-sidecar==2023.6.1
@ -23,7 +23,7 @@ graphene-django==3.0.0
gunicorn==20.1.0 gunicorn==20.1.0
Jinja2==3.1.2 Jinja2==3.1.2
Markdown==3.3.7 Markdown==3.3.7
mkdocs-material==9.1.15 mkdocs-material==9.1.16
mkdocstrings[python-legacy]==0.22.0 mkdocstrings[python-legacy]==0.22.0
netaddr==0.8.0 netaddr==0.8.0
Pillow==9.5.0 Pillow==9.5.0
@ -31,9 +31,9 @@ psycopg==3.1.9
psycopg-binary==3.1.9 psycopg-binary==3.1.9
psycopg-pool==3.1.7 psycopg-pool==3.1.7
PyYAML==6.0 PyYAML==6.0
sentry-sdk==1.25.0 sentry-sdk==1.25.1
social-auth-app-django==5.2.0 social-auth-app-django==5.2.0
social-auth-core[openidconnect]==4.4.2 social-auth-core[openidconnect]==4.4.2
svgwrite==1.4.3 svgwrite==1.4.3
tablib==3.4.0 tablib==3.5.0
tzdata==2023.3 tzdata==2023.3