diff --git a/docs/development/models.md b/docs/development/models.md index 113173891..6db61531b 100644 --- a/docs/development/models.md +++ b/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) diff --git a/docs/development/search.md b/docs/development/search.md index 02bcaa898..6ccffa7af 100644 --- a/docs/development/search.md +++ b/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 | - | diff --git a/docs/features/circuits.md b/docs/features/circuits.md index 7739efb4c..5afbcafb5 100644 --- a/docs/features/circuits.md +++ b/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. diff --git a/docs/features/contacts.md b/docs/features/contacts.md index 40e8dd12c..ed182134c 100644 --- a/docs/features/contacts.md +++ b/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 diff --git a/docs/getting-started/planning.md b/docs/getting-started/planning.md index 5dbe6e54e..9641cd98b 100644 --- a/docs/getting-started/planning.md +++ b/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 diff --git a/docs/models/circuits/circuit.md b/docs/models/circuits/circuit.md index 50637ab4e..19fd8c882 100644 --- a/docs/models/circuits/circuit.md +++ b/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.) diff --git a/docs/models/circuits/provider.md b/docs/models/circuits/provider.md index 2598eeec4..4caa02a77 100644 --- a/docs/models/circuits/provider.md +++ b/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. diff --git a/docs/models/circuits/provideraccount.md b/docs/models/circuits/provideraccount.md new file mode 100644 index 000000000..f906c657e --- /dev/null +++ b/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. \ No newline at end of file diff --git a/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py index 8fc1bfaf7..0f19ccffc 100644 --- a/netbox/circuits/api/nested_serializers.py +++ b/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 # diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 816c696d2..1ec8913c1 100644 --- a/netbox/circuits/api/serializers.py +++ b/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', ] diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index 9d75009d5..fcb7a1a51 100644 --- a/netbox/circuits/api/urls.py +++ b/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 diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index f5f3f0fab..bd9431887 100644 --- a/netbox/circuits/api/views.py +++ b/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 # diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 8e4c9ab06..e28238fea 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -16,6 +16,7 @@ __all__ = ( 'CircuitTerminationFilterSet', 'CircuitTypeFilterSet', 'ProviderNetworkFilterSet', + 'ProviderAccountFilterSet', 'ProviderFilterSet', ) @@ -66,7 +67,34 @@ 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(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(): @@ -75,7 +103,7 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): Q(name__icontains=value) | Q(account__icontains=value) | Q(comments__icontains=value) - ) + ).distinct() class ProviderNetworkFilterSet(NetBoxModelFilterSet): @@ -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(), diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 8fd09d3ee..39cdd85d0 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/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', 'account', 'description', 'comments', + '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 = ( + '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 = ( diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index c1a0056c4..690cea828 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/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' ] diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 05dacfd38..aeeddfd36 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/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, diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index 537db50df..8aeaa9619 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/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(), diff --git a/netbox/circuits/graphql/schema.py b/netbox/circuits/graphql/schema.py index 32b73e258..3d85f2512 100644 --- a/netbox/circuits/graphql/schema.py +++ b/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) diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index 5582de798..baa135e00 100644 --- a/netbox/circuits/graphql/types.py +++ b/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: diff --git a/netbox/circuits/migrations/0042_provideraccount.py b/netbox/circuits/migrations/0042_provideraccount.py new file mode 100644 index 000000000..3e583844e --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 1c9f9682e..f629c0b30 100644 --- a/netbox/circuits/models/circuits.py +++ b/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, diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index abd5cc7a1..52eb26c98 100644 --- a/netbox/circuits/models/providers.py +++ b/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 diff --git a/netbox/circuits/search.py b/netbox/circuits/search.py index 2859295d5..b80f92d4d 100644 --- a/netbox/circuits/search.py +++ b/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 diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index b3f62d5fc..e8bdf6a92 100644 --- a/netbox/circuits/tables/circuits.py +++ b/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', diff --git a/netbox/circuits/tables/providers.py b/netbox/circuits/tables/providers.py index 9de8d25b2..ef8a6cd38 100644 --- a/netbox/circuits/tables/providers.py +++ b/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', 'circuit_count') + 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', 'account', 'name', 'provider', 'circuit_count') class ProviderNetworkTable(NetBoxTable): diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index c9d2cfc40..1969441eb 100644 --- a/netbox/circuits/tests/test_api.py +++ b/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'] diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 897c87c05..e3380a1e5 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/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) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 231d6a43c..85e2304cf 100644 --- a/netbox/circuits/tests/test_views.py +++ b/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 diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index d8c5ea276..55a192c64 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -14,6 +14,14 @@ urlpatterns = [ path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), path('providers//', 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//', 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'), diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 25620a852..fc9540d16 100644 --- a/netbox/circuits/views.py +++ b/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 # diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 6041797c2..2b1428d27 100644 --- a/netbox/netbox/navigation/menu.py +++ b/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')), ), ), diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 81ff6e912..ee994e959 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -18,6 +18,10 @@ Provider {{ object.provider|linkify }} + + Account + {{ object.provider_account|linkify|placeholder }} + Circuit ID {{ object.cid }} diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index acfb8d0ca..63da79a36 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -7,7 +7,6 @@
Circuit Termination
- {% render_field form.provider %} {% render_field form.circuit %} {% render_field form.term_side %} {% render_field form.tags %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 3973d2867..2c076543c 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -52,6 +52,14 @@
+
+
+
Provider Accounts
+
+
Circuits
diff --git a/netbox/templates/circuits/provideraccount.html b/netbox/templates/circuits/provideraccount.html new file mode 100644 index 000000000..63344ada1 --- /dev/null +++ b/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 }} + +{% endblock %} + +{% block content %} +
+
+
+
Provider Account
+
+ + + + + + + + + + + + + +
Provider{{ object.provider|linkify }}
Account{{ object.account }}
Name{{ object.name|placeholder }}
+
+
+ {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
+
+ {% 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 %} +
+
+
+
Circuits
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %}