Quellcode durchsuchen

Closes: #9047 - Add Provider Accounts (#12057)

* #9047 - ProviderAccount

* #9047 - Move to new selector types

* #9047 - Re-introduce provider FK to Circuit model

* #9047 - Fix broken tests

* Misc cleanup

* Revert errant change

* Fix tests

* Update circuit filter form

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
Daniel Sheppard vor 2 Jahren
Ursprung
Commit
9d709c84e7
35 geänderte Dateien mit 792 neuen und 98 gelöschten Zeilen
  1. 1 0
      docs/development/models.md
  2. 1 1
      docs/development/search.md
  3. 4 2
      docs/features/circuits.md
  4. 1 0
      docs/features/contacts.md
  5. 1 1
      docs/getting-started/planning.md
  6. 4 0
      docs/models/circuits/circuit.md
  7. 0 4
      docs/models/circuits/provider.md
  8. 17 0
      docs/models/circuits/provideraccount.md
  9. 13 0
      netbox/circuits/api/nested_serializers.py
  10. 27 4
      netbox/circuits/api/serializers.py
  11. 2 3
      netbox/circuits/api/urls.py
  12. 11 1
      netbox/circuits/api/views.py
  13. 35 2
      netbox/circuits/filtersets.py
  14. 33 8
      netbox/circuits/forms/bulk_edit.py
  15. 23 3
      netbox/circuits/forms/bulk_import.py
  16. 27 1
      netbox/circuits/forms/filtersets.py
  17. 30 18
      netbox/circuits/forms/model_forms.py
  18. 3 0
      netbox/circuits/graphql/schema.py
  19. 9 0
      netbox/circuits/graphql/types.py
  20. 91 0
      netbox/circuits/migrations/0042_provideraccount.py
  21. 22 4
      netbox/circuits/models/circuits.py
  22. 51 8
      netbox/circuits/models/providers.py
  23. 9 1
      netbox/circuits/search.py
  24. 8 3
      netbox/circuits/tables/circuits.py
  25. 42 3
      netbox/circuits/tables/providers.py
  26. 56 4
      netbox/circuits/tests/test_api.py
  27. 67 17
      netbox/circuits/tests/test_filtersets.py
  28. 66 9
      netbox/circuits/tests/test_views.py
  29. 8 0
      netbox/circuits/urls.py
  30. 62 0
      netbox/circuits/views.py
  31. 1 0
      netbox/netbox/navigation/menu.py
  32. 4 0
      netbox/templates/circuits/circuit.html
  33. 0 1
      netbox/templates/circuits/circuittermination_edit.html
  34. 8 0
      netbox/templates/circuits/provider.html
  35. 55 0
      netbox/templates/circuits/provideraccount.html

+ 1 - 0
docs/development/models.md

@@ -32,6 +32,7 @@ These are considered the "core" application models which are used to model netwo
 
 * [circuits.Circuit](../models/circuits/circuit.md)
 * [circuits.Provider](../models/circuits/provider.md)
+* [circuits.ProviderAccount](../models/circuits/provideracount.md)
 * [circuits.ProviderNetwork](../models/circuits/providernetwork.md)
 * [core.DataSource](../models/core/datasource.md)
 * [dcim.Cable](../models/dcim/cable.md)

+ 1 - 1
docs/development/search.md

@@ -29,7 +29,7 @@ A SearchIndex subclass defines both its model and a list of two-tuples specifyin
 | 60     | Unique serialized attribute (per related object) | Device.serial                                      |
 | 100    | Primary human identifier                         | Device.name, Circuit.cid, Cable.label              |
 | 110    | Slug                                             | Site.slug                                          |
-| 200    | Secondary identifier                             | Provider.account, DeviceType.part_number           |
+| 200    | Secondary identifier                             | ProviderAccount.account, DeviceType.part_number    |
 | 300    | Highly unique descriptive attribute              | CircuitTermination.xconnect_id, IPAddress.dns_name |
 | 500    | Description                                      | Site.description                                   |
 | 1000   | Custom field default                             | -                                                  |

+ 4 - 2
docs/features/circuits.md

@@ -5,13 +5,15 @@ NetBox is ideal for managing your network's transit and peering providers and ci
 ```mermaid
 flowchart TD
     ASN --> Provider
-    Provider --> ProviderNetwork & Circuit
+    Provider --> ProviderNetwork & ProviderAccount & Circuit
+    ProviderAccount --> Circuit
     CircuitType --> Circuit
 
 click ASN "../../models/circuits/asn/"
 click Circuit "../../models/circuits/circuit/"
 click CircuitType "../../models/circuits/circuittype/"
 click Provider "../../models/circuits/provider/"
+click ProviderAccount "../../models/circuits/provideraccount/"
 click ProviderNetwork "../../models/circuits/providernetwork/"
 ```
 
@@ -25,7 +27,7 @@ Sometimes you'll need to model provider networks into which you don't have full
 
 A circuit is a physical connection between two points, which is installed and maintained by an external provider. For example, an Internet connection delivered as a fiber optic cable would be modeled as a circuit in NetBox.
 
-Each circuit is associated with a provider and assigned a circuit ID, which must be unique to that provider. A circuit is also assigned a user-defined type, operational status, and various other operating characteristics.
+Each circuit is associated with a provider and assigned a circuit ID, which must be unique to that provider. A circuit is also assigned a user-defined type, operational status, and various other operating characteristics. Provider accounts can also be employed to further categorize circuits belonging to a common provider: These may represent different business units or technologies.
 
 Each circuit may have up to two terminations (A and Z) defined. Each termination can be associated with a particular site or provider network. In the case of the former, a cable can be connected between the circuit termination and a device component to map its physical connectivity.
 

+ 1 - 0
docs/features/contacts.md

@@ -31,6 +31,7 @@ The following models support the assignment of contacts:
 
 * circuits.Circuit
 * circuits.Provider
+* circuits.ProviderAccount
 * dcim.Device
 * dcim.Location
 * dcim.Manufacturer

+ 1 - 1
docs/getting-started/planning.md

@@ -56,7 +56,7 @@ Below is the (rough) recommended order in which NetBox objects should be created
 4. Manufacturers, device types, and module types
 5. Platforms and device roles
 6. Devices and modules
-7. Providers and provider networks
+7. Providers, provider accounts, and provider networks
 8. Circuit types and circuits
 9. Wireless LAN groups and wireless LANs
 10. Route targets and VRFs

+ 4 - 0
docs/models/circuits/circuit.md

@@ -8,6 +8,10 @@ A circuit represents a physical point-to-point data connection, typically used t
 
 The [provider](./provider.md) to which this circuit belongs.
 
+### Provider Account
+
+Circuits may optionally be assigned to a specific [provider account](./provideraccount.md).
+
 ### Circuit ID
 
 An identifier for this circuit. This must be unique to the assigned provider. (Circuits assigned to different providers may have the same circuit ID.)

+ 0 - 4
docs/models/circuits/provider.md

