Просмотр исходного кода

Merge pull request #23674 from netbox-community/18040-mac-prefix

Closes #18040: Add a MACBlock model to track blocks of MAC addresses
bctiemann 11 часов назад
Родитель
Сommit
a46c1c26b5

+ 2 - 0
docs/features/devices-cabling.md

@@ -91,6 +91,8 @@ One especially nice feature of modules is that templated components can be autom
 
 
 [MAC addresses](../models/dcim/macaddress.md) are modeled as first-class objects in NetBox so that an interface may have multiple MAC addresses assigned to it, with one optionally designated as the interface's primary MAC. This accommodates virtual interfaces and modular hardware where the link-layer address is not necessarily fixed at the factory. MAC addresses can be assigned to both [device interfaces](../models/dcim/interface.md) and [virtual machine interfaces](../models/virtualization/vminterface.md).
 [MAC addresses](../models/dcim/macaddress.md) are modeled as first-class objects in NetBox so that an interface may have multiple MAC addresses assigned to it, with one optionally designated as the interface's primary MAC. This accommodates virtual interfaces and modular hardware where the link-layer address is not necessarily fixed at the factory. MAC addresses can be assigned to both [device interfaces](../models/dcim/interface.md) and [virtual machine interfaces](../models/virtualization/vminterface.md).
 
 
+[MAC address blocks](../models/dcim/macblock.md) represent blocks of MAC addresses, such as organizationally unique identifiers (OUIs). They can be used to track the utilization of a block, to create MAC addresses from the next available address in a block, and to prevent duplicate MAC addresses within a block.
+
 ## Cables
 ## Cables
 
 
 NetBox models cables as connections among certain types of device components and other objects. Each cable can be assigned a type, color, length, and label. NetBox will enforce basic sanity checks to prevent invalid connections. (For example, a network interface cannot be connected to a power outlet.)
 NetBox models cables as connections among certain types of device components and other objects. Each cable can be assigned a type, color, length, and label. NetBox will enforce basic sanity checks to prevent invalid connections. (For example, a network interface cannot be connected to a power outlet.)

+ 2 - 0
docs/models/dcim/macaddress.md

@@ -4,6 +4,8 @@ A MAC address object in NetBox represents a single Ethernet link-layer address a
 
 
 Most physical interfaces have only a single MAC address, hard-coded at the factory. However, some interfaces (particularly virtual interfaces and modular hardware) support multiple or reassignable MAC addresses. To accommodate this, NetBox models MAC addresses as first-class objects which may be created, modified, and reassigned independently of any specific interface.
 Most physical interfaces have only a single MAC address, hard-coded at the factory. However, some interfaces (particularly virtual interfaces and modular hardware) support multiple or reassignable MAC addresses. To accommodate this, NetBox models MAC addresses as first-class objects which may be created, modified, and reassigned independently of any specific interface.
 
 
+Blocks of MAC addresses, such as organizationally unique identifiers (OUIs), can be tracked as [MAC address blocks](./macblock.md).
+
 ## Fields
 ## Fields
 
 
 ### MAC Address
 ### MAC Address

+ 33 - 0
docs/models/dcim/macblock.md

@@ -0,0 +1,33 @@
+# MAC Address Blocks
+
+A MAC address block represents a contiguous range of [MAC addresses](./macaddress.md), defined by a prefix (the first address in the block) and a prefix length (the number of leading bits shared by every address in it). This mirrors the blocks assigned by the IEEE: an organizationally unique identifier (OUI, or MA-L block) such as `BC:24:11` is a MAC address block with a prefix length of 24, spanning the MAC addresses `BC:24:11:00:00:00` through `BC:24:11:FF:FF:FF`. The smaller MA-M and MA-S blocks have lengths of 28 and 36 bits, respectively. Blocks can also represent ranges within an assigned block which are set aside for a particular purpose, such as a virtualization cluster.
+
+A MAC address belongs to every MAC address block which contains it: MAC addresses are not explicitly assigned to blocks. The MAC address view shows the most specific block which contains it.
+
+Each MAC address block shows its utilization (the percentage of its MAC addresses which exist in NetBox), and its MAC addresses. A new MAC address can be created from the first available address within the block, using either the UI or the REST API (`/api/dcim/mac-blocks/<id>/available-mac-addresses/`).
+
+## Fields
+
+### Prefix
+
+The first MAC address in the block, for example `BC:24:11:00:00:00`. Any bits beyond the prefix length are cleared when the prefix is saved.
+
+### Prefix Length
+
+The number of leading bits which are shared by all MAC addresses in the block (1 to 48). Defaults to 24, the length of an OUI.
+
+### Name
+
+An optional name for the block, such as the organization to which it is assigned.
+
+### Enforce Unique
+
+If enabled, duplicate MAC addresses are not permitted within this block. Uniqueness cannot be enforced on a block which already contains duplicate MAC addresses.
+
+### Description
+
+An optional human-readable description of the MAC address block.
+
+### Comments
+
+Free-form Markdown-supported notes regarding the MAC address block.

+ 1 - 0
mkdocs.yml

@@ -225,6 +225,7 @@ nav:
             - InventoryItemTemplate: 'models/dcim/inventoryitemtemplate.md'
             - InventoryItemTemplate: 'models/dcim/inventoryitemtemplate.md'
             - Location: 'models/dcim/location.md'
             - Location: 'models/dcim/location.md'
             - MACAddress: 'models/dcim/macaddress.md'
             - MACAddress: 'models/dcim/macaddress.md'
+            - MACBlock: 'models/dcim/macblock.md'
             - Manufacturer: 'models/dcim/manufacturer.md'
             - Manufacturer: 'models/dcim/manufacturer.md'
             - Module: 'models/dcim/module.md'
             - Module: 'models/dcim/module.md'
             - ModuleBay: 'models/dcim/modulebay.md'
             - ModuleBay: 'models/dcim/modulebay.md'

+ 34 - 1
netbox/dcim/api/serializers_/devices.py

@@ -8,7 +8,7 @@ from rest_framework import serializers
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS
 from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS
-from dcim.models import Device, DeviceBay, MACAddress, Module, VirtualDeviceContext
+from dcim.models import Device, DeviceBay, MACAddress, MACBlock, Module, VirtualDeviceContext
 from dcim.utils import get_module_bay_positions, resolve_module_placeholder
 from dcim.utils import get_module_bay_positions, resolve_module_placeholder
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
 from ipam.api.serializers_.ip import IPAddressSerializer
 from ipam.api.serializers_.ip import IPAddressSerializer
@@ -27,8 +27,10 @@ from .sites import LocationSerializer, SiteSerializer
 from .virtualchassis import VirtualChassisSerializer
 from .virtualchassis import VirtualChassisSerializer
 
 
 __all__ = (
 __all__ = (
+    'AvailableMACAddressSerializer',
     'DeviceSerializer',
     'DeviceSerializer',
     'MACAddressSerializer',
     'MACAddressSerializer',
+    'MACBlockSerializer',
     'ModuleSerializer',
     'ModuleSerializer',
     'VirtualDeviceContextSerializer',
     'VirtualDeviceContextSerializer',
 )
 )
@@ -359,3 +361,34 @@ class MACAddressSerializer(PrimaryModelSerializer):
             'last_updated',
             'last_updated',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'mac_address', 'description')
         brief_fields = ('id', 'url', 'display', 'mac_address', 'description')
+
+
+class MACBlockSerializer(PrimaryModelSerializer):
+    last_address = serializers.CharField(read_only=True)
+    size = serializers.IntegerField(read_only=True)
+    mac_address_count = serializers.IntegerField(read_only=True)
+    utilization = serializers.FloatField(read_only=True)
+
+    class Meta:
+        model = MACBlock
+        fields = [
+            'id', 'url', 'display_url', 'display', 'prefix', 'prefix_length', 'last_address', 'size', 'name',
+            'enforce_unique', 'mac_address_count', 'utilization', 'description', 'owner', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'prefix', 'prefix_length', 'name', 'description')
+        # Preserve the prefix as normalized by MACBlock.clean()
+        model_clean_fields = ('prefix',)
+
+
+class AvailableMACAddressSerializer(serializers.Serializer):
+    """
+    Representation of a MAC address which does not exist in the database.
+    """
+    mac_address = serializers.CharField(read_only=True)
+    description = serializers.CharField(required=False)
+
+    def to_representation(self, instance):
+        return {
+            'mac_address': str(instance),
+        }

+ 13 - 1
netbox/dcim/api/urls.py

@@ -1,3 +1,5 @@
+from django.urls import path
+
 from netbox.api.routers import NetBoxRouter
 from netbox.api.routers import NetBoxRouter
 
 
 from . import views
 from . import views
@@ -65,6 +67,7 @@ router.register('inventory-item-roles', views.InventoryItemRoleViewSet)
 
 
 # Addressing
 # Addressing
 router.register('mac-addresses', views.MACAddressViewSet)
 router.register('mac-addresses', views.MACAddressViewSet)
