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

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 2 лет назад
Родитель
Сommit
9d709c84e7
35 измененных файлов с 792 добавлено и 98 удалено
  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.Circuit](../models/circuits/circuit.md)
 * [circuits.Provider](../models/circuits/provider.md)
 * [circuits.Provider](../models/circuits/provider.md)
+* [circuits.ProviderAccount](../models/circuits/provideracount.md)
 * [circuits.ProviderNetwork](../models/circuits/providernetwork.md)
 * [circuits.ProviderNetwork](../models/circuits/providernetwork.md)
 * [core.DataSource](../models/core/datasource.md)
 * [core.DataSource](../models/core/datasource.md)
 * [dcim.Cable](../models/dcim/cable.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                                      |
 | 60     | Unique serialized attribute (per related object) | Device.serial                                      |
 | 100    | Primary human identifier                         | Device.name, Circuit.cid, Cable.label              |
 | 100    | Primary human identifier                         | Device.name, Circuit.cid, Cable.label              |
 | 110    | Slug                                             | Site.slug                                          |
 | 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 |
 | 300    | Highly unique descriptive attribute              | CircuitTermination.xconnect_id, IPAddress.dns_name |
 | 500    | Description                                      | Site.description                                   |
 | 500    | Description                                      | Site.description                                   |
 | 1000   | Custom field default                             | -                                                  |
 | 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
 ```mermaid
 flowchart TD
 flowchart TD
     ASN --> Provider
     ASN --> Provider
-    Provider --> ProviderNetwork & Circuit
+    Provider --> ProviderNetwork & ProviderAccount & Circuit
+    ProviderAccount --> Circuit
     CircuitType --> Circuit
     CircuitType --> Circuit
 
 
 click ASN "../../models/circuits/asn/"
 click ASN "../../models/circuits/asn/"
 click Circuit "../../models/circuits/circuit/"
 click Circuit "../../models/circuits/circuit/"
 click CircuitType "../../models/circuits/circuittype/"
 click CircuitType "../../models/circuits/circuittype/"
 click Provider "../../models/circuits/provider/"
 click Provider "../../models/circuits/provider/"
+click ProviderAccount "../../models/circuits/provideraccount/"
 click ProviderNetwork "../../models/circuits/providernetwork/"
 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.
 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.
 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.Circuit
 * circuits.Provider
 * circuits.Provider
+* circuits.ProviderAccount
 * dcim.Device
 * dcim.Device
 * dcim.Location
 * dcim.Location
 * dcim.Manufacturer
 * 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
 4. Manufacturers, device types, and module types
 5. Platforms and device roles
 5. Platforms and device roles
 6. Devices and modules
 6. Devices and modules
-7. Providers and provider networks
+7. Providers, provider accounts, and provider networks
 8. Circuit types and circuits
 8. Circuit types and circuits
 9. Wireless LAN groups and wireless LANs
 9. Wireless LAN groups and wireless LANs
 10. Route targets and VRFs
 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.
 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
 ### 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.)
 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).
 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
 ### Portal URL
 
 
 The URL for the provider's customer service portal.
 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',
     'NestedCircuitTypeSerializer',
     'NestedProviderNetworkSerializer',
     'NestedProviderNetworkSerializer',
     'NestedProviderSerializer',
     'NestedProviderSerializer',
+    'NestedProviderAccountSerializer',
 ]
 ]
 
 
 
 
@@ -37,6 +38,18 @@ class NestedProviderSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name', 'slug', 'circuit_count']
         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
 # Circuits
 #
 #

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

@@ -18,6 +18,12 @@ from .nested_serializers import *
 
 
 class ProviderSerializer(NetBoxModelSerializer):
 class ProviderSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
+    accounts = SerializedPKRelatedField(
+        queryset=ProviderAccount.objects.all(),
+        serializer=NestedProviderAccountSerializer,
+        required=False,
+        many=True
+    )
     asns = SerializedPKRelatedField(
     asns = SerializedPKRelatedField(
         queryset=ASN.objects.all(),
         queryset=ASN.objects.all(),
         serializer=NestedASNSerializer,
         serializer=NestedASNSerializer,
@@ -31,11 +37,27 @@ class ProviderSerializer(NetBoxModelSerializer):
     class Meta:
     class Meta:
         model = Provider
         model = Provider
         fields = [
         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',
             '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
 # Provider networks
 #
 #
@@ -84,6 +106,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
 class CircuitSerializer(NetBoxModelSerializer):
 class CircuitSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
     provider = NestedProviderSerializer()
     provider = NestedProviderSerializer()
+    provider_account = NestedProviderAccountSerializer()
     status = ChoiceField(choices=CircuitStatusChoices, required=False)
     status = ChoiceField(choices=CircuitStatusChoices, required=False)
     type = NestedCircuitTypeSerializer()
     type = NestedCircuitTypeSerializer()
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
@@ -93,9 +116,9 @@ class CircuitSerializer(NetBoxModelSerializer):
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit
         fields = [
         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
 # Providers
 router.register('providers', views.ProviderViewSet)
 router.register('providers', views.ProviderViewSet)
+router.register('provider-accounts', views.ProviderAccountViewSet)
+router.register('provider-networks', views.ProviderNetworkViewSet)
 
 
 # Circuits
 # Circuits
 router.register('circuit-types', views.CircuitTypeViewSet)
 router.register('circuit-types', views.CircuitTypeViewSet)
 router.register('circuits', views.CircuitViewSet)
 router.register('circuits', views.CircuitViewSet)
 router.register('circuit-terminations', views.CircuitTerminationViewSet)
 router.register('circuit-terminations', views.CircuitTerminationViewSet)
 
 
-# Provider networks
-router.register('provider-networks', views.ProviderNetworkViewSet)
-
 app_name = 'circuits-api'
 app_name = 'circuits-api'
 urlpatterns = router.urls
 urlpatterns = router.urls

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

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

+ 35 - 2
netbox/circuits/filtersets.py

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

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

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

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

@@ -13,6 +13,7 @@ __all__ = (
     'CircuitTerminationImportForm',
     'CircuitTerminationImportForm',
     'CircuitTypeImportForm',
     'CircuitTypeImportForm',
     'ProviderImportForm',
     'ProviderImportForm',
+    'ProviderAccountImportForm',
     'ProviderNetworkImportForm',
     'ProviderNetworkImportForm',
 )
 )
 
 
@@ -23,7 +24,21 @@ class ProviderImportForm(NetBoxModelImportForm):
     class Meta:
     class Meta:
         model = Provider
         model = Provider
         fields = (
         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',
         to_field_name='name',
         help_text=_('Assigned provider')
         help_text=_('Assigned provider')
     )
     )
+    provider_account = CSVModelChoiceField(
+        queryset=ProviderAccount.objects.all(),
+        to_field_name='name',
+        help_text=_('Assigned provider account')
+    )
     type = CSVModelChoiceField(
     type = CSVModelChoiceField(
         queryset=CircuitType.objects.all(),
         queryset=CircuitType.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -74,8 +94,8 @@ class CircuitImportForm(NetBoxModelImportForm):
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit
         fields = [
         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',
     'CircuitFilterForm',
     'CircuitTypeFilterForm',
     'CircuitTypeFilterForm',
     'ProviderFilterForm',
     'ProviderFilterForm',
+    'ProviderAccountFilterForm',
     'ProviderNetworkFilterForm',
     'ProviderNetworkFilterForm',
 )
 )
 
 
@@ -56,6 +57,23 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
     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):
 class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
     model = ProviderNetwork
     model = ProviderNetwork
     fieldsets = (
     fieldsets = (
@@ -83,7 +101,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
     model = Circuit
     model = Circuit
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id', 'tag')),
         (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')),
         ('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
@@ -99,6 +117,14 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
         required=False,
         required=False,
         label=_('Provider')
         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(
     provider_network_id = DynamicModelMultipleChoiceField(
         queryset=ProviderNetwork.objects.all(),
         queryset=ProviderNetwork.objects.all(),
         required=False,
         required=False,

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

@@ -14,6 +14,7 @@ __all__ = (
     'CircuitTerminationForm',
     'CircuitTerminationForm',
     'CircuitTypeForm',
     'CircuitTypeForm',
     'ProviderForm',
     'ProviderForm',
+    'ProviderAccountForm',
     'ProviderNetworkForm',
     'ProviderNetworkForm',
 )
 )
 
 
@@ -29,13 +30,25 @@ class ProviderForm(NetBoxModelForm):
 
 
     fieldsets = (
     fieldsets = (
         ('Provider', ('name', 'slug', 'asns', 'description', 'tags')),
         ('Provider', ('name', 'slug', 'asns', 'description', 'tags')),
-        ('Support Info', ('account',)),
     )
     )
 
 
     class Meta:
     class Meta:
         model = Provider
         model = Provider
         fields = [
         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):
 class CircuitForm(TenancyForm, NetBoxModelForm):
     provider = DynamicModelChoiceField(
     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(
     type = DynamicModelChoiceField(
         queryset=CircuitType.objects.all()
         queryset=CircuitType.objects.all()
@@ -82,7 +103,7 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     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')),
         ('Service Parameters', ('install_date', 'termination_date', 'commit_rate')),
         ('Tenancy', ('tenant_group', 'tenant')),
         ('Tenancy', ('tenant_group', 'tenant')),
     )
     )
@@ -90,8 +111,8 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit
         fields = [
         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 = {
         widgets = {
             'install_date': DatePicker(),
             'install_date': DatePicker(),
@@ -101,18 +122,9 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
 
 
 
 
 class CircuitTerminationForm(NetBoxModelForm):
 class CircuitTerminationForm(NetBoxModelForm):
-    provider = DynamicModelChoiceField(
-        queryset=Provider.objects.all(),
-        required=False,
-        initial_params={
-            'circuits': '$circuit'
-        }
-    )
     circuit = DynamicModelChoiceField(
     circuit = DynamicModelChoiceField(
         queryset=Circuit.objects.all(),
         queryset=Circuit.objects.all(),
-        query_params={
-            'provider_id': '$provider',
-        },
+        selector=True
     )
     )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
@@ -128,8 +140,8 @@ class CircuitTerminationForm(NetBoxModelForm):
     class Meta:
     class Meta:
         model = CircuitTermination
         model = CircuitTermination
         fields = [
         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 = {
         widgets = {
             'port_speed': SelectSpeedWidget(),
             '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):
     def resolve_provider_list(root, info, **kwargs):
         return gql_query_optimizer(models.Provider.objects.all(), info)
         return gql_query_optimizer(models.Provider.objects.all(), info)
 
 
+    provider_account = ObjectField(ProviderAccountType)
+    provider_account_list = ObjectListField(ProviderAccountType)
+
     provider_network = ObjectField(ProviderNetworkType)
     provider_network = ObjectField(ProviderNetworkType)
     provider_network_list = ObjectListField(ProviderNetworkType)
     provider_network_list = ObjectListField(ProviderNetworkType)
 
 

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

@@ -10,6 +10,7 @@ __all__ = (
     'CircuitType',
     'CircuitType',
     'CircuitTypeType',
     'CircuitTypeType',
     'ProviderType',
     'ProviderType',
+    'ProviderAccountType',
     'ProviderNetworkType',
     'ProviderNetworkType',
 )
 )
 
 
@@ -45,6 +46,14 @@ class ProviderType(NetBoxObjectType, ContactsMixin):
         filterset_class = filtersets.ProviderFilterSet
         filterset_class = filtersets.ProviderFilterSet
 
 
 
 
+class ProviderAccountType(NetBoxObjectType):
+
+    class Meta:
+        model = models.ProviderAccount
+        fields = '__all__'
+        filterset_class = filtersets.ProviderAccountFilterSet
+
+
 class ProviderNetworkType(NetBoxObjectType):
 class ProviderNetworkType(NetBoxObjectType):
 
 
     class Meta:
     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):
 class Circuit(PrimaryModel):
     """
     """
     A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
     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(
     cid = models.CharField(
         max_length=100,
         max_length=100,
@@ -42,6 +42,13 @@ class Circuit(PrimaryModel):
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='circuits'
         related_name='circuits'
     )
     )
+    provider_account = models.ForeignKey(
+        to='circuits.ProviderAccount',
+        on_delete=models.PROTECT,
+        related_name='circuits',
+        blank=True,
+        null=True
+    )
     type = models.ForeignKey(
     type = models.ForeignKey(
         to='CircuitType',
         to='CircuitType',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
@@ -103,7 +110,8 @@ class Circuit(PrimaryModel):
     )
     )
 
 
     clone_fields = (
     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 = (
     prerequisite_models = (
         'circuits.CircuitType',
         'circuits.CircuitType',
@@ -111,12 +119,16 @@ class Circuit(PrimaryModel):
     )
     )
 
 
     class Meta:
     class Meta:
-        ordering = ['provider', 'cid']
+        ordering = ['provider', 'provider_account', 'cid']
         constraints = (
         constraints = (
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('provider', 'cid'),
                 fields=('provider', 'cid'),
                 name='%(app_label)s_%(class)s_unique_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):
     def __str__(self):
@@ -128,6 +140,12 @@ class Circuit(PrimaryModel):
     def get_status_color(self):
     def get_status_color(self):
         return CircuitStatusChoices.colors.get(self.status)
         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(
 class CircuitTermination(
     CustomFieldsMixin,
     CustomFieldsMixin,

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

@@ -1,5 +1,6 @@
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.contenttypes.fields import GenericRelation
 from django.db import models
 from django.db import models
+from django.db.models import Q
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
@@ -8,6 +9,7 @@ from netbox.models import PrimaryModel
 __all__ = (
 __all__ = (
     'ProviderNetwork',
     'ProviderNetwork',
     'Provider',
     'Provider',
+    'ProviderAccount',
 )
 )
 
 
 
 
@@ -30,20 +32,13 @@ class Provider(PrimaryModel):
         related_name='providers',
         related_name='providers',
         blank=True
         blank=True
     )
     )
-    account = models.CharField(
-        max_length=30,
-        blank=True,
-        verbose_name='Account number'
-    )
 
 
     # Generic relations
     # Generic relations
     contacts = GenericRelation(
     contacts = GenericRelation(
         to='tenancy.ContactAssignment'
         to='tenancy.ContactAssignment'
     )
     )
 
 
-    clone_fields = (
-        'account',
-    )
+    clone_fields = ()
 
 
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
@@ -55,6 +50,54 @@ class Provider(PrimaryModel):
         return reverse('circuits:provider', args=[self.pk])
         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):
 class ProviderNetwork(PrimaryModel):
     """
     """
     This represents a provider network which exists outside of NetBox, the details of which are unknown or
     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
     model = models.Provider
     fields = (
     fields = (
         ('name', 100),
         ('name', 100),
-        ('account', 200),
         ('description', 500),
         ('description', 500),
         ('comments', 5000),
         ('comments', 5000),
     )
     )
 
 
 
 
+class ProviderAccountIndex(SearchIndex):
+    model = models.ProviderAccount
+    fields = (
+        ('name', 100),
+        ('account', 200),
+        ('comments', 5000),
+    )
+
+
 @register_search
 @register_search
 class ProviderNetworkIndex(SearchIndex):
 class ProviderNetworkIndex(SearchIndex):
     model = models.ProviderNetwork
     model = models.ProviderNetwork

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

@@ -1,4 +1,5 @@
 import django_tables2 as tables
 import django_tables2 as tables
+
 from circuits.models import *
 from circuits.models import *
 from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
 from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
 
 
@@ -50,6 +51,10 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     provider = tables.Column(
     provider = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    provider_account = tables.Column(
+        linkify=True,
+        verbose_name='Account'
+    )
     status = columns.ChoiceFieldColumn()
     status = columns.ChoiceFieldColumn()
     termination_a = tables.TemplateColumn(
     termination_a = tables.TemplateColumn(
         template_code=CIRCUITTERMINATION_LINK,
         template_code=CIRCUITTERMINATION_LINK,
@@ -68,9 +73,9 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Circuit
         model = Circuit
         fields = (
         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 = (
         default_columns = (
             'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',
             '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__ = (
 __all__ = (
     'ProviderTable',
     'ProviderTable',
+    'ProviderAccountTable',
     'ProviderNetworkTable',
     'ProviderNetworkTable',
 )
 )
 
 
@@ -15,6 +16,16 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
     name = tables.Column(
         linkify=True
         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(
     asns = columns.ManyToManyColumn(
         linkify_item=True,
         linkify_item=True,
         verbose_name='ASNs'
         verbose_name='ASNs'
@@ -39,10 +50,38 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Provider
         model = Provider
         fields = (
         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):
 class ProviderNetworkTable(NetBoxTable):

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

@@ -20,7 +20,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
     model = Provider
     model = Provider
     brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url']
     brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url']
     bulk_update_data = {
     bulk_update_data = {
-        'account': '1234',
+        'comments': 'New comments',
     }
     }
 
 
     @classmethod
     @classmethod
@@ -106,6 +106,12 @@ class CircuitTest(APIViewTestCases.APIViewTestCase):
         )
         )
         Provider.objects.bulk_create(providers)
         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 = (
         circuit_types = (
             CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
             CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
             CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
             CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
@@ -113,9 +119,9 @@ class CircuitTest(APIViewTestCases.APIViewTestCase):
         CircuitType.objects.bulk_create(circuit_types)
         CircuitType.objects.bulk_create(circuit_types)
 
 
         circuits = (
         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)
         Circuit.objects.bulk_create(circuits)
 
 
@@ -123,16 +129,19 @@ class CircuitTest(APIViewTestCases.APIViewTestCase):
             {
             {
                 'cid': 'Circuit 4',
                 'cid': 'Circuit 4',
                 'provider': providers[1].pk,
                 'provider': providers[1].pk,
+                'provider_account': provider_accounts[1].pk,
                 'type': circuit_types[1].pk,
                 'type': circuit_types[1].pk,
             },
             },
             {
             {
                 'cid': 'Circuit 5',
                 'cid': 'Circuit 5',
                 'provider': providers[1].pk,
                 'provider': providers[1].pk,
+                'provider_account': provider_accounts[1].pk,
                 'type': circuit_types[1].pk,
                 'type': circuit_types[1].pk,
             },
             },
             {
             {
                 'cid': 'Circuit 6',
                 'cid': 'Circuit 6',
                 'provider': providers[1].pk,
                 'provider': providers[1].pk,
+                'provider_account': provider_accounts[1].pk,
                 'type': circuit_types[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):
 class ProviderNetworkTest(APIViewTestCases.APIViewTestCase):
     model = ProviderNetwork
     model = ProviderNetwork
     brief_fields = ['display', 'id', 'name', 'url']
     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)
         ASN.objects.bulk_create(asns)
 
 
         providers = (
         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)
         Provider.objects.bulk_create(providers)
         providers[0].asns.set([asns[0]])
         providers[0].asns.set([asns[0]])
@@ -64,8 +64,8 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
         CircuitType.objects.bulk_create(circuit_types)
         CircuitType.objects.bulk_create(circuit_types)
 
 
         circuits = (
         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)
         Circuit.objects.bulk_create(circuits)
 
 
@@ -87,10 +87,6 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'asn_id': [asns[0].pk, asns[1].pk]}
         params = {'asn_id': [asns[0].pk, asns[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
     def test_region(self):
         regions = Region.objects.all()[:2]
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
         params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -193,9 +189,17 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
         providers = (
         providers = (
             Provider(name='Provider 1', slug='provider-1'),
             Provider(name='Provider 1', slug='provider-1'),
             Provider(name='Provider 2', slug='provider-2'),
             Provider(name='Provider 2', slug='provider-2'),
+            Provider(name='Provider 3', slug='provider-3'),
         )
         )
         Provider.objects.bulk_create(providers)
         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 = (
         provider_networks = (
             ProviderNetwork(name='Provider Network 1', provider=providers[1]),
             ProviderNetwork(name='Provider Network 1', provider=providers[1]),
             ProviderNetwork(name='Provider Network 2', 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)
         ProviderNetwork.objects.bulk_create(provider_networks)
 
 
         circuits = (
         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)
         Circuit.objects.bulk_create(circuits)
 
 
@@ -246,6 +250,11 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'provider': [provider.slug]}
         params = {'provider': [provider.slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         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):
     def test_provider_network(self):
         provider_networks = ProviderNetwork.objects.all()[:2]
         provider_networks = ProviderNetwork.objects.all()[:2]
         params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]}
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'provider': [providers[0].slug, providers[1].slug]}
         params = {'provider': [providers[0].slug, providers[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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',
             'name': 'Provider X',
             'slug': 'provider-x',
             'slug': 'provider-x',
             'asns': [asns[6].pk, asns[7].pk],
             'asns': [asns[6].pk, asns[7].pk],
-            'account': '1234',
             'comments': 'Another provider',
             'comments': 'Another provider',
             'tags': [t.pk for t in tags],
             'tags': [t.pk for t in tags],
         }
         }
@@ -58,7 +57,6 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
 
 
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
-            'account': '5678',
             'comments': 'New comments',
             'comments': 'New comments',
         }
         }
 
 
@@ -124,6 +122,12 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
         Provider.objects.bulk_create(providers)
         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 = (
         circuittypes = (
             CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
             CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
             CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
             CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
@@ -131,9 +135,9 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         CircuitType.objects.bulk_create(circuittypes)
         CircuitType.objects.bulk_create(circuittypes)
 
 
         circuits = (
         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)
         Circuit.objects.bulk_create(circuits)
@@ -143,6 +147,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         cls.form_data = {
         cls.form_data = {
             'cid': 'Circuit X',
             'cid': 'Circuit X',
             'provider': providers[1].pk,
             'provider': providers[1].pk,
+            'provider_account': provider_accounts[1].pk,
             'type': circuittypes[1].pk,
             'type': circuittypes[1].pk,
             'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
             'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
             'tenant': None,
             'tenant': None,
@@ -155,10 +160,10 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         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 = (
         cls.csv_update_data = (
@@ -170,6 +175,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
             'provider': providers[1].pk,
             'provider': providers[1].pk,
+            'provider_account': provider_accounts[1].pk,
             'type': circuittypes[1].pk,
             'type': circuittypes[1].pk,
             'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
             'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
             'tenant': None,
             '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):
 class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = ProviderNetwork
     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/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
     path('providers/<int:pk>/', include(get_model_urls('circuits', 'provider'))),
     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
     # Provider networks
     path('provider-networks/', views.ProviderNetworkListView.as_view(), name='providernetwork_list'),
     path('provider-networks/', views.ProviderNetworkListView.as_view(), name='providernetwork_list'),
     path('provider-networks/add/', views.ProviderNetworkEditView.as_view(), name='providernetwork_add'),
     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):
     def get_extra_context(self, request, instance):
         related_models = (
         related_models = (
+            (ProviderAccount.objects.restrict(request.user, 'view').filter(provider=instance), 'provider_id'),
             (Circuit.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
     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
 # Provider networks
 #
 #

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

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

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

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

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

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

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

@@ -52,6 +52,14 @@
     </div>
     </div>
 </div>
 </div>
 <div class="row mb-3">
 <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="col col-md-12">
     <div class="card">
     <div class="card">
       <h5 class="card-header">Circuits</h5>
       <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 %}