@@ -16,10 +16,6 @@ A unique URL-friendly identifier. (This value can be used for filtering.)
 
 The [AS numbers](../ipam/asn.md) assigned to this provider (optional).
 
-### Account Number
-
-The administrative account identifier tied to this provider for your organization.
-
 ### Portal URL
 
 The URL for the provider's customer service portal.

+ 17 - 0
docs/models/circuits/provideraccount.md

@@ -0,0 +1,17 @@
+# Provider Accounts
+
+This model can be used to represent individual accounts associated with a provider.
+
+## Fields
+
+### Provider
+
+The [provider](./provider.md) the account belongs to.
+
+### Name
+
+A human-friendly name, unique to the provider.
+
+### Account Number
+
+The administrative account identifier tied to this provider for your organization.

+ 13 - 0
netbox/circuits/api/nested_serializers.py

@@ -9,6 +9,7 @@ __all__ = [
     'NestedCircuitTypeSerializer',
     'NestedProviderNetworkSerializer',
     'NestedProviderSerializer',
+    'NestedProviderAccountSerializer',
 ]
 
 
@@ -37,6 +38,18 @@ class NestedProviderSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name', 'slug', 'circuit_count']
 
 
+#
+# Provider Accounts
+#
+
+class NestedProviderAccountSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provideraccount-detail')
+
+    class Meta:
+        model = ProviderAccount
+        fields = ['id', 'url', 'display', 'name', 'account']
+
+
 #
 # Circuits
 #

+ 27 - 4
netbox/circuits/api/serializers.py

@@ -18,6 +18,12 @@ from .nested_serializers import *
 
 class ProviderSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
+    accounts = SerializedPKRelatedField(
+        queryset=ProviderAccount.objects.all(),
+        serializer=NestedProviderAccountSerializer,
+        required=False,
+        many=True
+    )
     asns = SerializedPKRelatedField(
         queryset=ASN.objects.all(),
         serializer=NestedASNSerializer,
@@ -31,11 +37,27 @@ class ProviderSerializer(NetBoxModelSerializer):
     class Meta:
         model = Provider
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'account', 'description', 'comments', 'asns', 'tags',
+            'id', 'url', 'display', 'name', 'slug', 'accounts', 'description', 'comments', 'asns', 'tags',
             'custom_fields', 'created', 'last_updated', 'circuit_count',
         ]
 
 
+#
+# Provider Accounts
+#
+
+class ProviderAccountSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provideraccount-detail')
+    provider = NestedProviderSerializer()
+
+    class Meta:
+        model = ProviderAccount
+        fields = [
+            'id', 'url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags', 'custom_fields',
+            'created', 'last_updated',
+        ]
+
+
 #
 # Provider networks
 #
@@ -84,6 +106,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
 class CircuitSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
     provider = NestedProviderSerializer()
+    provider_account = NestedProviderAccountSerializer()
     status = ChoiceField(choices=CircuitStatusChoices, required=False)
     type = NestedCircuitTypeSerializer()
     tenant = NestedTenantSerializer(required=False, allow_null=True)
@@ -93,9 +116,9 @@ class CircuitSerializer(NetBoxModelSerializer):
     class Meta:
         model = Circuit
         fields = [
-            'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date',
-            'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields',
-            'created', 'last_updated',
+            'id', 'url', 'display', 'cid', 'provider', 'provider_account', 'type', 'status', 'tenant', 'install_date',
+            'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated',
         ]
 
 

+ 2 - 3
netbox/circuits/api/urls.py

@@ -7,14 +7,13 @@ router.APIRootView = views.CircuitsRootView
 
 # Providers
 router.register('providers', views.ProviderViewSet)
+router.register('provider-accounts', views.ProviderAccountViewSet)
+router.register('provider-networks', views.ProviderNetworkViewSet)
 
 # Circuits
 router.register('circuit-types', views.CircuitTypeViewSet)
 router.register('circuits', views.CircuitViewSet)
 router.register('circuit-terminations', views.CircuitTerminationViewSet)
 
-# Provider networks
-router.register('provider-networks', views.ProviderNetworkViewSet)
-
 app_name = 'circuits-api'
 urlpatterns = router.urls

+ 11 - 1
netbox/circuits/api/views.py

@@ -46,7 +46,7 @@ class CircuitTypeViewSet(NetBoxModelViewSet):
 
 class CircuitViewSet(NetBoxModelViewSet):
     queryset = Circuit.objects.prefetch_related(
-        'type', 'tenant', 'provider', 'termination_a', 'termination_z'
+        'type', 'tenant', 'provider', 'provider_account', 'termination_a', 'termination_z'
     ).prefetch_related('tags')
     serializer_class = serializers.CircuitSerializer
     filterset_class = filtersets.CircuitFilterSet
@@ -65,6 +65,16 @@ class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet):
     brief_prefetch_fields = ['circuit']
 
 
+#
+# Provider accounts
+#
+
+class ProviderAccountViewSet(NetBoxModelViewSet):
+    queryset = ProviderAccount.objects.prefetch_related('provider', 'tags')
+    serializer_class = serializers.ProviderAccountSerializer
+    filterset_class = filtersets.ProviderAccountFilterSet
+
+
 #
 # Provider networks
 #

+ 35 - 2
netbox/circuits/filtersets.py

@@ -16,6 +16,7 @@ __all__ = (
     'CircuitTerminationFilterSet',
     'CircuitTypeFilterSet',
     'ProviderNetworkFilterSet',
+    'ProviderAccountFilterSet',
     'ProviderFilterSet',
 )
 
@@ -66,18 +67,45 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
 
     class Meta:
         model = Provider
-        fields = ['id', 'name', 'slug', 'account']
+        fields = ['id', 'name', 'slug']
 
     def search(self, queryset, name, value):
         if not value.strip():
             return queryset
         return queryset.filter(
             Q(name__icontains=value) |
-            Q(account__icontains=value) |
+            Q(accounts__account__icontains=value) |
+            Q(accounts__name__icontains=value) |
             Q(comments__icontains=value)
         )
 
 
+class ProviderAccountFilterSet(NetBoxModelFilterSet):
+    provider_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Provider.objects.all(),
+        label=_('Provider (ID)'),
+    )
+    provider = django_filters.ModelMultipleChoiceFilter(
+        field_name='provider__slug',
+        queryset=Provider.objects.all(),
+        to_field_name='slug',
+        label=_('Provider (slug)'),
+    )
+
+    class Meta:
+        model = ProviderAccount
+        fields = ['id', 'name', 'account', 'description']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(account__icontains=value) |
+            Q(comments__icontains=value)
+        ).distinct()
+
+
 class ProviderNetworkFilterSet(NetBoxModelFilterSet):
     provider_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Provider.objects.all(),
@@ -123,6 +151,11 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
         to_field_name='slug',
         label=_('Provider (slug)'),
     )