+router.register('mac-blocks', views.MACBlockViewSet)
 
 
 # Cables
 # Cables
 router.register('cables', views.CableViewSet)
 router.register('cables', views.CableViewSet)
@@ -86,4 +89,13 @@ router.register('cooling-feeds', views.CoolingFeedViewSet)
 router.register('connected-device', views.ConnectedDeviceViewSet, basename='connected-device')
 router.register('connected-device', views.ConnectedDeviceViewSet, basename='connected-device')
 
 
 app_name = 'dcim-api'
 app_name = 'dcim-api'
-urlpatterns = router.urls
+
+urlpatterns = [
+    path(
+        'mac-blocks/<int:pk>/available-mac-addresses/',
+        views.MACBlockAvailableMACAddressesView.as_view(),
+        name='macblock-available-mac-addresses'
+    ),
+]
+
+urlpatterns += router.urls

+ 43 - 0
netbox/dcim/api/views.py

@@ -1,3 +1,5 @@
+from itertools import islice
+
 from django.contrib.contenttypes.prefetch import GenericPrefetch
 from django.contrib.contenttypes.prefetch import GenericPrefetch
 from django.db.models import FETCH_PEERS, Prefetch
 from django.db.models import FETCH_PEERS, Prefetch
 from django.http import Http404, HttpResponse
 from django.http import Http404, HttpResponse
@@ -14,10 +16,12 @@ from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
 from dcim.models import *
 from dcim.models import *
 from dcim.svg import CableTraceSVG
 from dcim.svg import CableTraceSVG
 from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
 from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
+from ipam.api.views import AvailableObjectsView
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.pagination import StripCountAnnotationsPaginator
 from netbox.api.pagination import StripCountAnnotationsPaginator
 from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
 from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
+from netbox.config import get_config
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 from utilities.query import count_related
 from utilities.query import count_related
 from utilities.query_functions import CollateAsChar
 from utilities.query_functions import CollateAsChar
@@ -603,6 +607,45 @@ class MACAddressViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.MACAddressFilterSet
     filterset_class = filtersets.MACAddressFilterSet
 
 
 
 
+class MACBlockViewSet(NetBoxModelViewSet):
+    queryset = MACBlock.objects.annotate_mac_address_count()
+    serializer_class = serializers.MACBlockSerializer
+    filterset_class = filtersets.MACBlockFilterSet
+
+
+class MACBlockAvailableMACAddressesView(AvailableObjectsView):
+    queryset = MACAddress.objects.all()
+    read_serializer_class = serializers.AvailableMACAddressSerializer
+    write_serializer_class = serializers.AvailableMACAddressSerializer
+    advisory_lock_key = 'available-mac-addresses'
+
+    def get_parent(self, request, pk):
+        return get_object_or_404(MACBlock.objects.restrict(request.user), pk=pk)
+
+    def get_available_objects(self, parent, limit=None):
+        # A block may contain millions of available MAC addresses, so always apply a limit
+        if not limit or limit < 0:
+            limit = get_config().PAGINATE_COUNT
+        return list(islice(parent.iter_available_mac_addresses(), limit))
+
+    def prep_object_data(self, requested_objects, available_objects, parent):
+        for i, request_data in enumerate(requested_objects):
+            request_data['mac_address'] = str(available_objects[i])
+        return requested_objects
+
+    @extend_schema(methods=['get'], responses={200: serializers.AvailableMACAddressSerializer(many=True)})
+    def get(self, request, pk):
+        return super().get(request, pk)
+
+    @extend_schema(
+        methods=['post'],
+        responses={201: serializers.MACAddressSerializer(many=True)},
+        request=serializers.MACAddressSerializer(many=True),
+    )
+    def post(self, request, pk):
+        return super().post(request, pk)
+
+
 #
 #
 # Cables
 # Cables
 #
 #

+ 9 - 0
netbox/dcim/constants.py

@@ -85,6 +85,15 @@ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES + [
 ]
 ]
 
 
 
 
+#
+# MAC blocks
+#
+
+MACBLOCK_LENGTH_MIN = 1
+MACBLOCK_LENGTH_MAX = 48
+MACBLOCK_LENGTH_DEFAULT = 24  # An organizationally unique identifier (OUI)
+
+
 #
 #
 # Device components
 # Device components
 #
 #

+ 45 - 0
netbox/dcim/filtersets.py

@@ -79,6 +79,7 @@ __all__ = (
     'InventoryItemTemplateFilterSet',
     'InventoryItemTemplateFilterSet',
     'LocationFilterSet',
     'LocationFilterSet',
     'MACAddressFilterSet',
     'MACAddressFilterSet',
+    'MACBlockFilterSet',
     'ManufacturerFilterSet',
     'ManufacturerFilterSet',
     'ModuleBayFilterSet',
     'ModuleBayFilterSet',
     'ModuleBayTemplateFilterSet',
     'ModuleBayTemplateFilterSet',
@@ -2287,6 +2288,10 @@ class MACAddressFilterSet(PrimaryModelFilterSet):
         method='filter_primary',
         method='filter_primary',
         label=_('Is primary'),
         label=_('Is primary'),
     )
     )
