mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-26 01:06:11 -06:00
Merge branch 'feature' into 12237-django-4.2-2
This commit is contained in:
commit
2091fef53b
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -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
|
||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -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
|
||||
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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({
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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)'),
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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'
|
||||
)
|
||||
)
|
||||
|
@ -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)]),
|
||||
),
|
||||
]
|
||||
|
@ -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')
|
||||
)
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
||||
|
@ -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()
|
||||
)
|
||||
|
@ -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'),
|
||||
)
|
||||
|
@ -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'),
|
||||
)
|
||||
|
@ -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(
|
||||
|
@ -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']
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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'):
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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'])
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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=['*'])
|
||||
|
@ -2,18 +2,18 @@
|
||||
{% load helpers %}
|
||||
|
||||
{% block content %}
|
||||
{% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="card">
|
||||
<div class="card-body" id="object_list">
|
||||
{% include 'htmx/table.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
{% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="card">
|
||||
<div class="card-body htmx-container table-responsive" id="object_list">
|
||||
{% include 'htmx/table.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block modals %}
|
||||
{{ block.super }}
|
||||
{% table_config_form table %}
|
||||
{{ block.super }}
|
||||
{% table_config_form table %}
|
||||
{% endblock modals %}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)'),
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -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')
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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']
|
||||
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user