+    provider_account_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='provider_account',
+        queryset=ProviderAccount.objects.all(),
+        label=_('ProviderAccount (ID)'),
+    )
     provider_network_id = django_filters.ModelMultipleChoiceFilter(
         field_name='terminations__provider_network',
         queryset=ProviderNetwork.objects.all(),

+ 33 - 8
netbox/circuits/forms/bulk_edit.py

@@ -14,6 +14,7 @@ __all__ = (
     'CircuitBulkEditForm',
     'CircuitTypeBulkEditForm',
     'ProviderBulkEditForm',
+    'ProviderAccountBulkEditForm',
     'ProviderNetworkBulkEditForm',
 )
 
@@ -24,11 +25,6 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
         label=_('ASNs'),
         required=False
     )
-    account = forms.CharField(
-        max_length=30,
-        required=False,
-        label=_('Account number')
-    )
     description = forms.CharField(
         max_length=200,
         required=False
@@ -39,10 +35,32 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
 
     model = Provider
     fieldsets = (
-        (None, ('asns', 'account', )),
+        (None, ('asns', 'description')),
+    )
+    nullable_fields = (
+        'asns', 'description', 'comments',
+    )
+
+
+class ProviderAccountBulkEditForm(NetBoxModelBulkEditForm):
+    provider = DynamicModelChoiceField(
+        queryset=Provider.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+    comments = CommentField(
+        label=_('Comments')
+    )
+
+    model = ProviderAccount
+    fieldsets = (
+        (None, ('provider', 'description')),
     )
     nullable_fields = (
-        'asns', 'account', 'description', 'comments',
+        'description', 'comments',
     )
 
 
@@ -95,6 +113,13 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
         queryset=Provider.objects.all(),
         required=False
     )
+    provider_account = DynamicModelChoiceField(
+        queryset=ProviderAccount.objects.all(),
+        required=False,
+        query_params={
+            'provider': '$provider'
+        }
+    )
     status = forms.ChoiceField(
         choices=add_blank_choice(CircuitStatusChoices),
         required=False,
@@ -127,7 +152,7 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
     model = Circuit
     fieldsets = (
         ('Circuit', ('provider', 'type', 'status', 'description')),
-        ('Service Parameters', ('install_date', 'termination_date', 'commit_rate')),
+        ('Service Parameters', ('provider_account', 'install_date', 'termination_date', 'commit_rate')),
         ('Tenancy', ('tenant',)),
     )
     nullable_fields = (

+ 23 - 3
netbox/circuits/forms/bulk_import.py

@@ -13,6 +13,7 @@ __all__ = (
     'CircuitTerminationImportForm',
     'CircuitTypeImportForm',
     'ProviderImportForm',
+    'ProviderAccountImportForm',
     'ProviderNetworkImportForm',
 )
 
@@ -23,7 +24,21 @@ class ProviderImportForm(NetBoxModelImportForm):
     class Meta:
         model = Provider
         fields = (
-            'name', 'slug', 'account', 'description', 'comments', 'tags',
+            'name', 'slug', 'description', 'comments', 'tags',
+        )
+
+
+class ProviderAccountImportForm(NetBoxModelImportForm):
+    provider = CSVModelChoiceField(
+        queryset=Provider.objects.all(),
+        to_field_name='name',
+        help_text=_('Assigned provider')
+    )
+
+    class Meta:
+        model = ProviderAccount
+        fields = (
+            'provider', 'name', 'account', 'description', 'comments', 'tags',
         )
 
 
@@ -55,6 +70,11 @@ class CircuitImportForm(NetBoxModelImportForm):
         to_field_name='name',
         help_text=_('Assigned provider')
     )
+    provider_account = CSVModelChoiceField(
+        queryset=ProviderAccount.objects.all(),
+        to_field_name='name',
+        help_text=_('Assigned provider account')
+    )
     type = CSVModelChoiceField(
         queryset=CircuitType.objects.all(),
         to_field_name='name',
@@ -74,8 +94,8 @@ class CircuitImportForm(NetBoxModelImportForm):
     class Meta:
         model = Circuit
         fields = [
-            'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate',
-            'description', 'comments', 'tags'
+            'cid', 'provider', 'provider_account', 'type', 'status', 'tenant', 'install_date', 'termination_date',
+            'commit_rate', 'description', 'comments', 'tags'
         ]
 
 

+ 27 - 1
netbox/circuits/forms/filtersets.py

@@ -13,6 +13,7 @@ __all__ = (
     'CircuitFilterForm',
     'CircuitTypeFilterForm',
     'ProviderFilterForm',
+    'ProviderAccountFilterForm',
     'ProviderNetworkFilterForm',
 )
 
@@ -56,6 +57,23 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
 
 
+class ProviderAccountFilterForm(NetBoxModelFilterSetForm):
+    model = ProviderAccount
+    fieldsets = (
+        (None, ('q', 'filter_id', 'tag')),
+        ('Attributes', ('provider_id', 'account')),
+    )
+    provider_id = DynamicModelMultipleChoiceField(
+        queryset=Provider.objects.all(),
+        required=False,
+        label=_('Provider')
+    )
+    account = forms.CharField(
+        required=False
+    )
+    tag = TagFilterField(model)
+
+
 class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
     model = ProviderNetwork
     fieldsets = (
@@ -83,7 +101,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
     model = Circuit
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
-        ('Provider', ('provider_id', 'provider_network_id')),
+        ('Provider', ('provider_id', 'provider_account_id', 'provider_network_id')),
         ('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
@@ -99,6 +117,14 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
         required=False,
         label=_('Provider')
     )
+    provider_account_id = DynamicModelMultipleChoiceField(
+        queryset=ProviderAccount.objects.all(),
+        required=False,
+        query_params={
+            'provider_id': '$provider_id'
+        },
+        label=_('Provider account')
+    )
     provider_network_id = DynamicModelMultipleChoiceField(
         queryset=ProviderNetwork.objects.all(),
         required=False,

+ 30 - 18
netbox/circuits/forms/model_forms.py

@@ -14,6 +14,7 @@ __all__ = (
     'CircuitTerminationForm',
     'CircuitTypeForm',
     'ProviderForm',
+    'ProviderAccountForm',
     'ProviderNetworkForm',
 )
 
@@ -29,13 +30,25 @@ class ProviderForm(NetBoxModelForm):
 
     fieldsets = (
         ('Provider', ('name', 'slug', 'asns', 'description', 'tags')),
-        ('Support Info', ('account',)),
     )
 
     class Meta:
         model = Provider
         fields = [
-            'name', 'slug', 'account', 'asns', 'description', 'comments', 'tags',
+            'name', 'slug', 'asns', 'description', 'comments', 'tags',
+        ]
+
+
+class ProviderAccountForm(NetBoxModelForm):
+    provider = DynamicModelChoiceField(
+        queryset=Provider.objects.all()
+    )
+    comments = CommentField()
+
+    class Meta:
+        model = ProviderAccount
+        fields = [
+            'provider', 'name', 'account', 'description', 'comments', 'tags',
         ]
 
 
@@ -74,7 +87,15 @@ class CircuitTypeForm(NetBoxModelForm):
 
 class CircuitForm(TenancyForm, NetBoxModelForm):
     provider = DynamicModelChoiceField(
-        queryset=Provider.objects.all()
+        queryset=Provider.objects.all(),
+        selector=True
+    )
+    provider_account = DynamicModelChoiceField(
+        queryset=ProviderAccount.objects.all(),
+        required=False,
+        query_params={
+            'provider_id': '$provider',
+        }
     )
     type = DynamicModelChoiceField(
         queryset=CircuitType.objects.all()
@@ -82,7 +103,7 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
     comments = CommentField()
 
     fieldsets = (
-        ('Circuit', ('provider', 'cid', 'type', 'status', 'description', 'tags')),
+        ('Circuit', ('provider', 'provider_account', 'cid', 'type', 'status', 'description', 'tags')),
         ('Service Parameters', ('install_date', 'termination_date', 'commit_rate')),
         ('Tenancy', ('tenant_group', 'tenant')),
     )
@@ -90,8 +111,8 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
     class Meta:
         model = Circuit
         fields = [
-            'cid', 'type', 'provider', 'status', 'install_date', 'termination_date', 'commit_rate', 'description',
-            'tenant_group', 'tenant', 'comments', 'tags',
+            'cid', 'type', 'provider', 'provider_account', 'status', 'install_date', 'termination_date', 'commit_rate',
+            'description', 'tenant_group', 'tenant', 'comments', 'tags',
         ]
         widgets = {
             'install_date': DatePicker(),
@@ -101,18 +122,9 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
 
 
 class CircuitTerminationForm(NetBoxModelForm):
-    provider = DynamicModelChoiceField(
-        queryset=Provider.objects.all(),
-        required=False,
-        initial_params={
-            'circuits': '$circuit'
-        }
-    )
     circuit = DynamicModelChoiceField(
         queryset=Circuit.objects.all(),
-        query_params={
-            'provider_id': '$provider',
-        },
+        selector=True
     )
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
@@ -128,8 +140,8 @@ class CircuitTerminationForm(NetBoxModelForm):
     class Meta:
         model = CircuitTermination
         fields = [
-            'provider', 'circuit', 'term_side', 'site', 'provider_network', 'mark_connected', 'port_speed',
-            'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags',
+            'circuit', 'term_side', 'site', 'provider_network', 'mark_connected', 'port_speed', 'upstream_speed',
+            'xconnect_id', 'pp_info', 'description', 'tags',
         ]
         widgets = {
             'port_speed': SelectSpeedWidget(),

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

@@ -31,6 +31,9 @@ class CircuitsQuery(graphene.ObjectType):
     def resolve_provider_list(root, info, **kwargs):
         return gql_query_optimizer(models.Provider.objects.all(), info)
 
+    provider_account = ObjectField(ProviderAccountType)
+    provider_account_list = ObjectListField(ProviderAccountType)
+
     provider_network = ObjectField(ProviderNetworkType)
     provider_network_list = ObjectListField(ProviderNetworkType)
 

+ 9 - 0
netbox/circuits/graphql/types.py

@@ -10,6 +10,7 @@ __all__ = (
     'CircuitType',
     'CircuitTypeType',
     'ProviderType',
+    'ProviderAccountType',
     'ProviderNetworkType',
 )
 
@@ -45,6 +46,14 @@ class ProviderType(NetBoxObjectType, ContactsMixin):
         filterset_class = filtersets.ProviderFilterSet
 
 
+class ProviderAccountType(NetBoxObjectType):
+
+    class Meta:
+        model = models.ProviderAccount
+        fields = '__all__'
+        filterset_class = filtersets.ProviderAccountFilterSet
+
+
 class ProviderNetworkType(NetBoxObjectType):
 
     class Meta:

+ 91 - 0
netbox/circuits/migrations/0042_provideraccount.py

@@ -0,0 +1,91 @@
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+import utilities.json
+
+
+def create_provideraccounts_from_providers(apps, schema_editor):
+    """
+    Migrate Account in Provider model to separate account model
+    """
+    Provider = apps.get_model('circuits', 'Provider')
+    ProviderAccount = apps.get_model('circuits', 'ProviderAccount')
+
+    provider_accounts = []
+    for provider in Provider.objects.all():
+        if provider.account:
+            provider_accounts.append(ProviderAccount(
+                provider=provider,
+                account=provider.account
+            ))
+    ProviderAccount.objects.bulk_create(provider_accounts, batch_size=100)
+
+
+def restore_providers_from_provideraccounts(apps, schema_editor):
+    """
+    Restore Provider account values from auto-generated ProviderAccounts
+    """
+    ProviderAccount = apps.get_model('circuits', 'ProviderAccount')
+    provider_accounts = ProviderAccount.objects.order_by('pk')
+    for provideraccount in provider_accounts:
+        if provider_accounts.filter(provider=provideraccount.provider)[0] == provideraccount:
+            provideraccount.provider.account = provideraccount.account
+            provideraccount.provider.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0084_staging'),
+        ('circuits', '0041_standardize_description_comments'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ProviderAccount',
+            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)),
+                ('account', models.CharField(max_length=100)),
+                ('name', models.CharField(blank=True, max_length=100)),
+                ('provider', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='accounts', to='circuits.provider')),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'ordering': ('provider', 'account'),
+            },
+        ),
+        migrations.AddConstraint(
+            model_name='provideraccount',
+            constraint=models.UniqueConstraint(condition=models.Q(('name', ''), _negated=True), fields=('provider', 'name'), name='circuits_provideraccount_unique_provider_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='provideraccount',
+            constraint=models.UniqueConstraint(fields=('provider', 'account'), name='circuits_provideraccount_unique_provider_account'),
+        ),
+        migrations.RunPython(
+            create_provideraccounts_from_providers, restore_providers_from_provideraccounts
+        ),
+        migrations.RemoveField(
+            model_name='provider',
+            name='account',
+        ),
+        migrations.AddField(
+            model_name='circuit',
+            name='provider_account',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.provideraccount', null=True, blank=True),
+            preserve_default=False,
+        ),
+        migrations.AlterModelOptions(
+            name='circuit',
+            options={'ordering': ['provider', 'provider_account', 'cid']},
+        ),
+        migrations.AddConstraint(
+            model_name='circuit',
+            constraint=models.UniqueConstraint(fields=('provider_account', 'cid'), name='circuits_circuit_unique_provideraccount_cid'),
+        ),
+    ]

+ 22 - 4
netbox/circuits/models/circuits.py

@@ -29,8 +29,8 @@ class CircuitType(OrganizationalModel):
 class Circuit(PrimaryModel):
     """
     A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
-    circuits. Each circuit is also assigned a CircuitType and a Site.  Circuit port speed and commit rate are measured
-    in Kbps.
+    circuits. Each circuit is also assigned a CircuitType and a Site, and may optionally be assigned to a particular
+    ProviderAccount. Circuit port speed and commit rate are measured in Kbps.
     """
     cid = models.CharField(
         max_length=100,
@@ -42,6 +42,13 @@ class Circuit(PrimaryModel):
         on_delete=models.PROTECT,
         related_name='circuits'
     )
+    provider_account = models.ForeignKey(
+        to='circuits.ProviderAccount',
+        on_delete=models.PROTECT,
+        related_name='circuits',
+        blank=True,
+        null=True
+    )
     type = models.ForeignKey(
         to='CircuitType',
         on_delete=models.PROTECT,
@@ -103,7 +110,8 @@ class Circuit(PrimaryModel):
     )
 
     clone_fields = (
-        'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', 'description',
+        'provider', 'provider_account', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate',
+        'description',
     )
     prerequisite_models = (
         'circuits.CircuitType',
@@ -111,12 +119,16 @@ class Circuit(PrimaryModel):
     )
 
     class Meta:
-        ordering = ['provider', 'cid']
+        ordering = ['provider', 'provider_account', 'cid']
         constraints = (
             models.UniqueConstraint(
                 fields=('provider', 'cid'),
                 name='%(app_label)s_%(class)s_unique_provider_cid'
             ),
+            models.UniqueConstraint(
+                fields=('provider_account', 'cid'),
+                name='%(app_label)s_%(class)s_unique_provideraccount_cid'
+            ),
         )
 
     def __str__(self):
@@ -128,6 +140,12 @@ class Circuit(PrimaryModel):
     def get_status_color(self):
         return CircuitStatusChoices.colors.get(self.status)
 
+    def clean(self):
+        super().clean()
+
+        if self.provider_account and self.provider != self.provider_account.provider:
+            raise ValidationError({'provider_account': "The assigned account must belong to the assigned provider."})
+
 
 class CircuitTermination(
     CustomFieldsMixin,

+ 51 - 8
netbox/circuits/models/providers.py

@@ -1,5 +1,6 @@
 from django.contrib.contenttypes.fields import GenericRelation
 from django.db import models
+from django.db.models import Q
 from django.urls import reverse
 from django.utils.translation import gettext as _
 
@@ -8,6 +9,7 @@ from netbox.models import PrimaryModel
 __all__ = (
     'ProviderNetwork',
     'Provider',
+    'ProviderAccount',
 )
 
 
@@ -30,20 +32,13 @@ class Provider(PrimaryModel):
         related_name='providers',
         blank=True
     )
-    account = models.CharField(
-        max_length=30,
-        blank=True,
-        verbose_name='Account number'
-    )
 
     # Generic relations
     contacts = GenericRelation(
         to='tenancy.ContactAssignment'
     )
 
-    clone_fields = (
-        'account',
-    )
+    clone_fields = ()
 
     class Meta:
         ordering = ['name']
@@ -55,6 +50,54 @@ class Provider(PrimaryModel):
         return reverse('circuits:provider', args=[self.pk])
 
 
+class ProviderAccount(PrimaryModel):
+    """
+    This is a discrete account within a provider.  Each Circuit belongs to a Provider Account.
+    """
+    provider = models.ForeignKey(
+        to='circuits.Provider',
+        on_delete=models.PROTECT,
+        related_name='accounts'
+    )
+    account = models.CharField(
+        max_length=100,
+        verbose_name='Account ID'
+    )
+    name = models.CharField(
+        max_length=100,
+        blank=True
+    )
+
+    # Generic relations
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
+
+    clone_fields = ('provider', )
+
+    class Meta:
+        ordering = ('provider', 'account')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('provider', 'account'),
+                name='%(app_label)s_%(class)s_unique_provider_account'
+            ),
+            models.UniqueConstraint(
+                fields=('provider', 'name'),
+                name='%(app_label)s_%(class)s_unique_provider_name',
+                condition=~Q(name="")
+            ),
+        )
+
+    def __str__(self):
+        if self.name:
+            return f'{self.account} ({self.name})'
+        return f'{self.account}'
+
+    def get_absolute_url(self):
+        return reverse('circuits:provideraccount', args=[self.pk])
+
+
 class ProviderNetwork(PrimaryModel):
     """
     This represents a provider network which exists outside of NetBox, the details of which are unknown or

+ 9 - 1
netbox/circuits/search.py

@@ -39,12 +39,20 @@ class ProviderIndex(SearchIndex):
     model = models.Provider
     fields = (
         ('name', 100),
-        ('account', 200),
         ('description', 500),
         ('comments', 5000),
     )
 
 
+class ProviderAccountIndex(SearchIndex):
+    model = models.ProviderAccount
+    fields = (
+        ('name', 100),
+        ('account', 200),
+        ('comments', 5000),
+    )
+
+
 @register_search
 class ProviderNetworkIndex(SearchIndex):
     model = models.ProviderNetwork

+ 8 - 3
netbox/circuits/tables/circuits.py

@@ -1,4 +1,5 @@
 import django_tables2 as tables
+
 from circuits.models import *
 from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
 
@@ -50,6 +51,10 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     provider = tables.Column(
         linkify=True
     )
+    provider_account = tables.Column(
+        linkify=True,
+        verbose_name='Account'
+    )
     status = columns.ChoiceFieldColumn()
     termination_a = tables.TemplateColumn(
         template_code=CIRCUITTERMINATION_LINK,
@@ -68,9 +73,9 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = Circuit
         fields = (
-            'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'tenant_group', 'termination_a', 'termination_z',
-            'install_date', 'termination_date', 'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created',
-            'last_updated',
+            'pk', 'id', 'cid', 'provider', 'provider_account', 'type', 'status', 'tenant', 'tenant_group',
+            'termination_a', 'termination_z', 'install_date', 'termination_date', 'commit_rate', 'description',
+            'comments', 'contacts', 'tags', 'created', 'last_updated',
         )
         default_columns = (
             'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',

+ 42 - 3
netbox/circuits/tables/providers.py

@@ -7,6 +7,7 @@ from netbox.tables import NetBoxTable, columns
 
 __all__ = (
     'ProviderTable',
+    'ProviderAccountTable',
     'ProviderNetworkTable',
 )
 
@@ -15,6 +16,16 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
         linkify=True
     )
+    accounts = columns.ManyToManyColumn(
+        linkify_item=True,
+        verbose_name='Accounts'
+    )
+    account_count = columns.LinkedCountColumn(
+        accessor=tables.A('accounts__count'),
+        viewname='circuits:provideraccount_list',
+        url_params={'account_id': 'pk'},
+        verbose_name='Account Count'
+    )
     asns = columns.ManyToManyColumn(
         linkify_item=True,
         verbose_name='ASNs'
@@ -39,10 +50,38 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = Provider
         fields = (
-            'pk', 'id', 'name', 'asns', 'account', 'asn_count', 'circuit_count', 'description', 'comments', 'contacts',
-            'tags', 'created', 'last_updated',
+            'pk', 'id', 'name', 'accounts', 'account_count', 'asns', 'asn_count', 'circuit_count', 'description',
+            'comments', 'contacts', 'tags', 'created', 'last_updated',
+        )
+        default_columns = ('pk', 'name', 'account_count', 'circuit_count')
+
+
+class ProviderAccountTable(ContactsColumnMixin, NetBoxTable):
+    account = tables.Column(
+        linkify=True
+    )
+    name = tables.Column()
+    provider = tables.Column(
+        linkify=True
+    )
+    circuit_count = columns.LinkedCountColumn(
+        accessor=Accessor('count_circuits'),
+        viewname='circuits:circuit_list',
+        url_params={'provider_account_id': 'pk'},
+        verbose_name='Circuits'
+    )
+    comments = columns.MarkdownColumn()
+    tags = columns.TagColumn(
+        url_name='circuits:provideraccount_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = ProviderAccount
+        fields = (
+            'pk', 'id', 'account', 'name', 'provider', 'circuit_count', 'comments', 'contacts', 'tags', 'created',
+            'last_updated',
         )
-        default_columns = ('pk', 'name', 'account', 'circuit_count')
+        default_columns = ('pk', 'account', 'name', 'provider', 'circuit_count')
 
 
 class ProviderNetworkTable(NetBoxTable):

+ 56 - 4
netbox/circuits/tests/test_api.py

@@ -20,7 +20,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
     model = Provider
     brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url']
     bulk_update_data = {
-        'account': '1234',
+        'comments': 'New comments',
     }
 
     @classmethod
@@ -106,6 +106,12 @@ class CircuitTest(APIViewTestCases.APIViewTestCase):
         )
         Provider.objects.bulk_create(providers)
 
+        provider_accounts = (
+            ProviderAccount(name='Provider Account 1', provider=providers[0], account='1234'),
+            ProviderAccount(name='Provider Account 2', provider=providers[1], account='2345'),
+        )
+        ProviderAccount.objects.bulk_create(provider_accounts)
+
         circuit_types = (
             CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
             CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
@@ -113,9 +119,9 @@ class CircuitTest(APIViewTestCases.APIViewTestCase):
         CircuitType.objects.bulk_create(circuit_types)
 
         circuits = (
-            Circuit(cid='Circuit 1', provider=providers[0], type=circuit_types[0]),
-            Circuit(cid='Circuit 2', provider=providers[0], type=circuit_types[0]),
-            Circuit(cid='Circuit 3', provider=providers[0], type=circuit_types[0]),
+            Circuit(cid='Circuit 1', provider=providers[0], provider_account=provider_accounts[0], type=circuit_types[0]),
+            Circuit(cid='Circuit 2', provider=providers[0], provider_account=provider_accounts[0], type=circuit_types[0]),
+            Circuit(cid='Circuit 3', provider=providers[0], provider_account=provider_accounts[0], type=circuit_types[0]),
         )
         Circuit.objects.bulk_create(circuits)
 
@@ -123,16 +129,19 @@ class CircuitTest(APIViewTestCases.APIViewTestCase):
             {
                 'cid': 'Circuit 4',
                 'provider': providers[1].pk,
+                'provider_account': provider_accounts[1].pk,
                 'type': circuit_types[1].pk,
             },
             {
                 'cid': 'Circuit 5',
                 'provider': providers[1].pk,
+                'provider_account': provider_accounts[1].pk,
                 'type': circuit_types[1].pk,
             },
             {
                 'cid': 'Circuit 6',
                 'provider': providers[1].pk,
+                'provider_account': provider_accounts[1].pk,
                 'type': circuit_types[1].pk,
             },
         ]
@@ -197,6 +206,49 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
         }
 
 
+class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
+    model = ProviderAccount
+    brief_fields = ['account', 'display', 'id', 'name', 'url']
+
+    @classmethod
+    def setUpTestData(cls):
+        providers = (
+            Provider(name='Provider 1', slug='provider-1'),
+            Provider(name='Provider 2', slug='provider-2'),
+        )
+        Provider.objects.bulk_create(providers)
+
+        provider_accounts = (
+            ProviderAccount(name='Provider Account 1', provider=providers[0], account='1234'),
+            ProviderAccount(name='Provider Account 2', provider=providers[0], account='2345'),
+            ProviderAccount(name='Provider Account 3', provider=providers[0], account='3456'),
+        )
+        ProviderAccount.objects.bulk_create(provider_accounts)
+
+        cls.create_data = [
+            {
+                'name': 'Provider Account 4',
+                'provider': providers[0].pk,
+                'account': '4567',
+            },
+            {
+                'name': 'Provider Account 5',
+                'provider': providers[0].pk,
+                'account': '5678',
+            },
+            {
+                'name': 'Provider Account 6',
+                'provider': providers[0].pk,
+                'account': '6789',
+            },
+        ]
+
+        cls.bulk_update_data = {
+            'provider': providers[1].pk,
+            'description': 'New description',
+        }
+
+
 class ProviderNetworkTest(APIViewTestCases.APIViewTestCase):
     model = ProviderNetwork
     brief_fields = ['display', 'id', 'name', 'url']

+ 67 - 17
netbox/circuits/tests/test_filtersets.py

@@ -25,11 +25,11 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
         ASN.objects.bulk_create(asns)
 
         providers = (
-            Provider(name='Provider 1', slug='provider-1', account='1234'),
-            Provider(name='Provider 2', slug='provider-2', account='2345'),
-            Provider(name='Provider 3', slug='provider-3', account='3456'),
-            Provider(name='Provider 4', slug='provider-4', account='4567'),
-            Provider(name='Provider 5', slug='provider-5', account='5678'),
+            Provider(name='Provider 1', slug='provider-1'),
+            Provider(name='Provider 2', slug='provider-2'),
+            Provider(name='Provider 3', slug='provider-3'),
+            Provider(name='Provider 4', slug='provider-4'),
+            Provider(name='Provider 5', slug='provider-5'),
         )
         Provider.objects.bulk_create(providers)
         providers[0].asns.set([asns[0]])
@@ -64,8 +64,8 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
         CircuitType.objects.bulk_create(circuit_types)
 
         circuits = (
-            Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 1'),
-            Circuit(provider=providers[1], type=circuit_types[1], cid='Test Circuit 1'),
+            Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 1'),
+            Circuit(provider=providers[1], type=circuit_types[1], cid='Circuit 2'),
         )
         Circuit.objects.bulk_create(circuits)
 
@@ -87,10 +87,6 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'asn_id': [asns[0].pk, asns[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_account(self):
-        params = {'account': ['1234', '2345']}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_region(self):
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -193,9 +189,17 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
         providers = (
             Provider(name='Provider 1', slug='provider-1'),
             Provider(name='Provider 2', slug='provider-2'),
+            Provider(name='Provider 3', slug='provider-3'),
         )
         Provider.objects.bulk_create(providers)
 
+        provider_accounts = (
+            ProviderAccount(name='Provider Account 1', provider=providers[0], account='A'),
+            ProviderAccount(name='Provider Account 2', provider=providers[1], account='B'),
+            ProviderAccount(name='Provider Account 3', provider=providers[2], account='C'),
+        )
+        ProviderAccount.objects.bulk_create(provider_accounts)
+
         provider_networks = (
             ProviderNetwork(name='Provider Network 1', provider=providers[1]),
             ProviderNetwork(name='Provider Network 2', provider=providers[1]),
@@ -204,12 +208,12 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
         ProviderNetwork.objects.bulk_create(provider_networks)
 
         circuits = (
-            Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', termination_date='2021-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'),
-            Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', termination_date='2021-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'),
-            Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', termination_date='2021-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
-            Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', termination_date='2021-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
-            Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', termination_date='2021-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
-            Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', termination_date='2021-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE),
+            Circuit(provider=providers[0], provider_account=provider_accounts[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', termination_date='2021-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'),
+            Circuit(provider=providers[0], provider_account=provider_accounts[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', termination_date='2021-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'),
+            Circuit(provider=providers[0], provider_account=provider_accounts[1], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', termination_date='2021-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
+            Circuit(provider=providers[1], provider_account=provider_accounts[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', termination_date='2021-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
+            Circuit(provider=providers[1], provider_account=provider_accounts[2], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', termination_date='2021-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
+            Circuit(provider=providers[1], provider_account=provider_accounts[2], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', termination_date='2021-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE),
         )
         Circuit.objects.bulk_create(circuits)
 
@@ -246,6 +250,11 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'provider': [provider.slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
+    def test_provider_account(self):
+        provider_accounts = ProviderAccount.objects.all()[:2]
+        params = {'provider_account_id': [provider_accounts[0].pk, provider_accounts[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
     def test_provider_network(self):
         provider_networks = ProviderNetwork.objects.all()[:2]
         params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]}
@@ -445,3 +454,44 @@ class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'provider': [providers[0].slug, providers[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class ProviderAccountTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = ProviderAccount.objects.all()
+    filterset = ProviderAccountFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        providers = (
+            Provider(name='Provider 1', slug='provider-1'),
+            Provider(name='Provider 2', slug='provider-2'),
+            Provider(name='Provider 3', slug='provider-3'),
+        )
+        Provider.objects.bulk_create(providers)
+
+        provider_accounts = (
+            ProviderAccount(name='Provider Account 1', provider=providers[0], description='foobar1', account='1234'),
+            ProviderAccount(name='Provider Account 2', provider=providers[1], description='foobar2', account='2345'),
+            ProviderAccount(name='Provider Account 3', provider=providers[2], account='3456'),
+        )
+        ProviderAccount.objects.bulk_create(provider_accounts)
+
+    def test_name(self):
+        params = {'name': ['Provider Account 1', 'Provider Account 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_account(self):
+        params = {'account': ['1234', '3456']}
+        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_provider(self):
+        providers = Provider.objects.all()[:2]
+        params = {'provider_id': [providers[0].pk, providers[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'provider': [providers[0].slug, providers[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 66 - 9
netbox/circuits/tests/test_views.py

@@ -38,7 +38,6 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'name': 'Provider X',
             'slug': 'provider-x',
             'asns': [asns[6].pk, asns[7].pk],
-            'account': '1234',
             'comments': 'Another provider',
             'tags': [t.pk for t in tags],
         }
@@ -58,7 +57,6 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
 
         cls.bulk_edit_data = {
-            'account': '5678',
             'comments': 'New comments',
         }
 
@@ -124,6 +122,12 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         Provider.objects.bulk_create(providers)
 
+        provider_accounts = (
+            ProviderAccount(name='Provider Account 1', provider=providers[0], account='1234'),
+            ProviderAccount(name='Provider Account 2', provider=providers[1], account='2345'),
+        )
+        ProviderAccount.objects.bulk_create(provider_accounts)
+
         circuittypes = (
             CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
             CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
@@ -131,9 +135,9 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         CircuitType.objects.bulk_create(circuittypes)
 
         circuits = (
-            Circuit(cid='Circuit 1', provider=providers[0], type=circuittypes[0]),
-            Circuit(cid='Circuit 2', provider=providers[0], type=circuittypes[0]),
-            Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]),
+            Circuit(cid='Circuit 1', provider=providers[0], provider_account=provider_accounts[0], type=circuittypes[0]),
+            Circuit(cid='Circuit 2', provider=providers[0], provider_account=provider_accounts[0], type=circuittypes[0]),
+            Circuit(cid='Circuit 3', provider=providers[0], provider_account=provider_accounts[0], type=circuittypes[0]),
         )
 
         Circuit.objects.bulk_create(circuits)
@@ -143,6 +147,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         cls.form_data = {
             'cid': 'Circuit X',
             'provider': providers[1].pk,
+            'provider_account': provider_accounts[1].pk,
             'type': circuittypes[1].pk,
             'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
             'tenant': None,
@@ -155,10 +160,10 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
         cls.csv_data = (
-            "cid,provider,type,status",
-            "Circuit 4,Provider 1,Circuit Type 1,active",
-            "Circuit 5,Provider 1,Circuit Type 1,active",
-            "Circuit 6,Provider 1,Circuit Type 1,active",
+            "cid,provider,provider_account,type,status",
+            "Circuit 4,Provider 1,Provider Account 1,Circuit Type 1,active",
+            "Circuit 5,Provider 1,Provider Account 1,Circuit Type 1,active",
+            "Circuit 6,Provider 1,Provider Account 1,Circuit Type 1,active",
         )
 
         cls.csv_update_data = (
@@ -170,6 +175,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
         cls.bulk_edit_data = {
             'provider': providers[1].pk,
+            'provider_account': provider_accounts[1].pk,
             'type': circuittypes[1].pk,
             'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
             'tenant': None,
@@ -179,6 +185,57 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
 
+class ProviderAccountTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = ProviderAccount
+
+    @classmethod
+    def setUpTestData(cls):
+
+        providers = (
+            Provider(name='Provider 1', slug='provider-1'),
+            Provider(name='Provider 2', slug='provider-2'),
+        )
+        Provider.objects.bulk_create(providers)
+
+        provider_accounts = (
+            ProviderAccount(name='Provider Account 1', provider=providers[0], account='1234'),
+            ProviderAccount(name='Provider Account 2', provider=providers[0], account='2345'),
+            ProviderAccount(name='Provider Account 3', provider=providers[0], account='3456'),
+        )
+        ProviderAccount.objects.bulk_create(provider_accounts)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'name': 'Provider Account X',
+            'provider': providers[1].pk,
+            'account': 'XXXX',
+            'description': 'A new provider network',
+            'comments': 'Longer description goes here',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "name,provider,account,description",
+            "Provider Account 4,Provider 1,4567,Foo",
+            "Provider Account 5,Provider 1,5678,Bar",
+            "Provider Account 6,Provider 1,6789,Baz",
+        )
+
+        cls.csv_update_data = (
+            "id,name,account,description",
+            f"{provider_accounts[0].pk},Provider Network 7,7890,New description7",
+            f"{provider_accounts[1].pk},Provider Network 8,8901,New description8",
+            f"{provider_accounts[2].pk},Provider Network 9,9012,New description9",
+        )
+
+        cls.bulk_edit_data = {
+            'provider': providers[1].pk,
+            'description': 'New description',
+            'comments': 'New comments',
+        }
+
+
 class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = ProviderNetwork
 

+ 8 - 0
netbox/circuits/urls.py

@@ -14,6 +14,14 @@ urlpatterns = [
     path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
     path('providers/<int:pk>/', include(get_model_urls('circuits', 'provider'))),
 
+    # Provider accounts
+    path('provider-accounts/', views.ProviderAccountListView.as_view(), name='provideraccount_list'),
+    path('provider-accounts/add/', views.ProviderAccountEditView.as_view(), name='provideraccount_add'),
+    path('provider-accounts/import/', views.ProviderAccountBulkImportView.as_view(), name='provideraccount_import'),
+    path('provider-accounts/edit/', views.ProviderAccountBulkEditView.as_view(), name='provideraccount_bulk_edit'),
+    path('provider-accounts/delete/', views.ProviderAccountBulkDeleteView.as_view(), name='provideraccount_bulk_delete'),
+    path('provider-accounts/<int:pk>/', include(get_model_urls('circuits', 'provideraccount'))),
+
     # Provider networks
     path('provider-networks/', views.ProviderNetworkListView.as_view(), name='providernetwork_list'),
     path('provider-networks/add/', views.ProviderNetworkEditView.as_view(), name='providernetwork_add'),

+ 62 - 0
netbox/circuits/views.py

@@ -31,6 +31,7 @@ class ProviderView(generic.ObjectView):
 
     def get_extra_context(self, request, instance):
         related_models = (
+            (ProviderAccount.objects.restrict(request.user, 'view').filter(provider=instance), 'provider_id'),
             (Circuit.objects.restrict(request.user, 'view').filter(provider=instance), 'provider_id'),
         )
 
@@ -72,6 +73,67 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
     table = tables.ProviderTable
 
 
+#
+# ProviderAccounts
+#
+
+class ProviderAccountListView(generic.ObjectListView):
+    queryset = ProviderAccount.objects.annotate(
+        count_circuits=count_related(Circuit, 'provider_account')
+    )
+    filterset = filtersets.ProviderAccountFilterSet
+    filterset_form = forms.ProviderAccountFilterForm
+    table = tables.ProviderAccountTable
+
+
+@register_model_view(ProviderAccount)
+class ProviderAccountView(generic.ObjectView):
+    queryset = ProviderAccount.objects.all()
+
+    def get_extra_context(self, request, instance):
+        related_models = (
+            (Circuit.objects.restrict(request.user, 'view').filter(provider_account=instance), 'provider_account_id'),
+        )
+
+        return {
+            'related_models': related_models,
+        }
+
+
+@register_model_view(ProviderAccount, 'edit')
+class ProviderAccountEditView(generic.ObjectEditView):
+    queryset = ProviderAccount.objects.all()
+    form = forms.ProviderAccountForm
+
+
+@register_model_view(ProviderAccount, 'delete')
+class ProviderAccountDeleteView(generic.ObjectDeleteView):
+    queryset = ProviderAccount.objects.all()
+
+
+class ProviderAccountBulkImportView(generic.BulkImportView):
+    queryset = ProviderAccount.objects.all()
+    model_form = forms.ProviderAccountImportForm
+    table = tables.ProviderAccountTable
+
+
+class ProviderAccountBulkEditView(generic.BulkEditView):
+    queryset = ProviderAccount.objects.annotate(
+        count_circuits=count_related(Circuit, 'provider_account')
+    )
+    filterset = filtersets.ProviderAccountFilterSet
+    table = tables.ProviderAccountTable
+    form = forms.ProviderAccountBulkEditForm
+
+
+class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
+    queryset = ProviderAccount.objects.annotate(
+        count_circuits=count_related(Circuit, 'provider_account')
+    )
+    filterset = filtersets.ProviderAccountFilterSet
+    table = tables.ProviderAccountTable
+
+
 #
 # Provider networks
 #

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

@@ -245,6 +245,7 @@ CIRCUITS_MENU = Menu(
             label=_('Providers'),
             items=(
                 get_model_item('circuits', 'provider', _('Providers')),
+                get_model_item('circuits', 'provideraccount', _('Provider Accounts')),
                 get_model_item('circuits', 'providernetwork', _('Provider Networks')),
             ),
         ),

+ 4 - 0
netbox/templates/circuits/circuit.html

@@ -18,6 +18,10 @@
               <th scope="row">Provider</th>
               <td>{{ object.provider|linkify }}</td>
             </tr>
+            <tr>
+              <th scope="row">Account</th>
+              <td>{{ object.provider_account|linkify|placeholder }}</td>
+            </tr>
             <tr>
               <th scope="row">Circuit ID</th>
               <td>{{ object.cid }}</td>

+ 0 - 1
netbox/templates/circuits/circuittermination_edit.html

@@ -7,7 +7,6 @@
     <div class="row mb-2">
       <h5 class="offset-sm-3">Circuit Termination</h5>
     </div>
-    {% render_field form.provider %}
     {% render_field form.circuit %}
     {% render_field form.term_side %}
     {% render_field form.tags %}

+ 8 - 0
netbox/templates/circuits/provider.html

@@ -52,6 +52,14 @@
     </div>
 </div>
 <div class="row mb-3">
+  <div class="col col-md-12">
+    <div class="card">
+      <h5 class="card-header">Provider Accounts</h5>
+      <div class="card-body htmx-container table-responsive"
+        hx-get="{% url 'circuits:provideraccount_list' %}?provider_id={{ object.pk }}"
+        hx-trigger="load"
+      ></div>
+    </div>
   <div class="col col-md-12">
     <div class="card">
       <h5 class="card-header">Circuits</h5>

+ 55 - 0
netbox/templates/circuits/provideraccount.html

@@ -0,0 +1,55 @@
+{% extends 'generic/object.html' %}
+{% load static %}
+{% load helpers %}
+{% load plugins %}
+{% load render_table from django_tables2 %}
+
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item"><a href="{% url 'circuits:provideraccount_list' %}?provider_id={{ object.provider_id }}">{{ object.provider }}</a></li>
+{% endblock %}
+
+{% block content %}
+  <div class="row mb-3">
+	  <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">Provider Account</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Provider</th>
+              <td>{{ object.provider|linkify }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Account</th>
+              <td>{{ object.account }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Name</th>
+              <td>{{ object.name|placeholder }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      {% include 'inc/panels/tags.html' %}
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      {% include 'inc/panels/related_objects.html' %}
+      {% include 'inc/panels/comments.html' %}
+      {% include 'inc/panels/custom_fields.html' %}
+      {% include 'inc/panels/contacts.html' %}
+      {% plugin_right_page object %}
+    </div>
+    <div class="col col-md-12">
+      <div class="card">
+        <h5 class="card-header">Circuits</h5>
+        <div class="card-body htmx-container table-responsive"
+          hx-get="{% url 'circuits:circuit_list' %}?provider_account_id={{ object.pk }}"
+          hx-trigger="load"
+        ></div>
+      </div>
+    {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}