+    mac_block_id = MultiValueBigNumberFilter(
+        method='filter_mac_block',
+        label=_('MAC address block (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = MACAddress
         model = MACAddress
@@ -2344,6 +2349,46 @@ class MACAddressFilterSet(PrimaryModelFilterSet):
             return queryset.filter(query)
             return queryset.filter(query)
         return queryset.exclude(query)
         return queryset.exclude(query)
 
 
+    def filter_mac_block(self, queryset, name, value):
+        query = Q()
+        for mac_block in MACBlock.objects.filter(pk__in=value):
+            query |= Q(mac_address__gte=mac_block.first_address, mac_address__lte=mac_block.last_address)
+        if not query:
+            return queryset.none()
+        return queryset.filter(query)
+
+
+@register_filterset
+class MACBlockFilterSet(PrimaryModelFilterSet):
+    prefix = MultiValueMACAddressFilter()
+    contains = django_filters.CharFilter(
+        method='filter_contains',
+        label=_('Contains MAC address'),
+    )
+
+    class Meta:
+        model = MACBlock
+        fields = ('id', 'prefix_length', 'name', 'enforce_unique', 'description')
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = (
+            Q(prefix__icontains=value) |
+            Q(name__icontains=value) |
+            Q(description__icontains=value)
+        )
+        return queryset.filter(qs_filter)
+
+    def filter_contains(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        try:
+            mac_address = netaddr.EUI(value.strip(), version=48)
+        except (AddrFormatError, TypeError, ValueError):
+            return queryset.none()
+        return queryset.filter(prefix__lte=mac_address, _last_address__gte=mac_address)
+
 
 
 class CommonInterfaceFilterSet(django_filters.FilterSet):
 class CommonInterfaceFilterSet(django_filters.FilterSet):
     mode = django_filters.MultipleChoiceFilter(
     mode = django_filters.MultipleChoiceFilter(

+ 20 - 0
netbox/dcim/forms/bulk_edit.py

@@ -63,6 +63,7 @@ __all__ = (
     'InventoryItemTemplateBulkEditForm',
     'InventoryItemTemplateBulkEditForm',
     'LocationBulkEditForm',
     'LocationBulkEditForm',
     'MACAddressBulkEditForm',
     'MACAddressBulkEditForm',
+    'MACBlockBulkEditForm',
     'ManufacturerBulkEditForm',
     'ManufacturerBulkEditForm',
     'ModuleBayBulkEditForm',
     'ModuleBayBulkEditForm',
     'ModuleBayTemplateBulkEditForm',
     'ModuleBayTemplateBulkEditForm',
@@ -2283,3 +2284,22 @@ class MACAddressBulkEditForm(PrimaryModelBulkEditForm):
         FieldSet('description'),
         FieldSet('description'),
     )
     )
     nullable_fields = ('description', 'comments')
     nullable_fields = ('description', 'comments')
+
+
+class MACBlockBulkEditForm(PrimaryModelBulkEditForm):
+    name = forms.CharField(
+        label=_('Name'),
+        max_length=100,
+        required=False
+    )
+    enforce_unique = forms.NullBooleanField(
+        label=_('Enforce unique'),
+        required=False,
+        widget=BulkEditNullBooleanSelect
+    )
+
+    model = MACBlock
+    fieldsets = (
+        FieldSet('name', 'enforce_unique', 'description'),
+    )
+    nullable_fields = ('name', 'description', 'comments')

+ 10 - 0
netbox/dcim/forms/bulk_import.py

@@ -55,6 +55,7 @@ __all__ = (
     'InventoryItemRoleImportForm',
     'InventoryItemRoleImportForm',
     'LocationImportForm',
     'LocationImportForm',
     'MACAddressImportForm',
     'MACAddressImportForm',
+    'MACBlockImportForm',
     'ManufacturerImportForm',
     'ManufacturerImportForm',
     'ModuleBayImportForm',
     'ModuleBayImportForm',
     'ModuleBayTypeImportForm',
     'ModuleBayTypeImportForm',
@@ -1710,6 +1711,15 @@ class MACAddressImportForm(PrimaryModelImportForm):
         return instance
         return instance
 
 
 
 
+class MACBlockImportForm(PrimaryModelImportForm):
+
+    class Meta:
+        model = MACBlock
+        fields = [
+            'prefix', 'prefix_length', 'name', 'enforce_unique', 'description', 'owner', 'comments', 'tags',
+        ]
+
+
 #
 #
 # Cables
 # Cables
 #
 #

+ 38 - 1
netbox/dcim/forms/filtersets.py

@@ -60,6 +60,7 @@ __all__ = (
     'InventoryItemTemplateFilterForm',
     'InventoryItemTemplateFilterForm',
     'LocationFilterForm',
     'LocationFilterForm',
     'MACAddressFilterForm',
     'MACAddressFilterForm',
+    'MACBlockFilterForm',
     'ManufacturerFilterForm',
     'ManufacturerFilterForm',
     'ModuleBayFilterForm',
     'ModuleBayFilterForm',
     'ModuleBayTemplateFilterForm',
     'ModuleBayTemplateFilterForm',
@@ -2452,7 +2453,7 @@ class MACAddressFilterForm(PrimaryModelFilterSetForm):
     model = MACAddress
     model = MACAddress
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('mac_address', name=_('Attributes')),
+        FieldSet('mac_address', 'mac_block_id', name=_('Attributes')),
         FieldSet('device_id', 'virtual_machine_id', 'assigned', 'primary', name=_('Assignments')),
         FieldSet('device_id', 'virtual_machine_id', 'assigned', 'primary', name=_('Assignments')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
@@ -2461,6 +2462,11 @@ class MACAddressFilterForm(PrimaryModelFilterSetForm):
         required=False,
         required=False,
         label=_('MAC address'),
         label=_('MAC address'),
     )
     )
+    mac_block_id = DynamicModelMultipleChoiceField(
+        queryset=MACBlock.objects.all(),
+        required=False,
+        label=_('MAC address block'),
+    )
     device_id = DynamicModelMultipleChoiceField(
     device_id = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         required=False,
         required=False,
@@ -2488,6 +2494,37 @@ class MACAddressFilterForm(PrimaryModelFilterSetForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
+class MACBlockFilterForm(PrimaryModelFilterSetForm):
+    model = MACBlock
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('prefix', 'prefix_length', 'contains', 'enforce_unique', name=_('Attributes')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
+    )
+    prefix = forms.CharField(
+        required=False,
+        label=_('Prefix'),
+    )
+    prefix_length = forms.IntegerField(
+        required=False,
+        min_value=MACBLOCK_LENGTH_MIN,
+        max_value=MACBLOCK_LENGTH_MAX,
+        label=_('Prefix length'),
+    )
+    contains = forms.CharField(
+        required=False,
+        label=_('Contains MAC address'),
+    )
+    enforce_unique = forms.NullBooleanField(
+        required=False,
+        label=_('Enforce unique'),
+        widget=forms.Select(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        ),
+    )
+    tag = TagFilterField(model)
+
+
 #
 #
 # Connections
 # Connections
 #
 #

+ 21 - 0
netbox/dcim/forms/model_forms.py

@@ -73,6 +73,7 @@ __all__ = (
     'InventoryItemTemplateForm',
     'InventoryItemTemplateForm',
     'LocationForm',
     'LocationForm',
     'MACAddressForm',
     'MACAddressForm',
+    'MACBlockForm',
     'ManufacturerForm',
     'ManufacturerForm',
     'ModuleBayForm',
     'ModuleBayForm',
     'ModuleBayTemplateForm',
     'ModuleBayTemplateForm',
@@ -2603,3 +2604,23 @@ class MACAddressForm(PrimaryModelForm):
             self.instance.assigned_object = self.cleaned_data[selected_objects[0]]
             self.instance.assigned_object = self.cleaned_data[selected_objects[0]]
         else:
         else:
             self.instance.assigned_object = None
             self.instance.assigned_object = None
+
+
+class MACBlockForm(PrimaryModelForm):
+    prefix = forms.CharField(
+        required=True,
+        label=_('Prefix'),
+        help_text=_('The first MAC address in the block (e.g. BC:24:11:00:00:00)')
+    )
+
+    fieldsets = (
+        FieldSet(
+            'prefix', 'prefix_length', 'name', 'enforce_unique', 'description', 'tags', name=_('MAC Address Block')
+        ),
+    )
+
+    class Meta:
+        model = MACBlock
+        fields = [
+            'prefix', 'prefix_length', 'name', 'enforce_unique', 'description', 'owner', 'comments', 'tags',
+        ]

+ 11 - 0
netbox/dcim/graphql/filters.py

@@ -97,6 +97,7 @@ __all__ = (
     'InventoryItemTemplateFilter',
     'InventoryItemTemplateFilter',
     'LocationFilter',
     'LocationFilter',
     'MACAddressFilter',
     'MACAddressFilter',
+    'MACBlockFilter',
     'ManufacturerFilter',
     'ManufacturerFilter',
     'ModuleBayFilter',
     'ModuleBayFilter',
     'ModuleBayTemplateFilter',
     'ModuleBayTemplateFilter',
@@ -538,6 +539,16 @@ class MACAddressFilter(PrimaryModelFilter):
         return ~Q(query)
         return ~Q(query)
 
 
 
 
+@register_filter(models.MACBlock, lookups=True)
+class MACBlockFilter(PrimaryModelFilter):
+    prefix: StrFilterLookup | None = strawberry_django.filter_field()
+    prefix_length: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    enforce_unique: FilterLookup[bool] | None = strawberry_django.filter_field()
+
+
 @register_filter(models.Interface, lookups=True)
 @register_filter(models.Interface, lookups=True)
 class InterfaceFilter(
 class InterfaceFilter(
     ModularComponentFilterMixin,
     ModularComponentFilterMixin,

+ 3 - 0
netbox/dcim/graphql/schema.py

@@ -66,6 +66,9 @@ class DCIMQuery:
     mac_address: MACAddressType = strawberry_django.field()
     mac_address: MACAddressType = strawberry_django.field()
     mac_address_list: list[MACAddressType] = strawberry_django.field()
     mac_address_list: list[MACAddressType] = strawberry_django.field()
 
 
+    mac_block: MACBlockType = strawberry_django.field()
+    mac_block_list: list[MACBlockType] = strawberry_django.field()
+
     interface: InterfaceType = strawberry_django.field()
     interface: InterfaceType = strawberry_django.field()
     interface_list: list[InterfaceType] = strawberry_django.field()
     interface_list: list[InterfaceType] = strawberry_django.field()
 
 

+ 11 - 0
netbox/dcim/graphql/types.py

@@ -74,6 +74,7 @@ __all__ = (
     'InventoryItemType',
     'InventoryItemType',
     'LocationType',
     'LocationType',
     'MACAddressType',
     'MACAddressType',
+    'MACBlockType',
     'ManufacturerType',
     'ManufacturerType',
     'ModularComponentType',
     'ModularComponentType',
     'ModuleBayTemplateType',
     'ModuleBayTemplateType',
@@ -524,6 +525,16 @@ class FrontPortTemplateType(ModularComponentTemplateType):
     mappings: list[Annotated["PortMappingTemplateType", strawberry.lazy('dcim.graphql.types')]]
     mappings: list[Annotated["PortMappingTemplateType", strawberry.lazy('dcim.graphql.types')]]
 
 
 
 
+@register_type(
+    models.MACBlock,
+    exclude=['_last_address'],
+    filters=MACBlockFilter,
+    pagination=True
+)
+class MACBlockType(PrimaryObjectType):
+    prefix: str
+
+
 @register_type(
 @register_type(
     models.MACAddress,
     models.MACAddress,
     exclude=['assigned_object_type', 'assigned_object_id'],
     exclude=['assigned_object_type', 'assigned_object_id'],

+ 77 - 0
netbox/dcim/migrations/0253_macblock.py

@@ -0,0 +1,77 @@
+# Generated by Django 6.1.1 on 2026-11-12 14:04
+
+import django.core.validators
+import django.db.models.deletion
+import taggit.managers
+from django.db import migrations, models
+
+import dcim.fields
+import netbox.models.deletion
+import utilities.json
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0252_generated_fields'),
+        ('extras', '0145_taggeditem_object_id_bigint'),
+        ('users', '0016_default_ordering_indexes'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='MACBlock',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                (
+                    'custom_field_data',
+                    models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+                ),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('comments', models.TextField(blank=True)),
+                ('prefix', dcim.fields.MACAddressField()),
+                (
+                    'prefix_length',
+                    models.PositiveSmallIntegerField(
+                        default=24,
+                        validators=[
+                            django.core.validators.MinValueValidator(1),
+                            django.core.validators.MaxValueValidator(48),
+                        ],
+                    ),
+                ),
+                ('name', models.CharField(blank=True, max_length=100)),
+                ('enforce_unique', models.BooleanField(default=False)),
+                ('_last_address', dcim.fields.MACAddressField(blank=True, editable=False)),
+                (
+                    'owner',
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.PROTECT,
+                        related_name='+',
+                        to='users.owner',
+                    ),
+                ),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'verbose_name': 'MAC address block',
+                'verbose_name_plural': 'MAC address blocks',
+                'ordering': ('prefix', 'prefix_length', 'pk'),
+                'indexes': [
+                    models.Index(fields=['prefix', 'prefix_length', 'id'], name='dcim_macblo_prefix_221d31_idx'),
+                    models.Index(fields=['prefix', '_last_address'], name='dcim_macblo_prefix_5acee2_idx'),
+                ],
+                'constraints': [
+                    models.UniqueConstraint(
+                        fields=('prefix', 'prefix_length'),
+                        name='dcim_macblock_unique_prefix_prefix_length',
+                        violation_error_message='A MAC address block with this prefix and length already exists.',
+                    )
+                ],
+            },
+            bases=(netbox.models.deletion.DeleteMixin, models.Model),
+        ),
+    ]

+ 229 - 2
netbox/dcim/models/devices.py

@@ -9,16 +9,18 @@ from django.core.exceptions import ValidationError
 from django.core.files.storage import default_storage
 from django.core.files.storage import default_storage
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models, router, transaction
 from django.db import models, router, transaction
-from django.db.models import F, ProtectedError, prefetch_related_objects
+from django.db.models import Count, F, ProtectedError, prefetch_related_objects
 from django.db.models.functions import Lower
 from django.db.models.functions import Lower
 from django.db.models.signals import post_save
 from django.db.models.signals import post_save
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
+from netaddr import EUI, AddrFormatError
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
-from dcim.fields import MACAddressField
+from dcim.fields import MACAddressField, mac_unix_expanded_uppercase
+from dcim.querysets import MACBlockQuerySet
 from dcim.utils import create_port_mappings, update_interface_bridges, update_interface_parents
 from dcim.utils import create_port_mappings, update_interface_bridges, update_interface_parents
 from extras.models import ConfigContextModel, CustomField
 from extras.models import ConfigContextModel, CustomField
 from extras.querysets import ConfigContextModelQuerySet
 from extras.querysets import ConfigContextModelQuerySet
@@ -42,6 +44,7 @@ __all__ = (
     'DeviceRole',
     'DeviceRole',
     'DeviceType',
     'DeviceType',
     'MACAddress',
     'MACAddress',
+    'MACBlock',
     'Manufacturer',
     'Manufacturer',
     'Platform',
     'Platform',
     'VirtualChassis',
     'VirtualChassis',
@@ -1559,3 +1562,227 @@ class MACAddress(PrimaryModel):
                     raise ValidationError(
                     raise ValidationError(
                         _("Cannot reassign MAC Address while it is designated as the primary MAC for an object")
                         _("Cannot reassign MAC Address while it is designated as the primary MAC for an object")
                     )
                     )
+
+        # Enforce uniqueness within any MAC block which requires it
+        if self.mac_address and (
+            mac_block := self.get_mac_blocks().filter(enforce_unique=True).order_by('-prefix_length').first()
+        ):
+            duplicates = MACAddress.objects.filter(mac_address=self.mac_address).exclude(pk=self.pk)
+            if duplicates.exists():
+                raise ValidationError({
+                    'mac_address': _(
+                        "Duplicate MAC address found within MAC address block {mac_block}, which enforces uniqueness"
+                    ).format(mac_block=mac_block)
+                })
+
+    def get_mac_blocks(self):
+        """
+        Return all MACBlocks which contain this MAC address.
+        """
+        return MACBlock.objects.filter(prefix__lte=self.mac_address, _last_address__gte=self.mac_address)
+
+    @cached_property
+    def mac_block(self):
+        """
+        Return the most specific MACBlock which contains this MAC address (if any).
+        """
+        if not self.mac_address:
+            return None
+        return self.get_mac_blocks().order_by('-prefix_length').first()
+
+
+class MACBlock(PrimaryModel):
+    """
+    A block of MAC addresses which share a common prefix, such as an organizationally unique identifier (OUI).
+    """
+    prefix = MACAddressField(
+        verbose_name=_('prefix'),
+        help_text=_('The first MAC address in the block')
+    )
+    prefix_length = models.PositiveSmallIntegerField(
+        verbose_name=_('prefix length'),
+        default=MACBLOCK_LENGTH_DEFAULT,
+        validators=(
+            MinValueValidator(MACBLOCK_LENGTH_MIN),
+            MaxValueValidator(MACBLOCK_LENGTH_MAX),
+        ),
+        help_text=_('Number of leading bits shared by all MAC addresses in the block (e.g. 24 for an OUI)')
+    )
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=100,
+        blank=True,
+        help_text=_('For example, the organization to which the block is assigned')
+    )
+    enforce_unique = models.BooleanField(
+        verbose_name=_('enforce unique'),
+        default=False,
+        help_text=_('Prevent duplicate MAC addresses within this block')
+    )
+
+    # Cached last MAC address in the block, for finding the MAC addresses and blocks it contains
+    _last_address = MACAddressField(
+        blank=True,
+        editable=False
+    )
+
+    objects = MACBlockQuerySet.as_manager()
+
+    clone_fields = ('prefix_length', 'enforce_unique', 'description')
+
+    class Meta:
+        ordering = ('prefix', 'prefix_length', 'pk')
+        indexes = (
+            models.Index(fields=('prefix', 'prefix_length', 'id')),  # Default ordering
+            models.Index(fields=('prefix', '_last_address')),
+        )
+        constraints = (
+            models.UniqueConstraint(
+                fields=('prefix', 'prefix_length'),
+                name='%(app_label)s_%(class)s_unique_prefix_prefix_length',
+                violation_error_message=_("A MAC address block with this prefix and length already exists.")
+            ),
+        )
+        verbose_name = _('MAC address block')
+        verbose_name_plural = _('MAC address blocks')
+
+    def __str__(self):
+        return f'{self.prefix}/{self.prefix_length}'
+
+    def _get_bounds(self):
+        """
+        Return the first and last MAC addresses in the block as integers, or None if the prefix or its length is
+        invalid.
+        """
+        try:
+            prefix = int(EUI(self.prefix, version=48))
+            prefix_length = int(self.prefix_length)
+        except (AddrFormatError, TypeError, ValueError):
+            return None
+        if not MACBLOCK_LENGTH_MIN <= prefix_length <= MACBLOCK_LENGTH_MAX:
+            return None
+        host_mask = (1 << (48 - prefix_length)) - 1
+        return prefix & ~host_mask, prefix | host_mask
+
+    @property
+    def first_address(self):
+        """
+        Return the first MAC address in the block (the prefix with any host bits cleared).
+        """
+        if bounds := self._get_bounds():
+            return EUI(bounds[0], version=48, dialect=mac_unix_expanded_uppercase)
+        return None
+
+    @property
+    def last_address(self):
+        """
+        Return the last MAC address in the block.
+        """
+        if bounds := self._get_bounds():
+            return EUI(bounds[1], version=48, dialect=mac_unix_expanded_uppercase)
+        return None
+
+    @property
+    def size(self):
+        """
+        Return the number of MAC addresses in the block.
+        """
+        return 2 ** (48 - self.prefix_length)
+
+    def clean(self):
+        super().clean()
+
+        # Clear any host bits from the prefix, so that its uniqueness is validated against the value to be saved
+        if (first_address := self.first_address) is not None:
+            self.prefix = first_address
+            self._last_address = self.last_address
+
+        # Uniqueness cannot be enforced if the block already contains duplicate MAC addresses
+        if self.enforce_unique and self.first_address is not None:
+            duplicate = self.get_mac_addresses().values('mac_address').annotate(
+                count=Count('pk')
+            ).filter(count__gt=1).order_by('mac_address').first()
+            if duplicate:
+                raise ValidationError({
+                    'enforce_unique': _(
+                        "Cannot enforce uniqueness: duplicate MAC address {mac_address} exists within this block"
+                    ).format(mac_address=duplicate['mac_address'])
+                })
+
+    def save(self, *args, **kwargs):
+        if self.first_address is None:
+            raise ValueError(f"Invalid MAC address block: {self.prefix}/{self.prefix_length}")
+
+        # Clear any host bits from the prefix, and cache the last address in the block
+        self.prefix = self.first_address
+        self._last_address = self.last_address
+
+        super().save(*args, **kwargs)
+
+    def get_mac_addresses(self):
+        """
+        Return all MACAddresses within this block.
+        """
+        return MACAddress.objects.filter(mac_address__gte=self.first_address, mac_address__lte=self.last_address)
+
+    def get_child_blocks(self):
+        """
+        Return all MACBlocks within this block.
+        """
+        return MACBlock.objects.filter(
+            prefix__gte=self.first_address,
+            _last_address__lte=self.last_address,
+            prefix_length__gt=self.prefix_length,
+        )
+
+    @cached_property
+    def mac_address_count(self):
+        """
+        Return the number of distinct MAC addresses within the block. (May be annotated by
+        MACBlockQuerySet.annotate_mac_address_count().)
+        """
+        return self.get_mac_addresses().order_by().values('mac_address').distinct().count()
+
+    @property
+    def utilization(self):
+        """
+        Return the percentage of MAC addresses within the block which exist in NetBox.
+        """
+        return self.mac_address_count / self.size * 100
+
+    def _iter_occupied(self, batch_size=5000):
+        """
+        Yield the distinct MAC addresses within the block which exist in NetBox as integers, in ascending order.
+        Addresses are fetched in batches, so that stopping early stops reading from the database.
+        """
+        resume = self.first_address
+        while True:
+            mac_addresses = list(
+                MACAddress.objects.filter(mac_address__gte=resume, mac_address__lte=self.last_address)
+                .order_by('mac_address')
+                .values_list('mac_address', flat=True)
+                .distinct()[:batch_size]
+            )
+            for mac_address in mac_addresses:
+                yield int(mac_address)
+            if len(mac_addresses) < batch_size or mac_addresses[-1] == self.last_address:
+                return
+            resume = EUI(int(mac_addresses[-1]) + 1, version=48)
+
+    def iter_available_mac_addresses(self):
+        """
+        Yield the MAC addresses within the block which do not exist in NetBox, in ascending order.
+        """
+        candidate = int(self.first_address)
+        for occupied in self._iter_occupied():
+            for value in range(candidate, occupied):
+                yield EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
+            candidate = occupied + 1
+        for value in range(candidate, int(self.last_address) + 1):
+            yield EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
+
+    def get_first_available_mac_address(self):
+        """
+        Return the first MAC address within the block which does not exist in NetBox (or None).
+        """
+        return next(self.iter_available_mac_addresses(), None)

+ 30 - 0
netbox/dcim/querysets.py

@@ -0,0 +1,30 @@
+from django.db.models import Count, IntegerField, OuterRef, Subquery, Value
+from django.db.models.functions import Coalesce
+
+from utilities.querysets import RestrictedQuerySet
+
+__all__ = (
+    'MACBlockQuerySet',
+)
+
+
+class MACBlockQuerySet(RestrictedQuerySet):
+
+    def annotate_mac_address_count(self):
+        """
+        Annotate the number of distinct MAC addresses within each MACBlock.
+        """
+        from .models import MACAddress
+
+        mac_addresses = MACAddress.objects.filter(
+            mac_address__gte=OuterRef('prefix'),
+            mac_address__lte=OuterRef('_last_address'),
+        ).order_by().annotate(
+            group=Value(1)
+        ).values('group').annotate(
+            count=Count('mac_address', distinct=True)
+        ).values('count')
+
+        return self.annotate(
+            mac_address_count=Coalesce(Subquery(mac_addresses, output_field=IntegerField()), 0)
+        )

+ 12 - 0
netbox/dcim/search.py

@@ -167,6 +167,18 @@ class MACAddressIndex(SearchIndex):
     display_attrs = ('assigned_object', 'description')
     display_attrs = ('assigned_object', 'description')
 
 
 
 
+@register_search
+class MACBlockIndex(SearchIndex):
+    model = models.MACBlock
+    fields = (
+        ('prefix', 100),
+        ('name', 100),
+        ('description', 500),
+        ('comments', 5000),
+    )
+    display_attrs = ('name', 'description')
+
+
 @register_search
 @register_search
 class InterfaceIndex(SearchIndex):
 class InterfaceIndex(SearchIndex):
     model = models.Interface
     model = models.Interface

+ 51 - 0
netbox/dcim/tables/devices.py

@@ -38,6 +38,7 @@ __all__ = (
     'InventoryItemRoleTable',
     'InventoryItemRoleTable',
     'InventoryItemTable',
     'InventoryItemTable',
     'MACAddressTable',
     'MACAddressTable',
+    'MACBlockTable',
     'ModuleBayTable',
     'ModuleBayTable',
     'PlatformTable',
     'PlatformTable',
     'PowerOutletTable',
     'PowerOutletTable',
@@ -1330,3 +1331,53 @@ class MACAddressTable(PrimaryModelTable):
         default_columns = (
         default_columns = (
             'pk', 'mac_address', 'is_primary', 'assigned_object_parent', 'assigned_object', 'description',
             'pk', 'mac_address', 'is_primary', 'assigned_object_parent', 'assigned_object', 'description',
         )
         )
+
+
+class MACBlockTable(PrimaryModelTable):
+    prefix = tables.Column(
+        verbose_name=_('Prefix'),
+        linkify=True
+    )
+    prefix_length = tables.Column(
+        verbose_name=_('Length')
+    )
+    name = tables.Column(
+        verbose_name=_('Name')
+    )
+    size = tables.Column(
+        verbose_name=_('Size'),
+        orderable=False
+    )
+    mac_address_count = columns.LinkedCountColumn(
+        viewname='dcim:macaddress_list',
+        url_params={'mac_block_id': 'pk'},
+        verbose_name=_('MAC Addresses'),
+        orderable=False
+    )
+    utilization = columns.UtilizationColumn(
+        verbose_name=_('Utilization'),
+        orderable=False
+    )
+    enforce_unique = columns.BooleanColumn(
+        verbose_name=_('Enforce Unique')
+    )
+    tags = columns.TagColumn(
+        url_name='dcim:macblock_list'
+    )
+
+    def render_prefix(self, record):
+        # Include the prefix length, to distinguish blocks which start at the same address
+        return str(record)
+
+    def value_prefix(self, record):
+        return str(record.prefix)
+
+    class Meta(PrimaryModelTable.Meta):
+        model = models.MACBlock
+        fields = (
+            'pk', 'id', 'prefix', 'prefix_length', 'name', 'size', 'mac_address_count', 'utilization',
+            'enforce_unique', 'description', 'comments', 'tags', 'created', 'last_updated',
+        )
+        default_columns = (
+            'pk', 'prefix', 'name', 'mac_address_count', 'utilization', 'enforce_unique', 'description',
+        )

+ 2 - 0
netbox/dcim/tests/query_counts.json

@@ -46,6 +46,8 @@
   "location:list_objects_with_permission": 18,
   "location:list_objects_with_permission": 18,
   "macaddress:api_list_objects": 16,
   "macaddress:api_list_objects": 16,
   "macaddress:list_objects_with_permission": 20,
   "macaddress:list_objects_with_permission": 20,
+  "macblock:api_list_objects": 12,
+  "macblock:list_objects_with_permission": 16,
   "manufacturer:api_list_objects": 12,
   "manufacturer:api_list_objects": 12,
   "manufacturer:list_objects_with_permission": 16,
   "manufacturer:list_objects_with_permission": 16,
   "module:api_list_objects": 19,
   "module:api_list_objects": 19,

+ 127 - 0
netbox/dcim/tests/test_api.py

@@ -32,6 +32,7 @@ from extras.models import ConfigContext, ConfigTemplate, ExportTemplate, Tag
 from ipam.choices import FHRPGroupProtocolChoices, VLANQinQRoleChoices
 from ipam.choices import FHRPGroupProtocolChoices, VLANQinQRoleChoices
 from ipam.models import ASN, RIR, VLAN, VRF, FHRPGroup, FHRPGroupAssignment, IPAddress
 from ipam.models import ASN, RIR, VLAN, VRF, FHRPGroup, FHRPGroupAssignment, IPAddress
 from netbox.api.serializers import GenericObjectSerializer
 from netbox.api.serializers import GenericObjectSerializer
+from netbox.config import get_config
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from users.constants import TOKEN_PREFIX
 from users.constants import TOKEN_PREFIX
 from users.models import ObjectPermission, Token, User
 from users.models import ObjectPermission, Token, User
@@ -7400,3 +7401,129 @@ class MACAddressTestCase(APIViewTestCases.APIViewTestCase):
         url = reverse('dcim-api:macaddress-detail', kwargs={'pk': non_primary_mac.pk})
         url = reverse('dcim-api:macaddress-detail', kwargs={'pk': non_primary_mac.pk})
         response = self.client.get(url, **self.header)
         response = self.client.get(url, **self.header)
         self.assertFalse(response.data['is_primary'])
         self.assertFalse(response.data['is_primary'])
+
+
+class MACBlockTestCase(APIViewTestCases.APIViewTestCase):
+    model = MACBlock
+    graphql_base_name = 'mac_block'
+    brief_fields = ['description', 'display', 'id', 'name', 'prefix', 'prefix_length', 'url']
+    bulk_update_data = {
+        'description': 'New description',
+    }
+    bulk_update_invalid_data = {
+        'prefix_length': 99,
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        MACBlock.objects.create(prefix='BC:24:11:00:00:00', prefix_length=24, name='Block 1')
+        MACBlock.objects.create(prefix='BC:24:12:00:00:00', prefix_length=24, name='Block 2')
+        MACBlock.objects.create(prefix='BC:24:13:00:00:00', prefix_length=24, name='Block 3')
+
+        cls.create_data = [
+            {
+                'prefix': 'BC:24:14:00:00:00',
+                'prefix_length': 24,
+                'name': 'Block 4',
+            },
+            {
+                'prefix': 'BC:24:14:50:00:00',
+                'prefix_length': 28,
+                'enforce_unique': True,
+            },
+            {
+                'prefix': 'BC:24:15:00:00:00',
+            },
+        ]
+
+    def test_read_only_fields(self):
+        self.add_permissions('dcim.view_macblock')
+        mac_block = MACBlock.objects.create(prefix='BC:24:16:00:00:00', prefix_length=46)
+        MACAddress.objects.bulk_create((
+            MACAddress(mac_address='BC:24:16:00:00:01'),
+            MACAddress(mac_address='BC:24:16:00:00:01'),
+            MACAddress(mac_address='BC:24:16:00:00:02'),
+        ))
+        response = self.client.get(self._get_detail_url(mac_block), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['last_address'], 'BC:24:16:00:00:03')
+        self.assertEqual(response.data['size'], 4)
+        self.assertEqual(response.data['mac_address_count'], 2)
+        self.assertEqual(response.data['utilization'], 50)
+
+    def test_create_normalizes_prefix(self):
+        self.add_permissions('dcim.add_macblock')
+        data = {'prefix': 'BC:24:16:AA:BB:CC', 'prefix_length': 24}
+        response = self.client.post(self._get_list_url(), data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(response.data['prefix'], 'BC:24:16:00:00:00')
+
+        # A block whose host bits differ from an existing block is a duplicate
+        data = {'prefix': 'BC:24:11:AA:BB:CC', 'prefix_length': 24}
+        response = self.client.post(self._get_list_url(), data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+    def test_list_available_mac_addresses(self):
+        mac_block = MACBlock.objects.create(prefix='BC:24:16:00:00:00', prefix_length=45)
+        MACAddress.objects.create(mac_address='BC:24:16:00:00:01')
+        url = reverse('dcim-api:macblock-available-mac-addresses', kwargs={'pk': mac_block.pk})
+        self.add_permissions('dcim.view_macblock', 'dcim.view_macaddress')
+
+        response = self.client.get(url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(
+            [mac['mac_address'] for mac in response.data],
+            ['BC:24:16:00:00:00', 'BC:24:16:00:00:02', 'BC:24:16:00:00:03', 'BC:24:16:00:00:04',
+             'BC:24:16:00:00:05', 'BC:24:16:00:00:06', 'BC:24:16:00:00:07']
+        )
+
+        # The number of results is limited
+        response = self.client.get(url, {'limit': 2}, **self.header)
+        self.assertEqual(len(response.data), 2)
+
+        # An invalid limit falls back to the default
+        response = self.client.get(url, {'limit': -1}, **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(len(response.data), 7)
+
+        # A large block returns a single page of results
+        mac_block = MACBlock.objects.get(name='Block 1')
+        url = reverse('dcim-api:macblock-available-mac-addresses', kwargs={'pk': mac_block.pk})
+        response = self.client.get(url, **self.header)
+        self.assertEqual(len(response.data), get_config().PAGINATE_COUNT)
+
+    def test_create_single_available_mac_address(self):
+        mac_block = MACBlock.objects.create(prefix='BC:24:16:00:00:00', prefix_length=47)
+        url = reverse('dcim-api:macblock-available-mac-addresses', kwargs={'pk': mac_block.pk})
+        self.add_permissions('dcim.view_macblock', 'dcim.add_macaddress')
+
+        # Create both available MAC addresses with individual requests
+        for i in range(2):
+            data = {'description': f'MAC address {i}'}
+            response = self.client.post(url, data, format='json', **self.header)
+            self.assertHttpStatus(response, status.HTTP_201_CREATED)
+            self.assertEqual(response.data['mac_address'], f'BC:24:16:00:00:0{i}')
+            self.assertEqual(response.data['description'], data['description'])
+
+        # Try to create one more MAC address
+        response = self.client.post(url, {}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
+        self.assertIn('detail', response.data)
+
+    def test_create_multiple_available_mac_addresses(self):
+        mac_block = MACBlock.objects.create(prefix='BC:24:16:00:00:00', prefix_length=46)
+        url = reverse('dcim-api:macblock-available-mac-addresses', kwargs={'pk': mac_block.pk})
+        self.add_permissions('dcim.view_macblock', 'dcim.add_macaddress')
+
+        # Try to create five MAC addresses (only four are available)
+        data = [{'description': f'MAC address {i}'} for i in range(5)]
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
+
+        # Create all four available MAC addresses in a single request
+        response = self.client.post(url, data[:4], format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(
+            [mac['mac_address'] for mac in response.data],
+            ['BC:24:16:00:00:00', 'BC:24:16:00:00:01', 'BC:24:16:00:00:02', 'BC:24:16:00:00:03']
+        )

+ 67 - 0
netbox/dcim/tests/test_filtersets.py

@@ -9170,3 +9170,70 @@ class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         params = {'primary': False}
         params = {'primary': False}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+    def test_mac_block(self):
+        mac_blocks = (
+            MACBlock.objects.create(prefix='00:00:00:01:00:00', prefix_length=32),
+            MACBlock.objects.create(prefix='00:00:00:03:00:00', prefix_length=32),
+        )
+        params = {'mac_block_id': [mac_blocks[0].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'mac_block_id': [mac_blocks[0].pk, mac_blocks[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+
+class MACBlockTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
+    queryset = MACBlock.objects.all()
+    filterset = MACBlockFilterSet
+    ignore_fields = ('_last_address',)
+
+    @classmethod
+    def setUpTestData(cls):
+        MACBlock.objects.create(
+            prefix='BC:24:11:00:00:00', prefix_length=24, name='Block 1', enforce_unique=True,
+            description='foobar1'
+        )
+        MACBlock.objects.create(
+            prefix='BC:24:11:50:00:00', prefix_length=28, name='Block 2', description='foobar2'
+        )
+        MACBlock.objects.create(
+            prefix='BC:24:12:00:00:00', prefix_length=36, name='Block 3', description='foobar3'
+        )
+
+    def test_q(self):
+        params = {'q': 'Block 1'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'q': 'BC:24:11'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_prefix(self):
+        params = {'prefix': ['BC:24:11:00:00:00', 'bc-24-12-00-00-00']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_prefix_length(self):
+        params = {'prefix_length': [24, 28]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_name(self):
+        params = {'name': ['Block 1', 'Block 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_enforce_unique(self):
+        params = {'enforce_unique': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'enforce_unique': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_contains(self):
+        params = {'contains': 'BC:24:11:51:00:00'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'contains': 'BC:24:11:61:00:00'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'contains': 'BC:24:13:00:00:00'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+        params = {'contains': 'invalid'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)

+ 148 - 0
netbox/dcim/tests/test_models.py

@@ -11,6 +11,7 @@ from django.db.models.signals import post_save
 from django.test import RequestFactory, TestCase, override_settings, tag
 from django.test import RequestFactory, TestCase, override_settings, tag
 from django.test.utils import CaptureQueriesContext
 from django.test.utils import CaptureQueriesContext
 from django.utils.text import slugify
 from django.utils.text import slugify
+from netaddr import EUI
 
 
 from circuits.models import *
 from circuits.models import *
 from core.choices import ObjectChangeActionChoices
 from core.choices import ObjectChangeActionChoices
@@ -129,6 +130,153 @@ class MACAddressTestCase(TestCase):
         new_iface.full_clean()  # must not raise
         new_iface.full_clean()  # must not raise
 
 
 
 
+class MACBlockTestCase(TestCase):
+
+    def test_prefix_normalized(self):
+        mac_block = MACBlock.objects.create(prefix='BC:24:11:12:34:56', prefix_length=24)
+        mac_block.refresh_from_db()
+        self.assertEqual(str(mac_block.prefix), 'BC:24:11:00:00:00')
+        self.assertEqual(str(mac_block.last_address), 'BC:24:11:FF:FF:FF')
+        self.assertEqual(str(mac_block._last_address), 'BC:24:11:FF:FF:FF')
+        self.assertEqual(str(mac_block), 'BC:24:11:00:00:00/24')
+
+    def test_invalid_prefix(self):
+        for prefix, prefix_length in (('invalid', 24), ('BC:24:11:00:00:00', 49), ('BC:24:11:00:00:00', 'x')):
+            mac_block = MACBlock(prefix=prefix, prefix_length=prefix_length, enforce_unique=True)
+            self.assertIsNone(mac_block.first_address)
+            with self.assertRaises(ValidationError):
+                mac_block.full_clean()
+
+    def test_duplicate_prefix_with_host_bits(self):
+        MACBlock.objects.create(prefix='BC:24:11:00:00:00', prefix_length=24)
+        mac_block = MACBlock(prefix='BC:24:11:AA:BB:CC', prefix_length=24)
+        with self.assertRaises(ValidationError):
+            mac_block.full_clean()
+
+    def test_save_invalid_prefix(self):
+        with self.assertRaisesMessage(ValueError, 'Invalid MAC address block'):
+            MACBlock.objects.create(prefix='BC:24:11:00:00:00', prefix_length=0)
+
+    def test_size(self):
+        self.assertEqual(MACBlock(prefix='BC:24:11:00:00:00', prefix_length=24).size, 2 ** 24)
+        self.assertEqual(MACBlock(prefix='BC:24:11:50:00:00', prefix_length=28).size, 2 ** 20)
+        self.assertEqual(MACBlock(prefix='BC:24:11:00:00:01', prefix_length=48).size, 1)
+
+    def test_duplicate_prefix(self):
+        MACBlock.objects.create(prefix='BC:24:11:00:00:00', prefix_length=24)
+        mac_block = MACBlock(prefix='BC:24:11:00:00:00', prefix_length=24)
+        with self.assertRaises(ValidationError):
+            mac_block.full_clean()
+        # A block of a different length may share the same first address
+        MACBlock(prefix='BC:24:11:00:00:00', prefix_length=28).full_clean()
+
+    def test_get_mac_addresses(self):
+        mac_block = MACBlock.objects.create(prefix='BC:24:11:50:00:00', prefix_length=28)
+        MACAddress.objects.bulk_create((
+            MACAddress(mac_address='BC:24:11:4F:FF:FF'),
+            MACAddress(mac_address='BC:24:11:50:00:00'),
+            MACAddress(mac_address='BC:24:11:5F:FF:FF'),
+            MACAddress(mac_address='BC:24:11:60:00:00'),
+        ))
+        self.assertEqual(
+            sorted(str(mac.mac_address) for mac in mac_block.get_mac_addresses()),
+            ['BC:24:11:50:00:00', 'BC:24:11:5F:FF:FF']
+        )
+
+    def test_get_child_blocks(self):
+        mac_blocks = (
+            MACBlock.objects.create(prefix='BC:24:11:00:00:00', prefix_length=24),
+            MACBlock.objects.create(prefix='BC:24:11:00:00:00', prefix_length=28),
+            MACBlock.objects.create(prefix='BC:24:11:F0:00:00', prefix_length=28),
+            MACBlock.objects.create(prefix='BC:24:12:00:00:00', prefix_length=28),
+        )
+        self.assertEqual(list(mac_blocks[0].get_child_blocks()), [mac_blocks[1], mac_blocks[2]])
+        self.assertEqual(list(mac_blocks[1].get_child_blocks()), [])
+
+    def test_mac_address_mac_block(self):
+        mac_blocks = (
+            MACBlock.objects.create(prefix='BC:24:11:00:00:00', prefix_length=24),
+            MACBlock.objects.create(prefix='BC:24:11:50:00:00', prefix_length=28),
+        )
+        self.assertEqual(MACAddress(mac_address='BC:24:11:51:00:00').mac_block, mac_blocks[1])
+        self.assertEqual(MACAddress(mac_address='BC:24:11:61:00:00').mac_block, mac_blocks[0])
+        self.assertIsNone(MACAddress(mac_address='BC:24:12:00:00:00').mac_block)
+
+    def test_available_mac_addresses(self):
+        mac_block = MACBlock.objects.create(prefix='BC:24:11:00:00:00', prefix_length=45)
+        self.assertEqual(str(mac_block.get_first_available_mac_address()), 'BC:24:11:00:00:00')
+
+        MACAddress.objects.bulk_create((
+            MACAddress(mac_address='BC:24:11:00:00:00'),
+            MACAddress(mac_address='BC:24:11:00:00:01'),
+            MACAddress(mac_address='BC:24:11:00:00:01'),  # Duplicate
+            MACAddress(mac_address='BC:24:11:00:00:03'),
+            MACAddress(mac_address='BC:24:11:00:00:07'),
+            MACAddress(mac_address='BC:24:11:00:00:08'),  # Outside the block
+        ))
+        self.assertEqual(str(mac_block.get_first_available_mac_address()), 'BC:24:11:00:00:02')
+        self.assertEqual(
+            [str(mac) for mac in mac_block.iter_available_mac_addresses()],
+            ['BC:24:11:00:00:02', 'BC:24:11:00:00:04', 'BC:24:11:00:00:05', 'BC:24:11:00:00:06']
+        )
+
+        # Fetching occupied addresses in batches finds the same gaps
+        self.assertEqual(
+            list(mac_block._iter_occupied(batch_size=2)),
+            [int(EUI(mac)) for mac in ('BC:24:11:00:00:00', 'BC:24:11:00:00:01', 'BC:24:11:00:00:03',
+                                       'BC:24:11:00:00:07')]
+        )
+
+    def test_no_available_mac_addresses(self):
+        mac_block = MACBlock.objects.create(prefix='BC:24:11:00:00:00', prefix_length=47)
+        MACAddress.objects.bulk_create((
+            MACAddress(mac_address='BC:24:11:00:00:00'),
+            MACAddress(mac_address='BC:24:11:00:00:01'),
+        ))
+        self.assertIsNone(mac_block.get_first_available_mac_address())
+        self.assertEqual(mac_block.utilization, 100)
+
+    def test_utilization(self):
+        mac_block = MACBlock.objects.create(prefix='BC:24:11:00:00:00', prefix_length=46)
+        MACAddress.objects.bulk_create((
+            MACAddress(mac_address='BC:24:11:00:00:00'),
+            MACAddress(mac_address='BC:24:11:00:00:00'),  # Duplicate
+            MACAddress(mac_address='BC:24:11:00:00:02'),
+        ))
+        self.assertEqual(mac_block.utilization, 50)
+        annotated = MACBlock.objects.annotate_mac_address_count().get(pk=mac_block.pk)
+        self.assertEqual(annotated.mac_address_count, 2)
+        self.assertEqual(annotated.utilization, 50)
+
+    def test_enforce_unique(self):
+        MACBlock.objects.create(prefix='BC:24:11:00:00:00', prefix_length=24, enforce_unique=True)
+        MACAddress.objects.create(mac_address='BC:24:11:00:00:01')
+        MACAddress.objects.create(mac_address='BC:24:12:00:00:01')
+
+        # A duplicate within the block is rejected
+        with self.assertRaisesMessage(ValidationError, 'BC:24:11:00:00:00/24'):
+            MACAddress(mac_address='BC:24:11:00:00:01').full_clean()
+
+        # A duplicate Outside the block is permitted
+        MACAddress(mac_address='BC:24:12:00:00:01').full_clean()
+
+        # Saving an existing MAC address does not count it as its own duplicate
+        MACAddress.objects.get(mac_address='BC:24:11:00:00:01').full_clean()
+
+    def test_enforce_unique_with_duplicates(self):
+        MACAddress.objects.bulk_create((
+            MACAddress(mac_address='BC:24:11:00:00:01'),
+            MACAddress(mac_address='BC:24:11:00:00:01'),
+        ))
+        mac_block = MACBlock(prefix='BC:24:11:00:00:00', prefix_length=24, enforce_unique=True)
+        with self.assertRaisesMessage(ValidationError, 'BC:24:11:00:00:01'):
+            mac_block.full_clean()
+
+        # Uniqueness is not enforced by default
+        mac_block.enforce_unique = False
+        mac_block.full_clean()
+
+
 class LocationTestCase(TestCase):
 class LocationTestCase(TestCase):
 
 
     def test_change_location_site(self):
     def test_change_location_site(self):

+ 4 - 0
netbox/dcim/tests/test_tables.py

@@ -334,3 +334,7 @@ class VirtualDeviceContextTableTestCase(TableTestCases.StandardTableTestCase):
 
 
 class MACAddressTableTestCase(TableTestCases.StandardTableTestCase):
 class MACAddressTableTestCase(TableTestCases.StandardTableTestCase):
     table = MACAddressTable
     table = MACAddressTable
+
+
+class MACBlockTableTestCase(TableTestCases.StandardTableTestCase):
+    table = MACBlockTable

+ 81 - 0
netbox/dcim/tests/test_views.py

@@ -6971,3 +6971,84 @@ class MACAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)
         self.assertIn(b'quick-add-object', response.content)
         self.assertIn(b'quick-add-object', response.content)
         self.assertEqual(initial_count + 1, self._get_queryset().count())
         self.assertEqual(initial_count + 1, self._get_queryset().count())
+
+    def test_mac_blocks_panel(self):
+        """
+        The MAC address page lists the MAC blocks containing it, only if the user can view MAC blocks.
+        """
+        MACBlock.objects.create(prefix='BC:24:11:00:00:00', prefix_length=24, name='Block 1')
+        mac_address = MACAddress.objects.create(mac_address='BC:24:11:00:00:01')
+        panel_url = 'contains=BC%3A24%3A11%3A00%3A00%3A01'
+
+        self.add_permissions('dcim.view_macaddress')
+        response = self.client.get(mac_address.get_absolute_url())
+        self.assertHttpStatus(response, 200)
+        self.assertNotContains(response, panel_url)
+
+        self.add_permissions('dcim.view_macblock')
+        response = self.client.get(mac_address.get_absolute_url())
+        self.assertHttpStatus(response, 200)
+        self.assertContains(response, panel_url)
+
+
+class MACBlockTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = MACBlock
+
+    @classmethod
+    def setUpTestData(cls):
+        mac_blocks = (
+            MACBlock.objects.create(prefix='BC:24:11:00:00:00', prefix_length=24, name='Block 1'),
+            MACBlock.objects.create(prefix='BC:24:12:00:00:00', prefix_length=24, name='Block 2'),
+            MACBlock.objects.create(prefix='BC:24:13:00:00:00', prefix_length=24, name='Block 3'),
+        )
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'prefix': EUI('BC:24:14:50:00:00'),
+            'prefix_length': 28,
+            'name': 'Block X',
+            'enforce_unique': True,
+            'description': 'New MAC block',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "prefix,prefix_length,name",
+            "BC:24:15:00:00:00,24,Block 4",
+            "BC:24:16:00:00:00,28,Block 5",
+            "BC:24:17:00:00:00,36,Block 6",
+        )
+
+        cls.csv_update_data = (
+            "id,name,enforce_unique",
+            f"{mac_blocks[0].pk},Block 7,true",
+            f"{mac_blocks[1].pk},Block 8,true",
+            f"{mac_blocks[2].pk},Block 9,false",
+        )
+
+        cls.bulk_edit_data = {
+            'name': 'New name',
+            'enforce_unique': True,
+            'description': 'New description',
+        }
+
+    def test_create_duplicate_prefix_with_host_bits(self):
+        self.add_permissions('dcim.add_macblock')
+        response = self.client.post(
+            reverse('dcim:macblock_add'), {'prefix': 'BC:24:11:AA:BB:CC', 'prefix_length': 24}
+        )
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(MACBlock.objects.filter(prefix='BC:24:11:00:00:00', prefix_length=24).count(), 1)
+
+    def test_mac_addresses_panel(self):
+        """
+        The MAC block page offers to add the first available MAC address within the block.
+        """
+        self.add_permissions('dcim.view_macblock', 'dcim.view_macaddress', 'dcim.add_macaddress')
+        mac_block = MACBlock.objects.get(name='Block 1')
+        MACAddress.objects.create(mac_address='BC:24:11:00:00:00')
+
+        response = self.client.get(mac_block.get_absolute_url())
+        self.assertHttpStatus(response, 200)
+        self.assertContains(response, 'mac_address=BC%3A24%3A11%3A00%3A00%3A01')

+ 11 - 0
netbox/dcim/ui/panels.py

@@ -478,6 +478,17 @@ class MACAddressPanel(panels.ObjectAttributesPanel):
     is_primary = attrs.BooleanAttr('is_primary', label=_('Primary for interface'))
     is_primary = attrs.BooleanAttr('is_primary', label=_('Primary for interface'))
 
 
 
 
+class MACBlockPanel(panels.ObjectAttributesPanel):
+    prefix = attrs.TextAttr('prefix', label=_('Prefix'), style='font-monospace', copy_button=True)
+    prefix_length = attrs.NumericAttr('prefix_length', label=_('Prefix length'))
+    name = attrs.TextAttr('name')
+    last_address = attrs.TextAttr('last_address', label=_('Last address'), style='font-monospace')
+    size = attrs.NumericAttr('size')
+    utilization = attrs.UtilizationAttr('utilization')
+    enforce_unique = attrs.BooleanAttr('enforce_unique', label=_('Enforce unique'))
+    description = attrs.TextAttr('description')
+
+
 class ConnectionPanel(panels.ObjectPanel):
 class ConnectionPanel(panels.ObjectPanel):
     """
     """
     A panel which displays connection information for a cabled object.
     A panel which displays connection information for a cabled object.

+ 2 - 0
netbox/dcim/urls.py

@@ -208,5 +208,7 @@ urlpatterns = [
 
 
     path('mac-addresses/', include(get_model_urls('dcim', 'macaddress', detail=False))),
     path('mac-addresses/', include(get_model_urls('dcim', 'macaddress', detail=False))),
     path('mac-addresses/<int:pk>/', include(get_model_urls('dcim', 'macaddress'))),
     path('mac-addresses/<int:pk>/', include(get_model_urls('dcim', 'macaddress'))),
+    path('mac-blocks/', include(get_model_urls('dcim', 'macblock', detail=False))),
+    path('mac-blocks/<int:pk>/', include(get_model_urls('dcim', 'macblock'))),
 
 
 ]
 ]

+ 81 - 0
netbox/dcim/views.py

@@ -5870,6 +5870,11 @@ class MACAddressView(generic.ObjectView):
         ],
         ],
         right_panels=[
         right_panels=[
             CommentsPanel(),
             CommentsPanel(),
+            ObjectsTablePanel(
+                model='dcim.MACBlock',
+                title=_('MAC Address Blocks'),
+                filters={'contains': lambda ctx: str(ctx['object'].mac_address)},
+            ),
         ],
         ],
     )
     )
 
 
@@ -5947,3 +5952,79 @@ class MACAddressBulkDeleteView(generic.BulkDeleteView):
     queryset = MACAddress.objects.all()
     queryset = MACAddress.objects.all()
     filterset = filtersets.MACAddressFilterSet
     filterset = filtersets.MACAddressFilterSet
     table = tables.MACAddressTable
     table = tables.MACAddressTable
+
+
+#
+# MAC address blocks
+#
+
+@register_model_view(MACBlock, 'list', path='', detail=False)
+class MACBlockListView(generic.ObjectListView):
+    queryset = MACBlock.objects.annotate_mac_address_count()
+    filterset = filtersets.MACBlockFilterSet
+    filterset_form = forms.MACBlockFilterForm
+    table = tables.MACBlockTable
+    actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
+
+
+@register_model_view(MACBlock)
+class MACBlockView(generic.ObjectView):
+    queryset = MACBlock.objects.all()
+    template_name = 'generic/object.html'
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.MACBlockPanel(),
+            TagsPanel(),
+            CustomFieldsPanel(),
+        ],
+        right_panels=[
+            CommentsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                model='dcim.MACAddress',
+                title=_('MAC Addresses'),
+                filters={'mac_block_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject(
+                        'dcim.MACAddress',
+                        label=_('Add Next Available'),
+                        url_params={'mac_address': lambda ctx: ctx['object'].get_first_available_mac_address()},
+                    ),
+                ],
+            ),
+        ],
+    )
+
+
+@register_model_view(MACBlock, 'add', detail=False)
+@register_model_view(MACBlock, 'edit')
+class MACBlockEditView(generic.ObjectEditView):
+    queryset = MACBlock.objects.all()
+    form = forms.MACBlockForm
+
+
+@register_model_view(MACBlock, 'delete')
+class MACBlockDeleteView(generic.ObjectDeleteView):
+    queryset = MACBlock.objects.all()
+
+
+@register_model_view(MACBlock, 'bulk_import', path='import', detail=False)
+class MACBlockBulkImportView(generic.BulkImportView):
+    queryset = MACBlock.objects.all()
+    model_form = forms.MACBlockImportForm
+
+
+@register_model_view(MACBlock, 'bulk_edit', path='edit', detail=False)
+class MACBlockBulkEditView(generic.BulkEditView):
+    queryset = MACBlock.objects.annotate_mac_address_count()
+    filterset = filtersets.MACBlockFilterSet
+    table = tables.MACBlockTable
+    form = forms.MACBlockBulkEditForm
+
+
+@register_model_view(MACBlock, 'bulk_delete', path='delete', detail=False)
+class MACBlockBulkDeleteView(generic.BulkDeleteView):
+    queryset = MACBlock.objects.annotate_mac_address_count()
+    filterset = filtersets.MACBlockFilterSet
+    table = tables.MACBlockTable

+ 1 - 0
netbox/netbox/constants.py

@@ -28,6 +28,7 @@ ADVISORY_LOCK_KEYS = {
     'available-ips': 100200,
     'available-ips': 100200,
     'available-vlans': 100300,
     'available-vlans': 100300,
     'available-asns': 100400,
     'available-asns': 100400,
+    'available-mac-addresses': 100500,
 
 
     # Jobs
     # Jobs
     'job-schedules': 110100,
     'job-schedules': 110100,

+ 1 - 0
netbox/netbox/navigation/menu.py

@@ -121,6 +121,7 @@ DEVICES_MENU = Menu(
             label=_('Addressing'),
             label=_('Addressing'),
             items=(
             items=(
                 get_model_item('dcim', 'macaddress', _('MAC Addresses')),
                 get_model_item('dcim', 'macaddress', _('MAC Addresses')),
+                get_model_item('dcim', 'macblock', _('MAC Address Blocks')),
             ),
             ),
         ),
         ),
     ),
     ),